← back to jeonghoon0126__covering-spot

Function bodies 369 total

All specs Real LLM only Function bodies
buildSlotGroups function · typescript · L348-L352 (5 LOC)
src/app/api/admin/dispatch-auto/route.ts
function buildSlotGroups(
  bookings: DispatchBooking[],
  drivers: DispatchDriver[],
  driverSlotFilters: Record<string, string[]>,
): Array<{ bookings: DispatchBooking[]; drivers: DispatchDriver[] }> {
mergeDispatchResults function · typescript · L394-L424 (31 LOC)
src/app/api/admin/dispatch-auto/route.ts
function mergeDispatchResults(results: (AutoDispatchResult | null)[]): AutoDispatchResult {
  const merged: AutoDispatchResult = {
    plan: [],
    unassigned: [],
    stats: { totalBookings: 0, assigned: 0, unassigned: 0, totalDistance: 0 },
  };

  for (const result of results) {
    if (!result) continue;
    for (const dp of result.plan) {
      const existing = merged.plan.find((p) => p.driverId === dp.driverId);
      if (existing) {
        const offset = existing.bookings.length;
        existing.bookings.push(...dp.bookings.map((b, i) => ({ ...b, routeOrder: offset + i + 1 })));
        existing.totalLoad += dp.totalLoad;
        existing.totalDistance += dp.totalDistance;
        existing.legs += dp.legs;
        existing.unloadingStops.push(...dp.unloadingStops);
      } else {
        merged.plan.push({ ...dp });
      }
    }
    merged.unassigned.push(...result.unassigned);
    merged.stats.assigned += result.stats.assigned;
    merged.stats.unassigned += result.stats.un
PUT function · typescript · L460-L560 (101 LOC)
src/app/api/admin/dispatch-auto/route.ts
export async function PUT(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }

    const body = await req.json();
    const parsed = ApplySchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json({ error: "잘못된 요청", details: parsed.error.format() }, { status: 400 });
    }

    const { plan } = parsed.data;

    // driverName 서버 조회 (비활성 기사 포함 — 배차 시점에 비활성화될 수 있음)
    const allDrivers = await getDrivers(false);
    const driverNameMap = new Map(allDrivers.map((d) => [d.id, d.name]));

    // driverId 유효성 검증 (DB에 없는 기사 → 즉시 거부)
    const unknownDriverIds = plan
      .map((dp) => dp.driverId)
      .filter((id) => !driverNameMap.has(id));
    if (unknownDriverIds.length > 0) {
      return NextResponse.json(
        { error: "존재하지 않는 기사 ID가 포함되어 있습니다", unknownDriverIds },
        { status: 400 },
      );
    }

    const succeeded: string[] = [];
    const failed: string[] = [];
PATCH function · typescript · L568-L650 (83 LOC)
src/app/api/admin/dispatch-auto/route.ts
export async function PATCH(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }

    const date = req.nextUrl.searchParams.get("date");
    if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
      return NextResponse.json({ error: "date 파라미터가 필요합니다 (YYYY-MM-DD)" }, { status: 400 });
    }

    const [allBookings, allDrivers, unloadingPoints] = await Promise.all([
      getBookings(date),
      getDrivers(false),
      getUnloadingPoints(true),
    ]);

    const driverMap = new Map(allDrivers.map((d) => [d.id, d]));

    // 배차 완료된 주문만 (driverId + routeOrder 있는 것)
    const dispatchedBookings = allBookings.filter(
      (b) => b.driverId && b.routeOrder != null && b.latitude != null && b.longitude != null,
    );
    if (dispatchedBookings.length === 0) {
      return NextResponse.json({ updated: 0, message: "배차된 주문이 없습니다" });
    }

    // 기사별 그룹핑
    const byDriver = new Map<string, typeof dispatchedB
PUT function · typescript · L29-L83 (55 LOC)
src/app/api/admin/dispatch/route-order/route.ts
export async function PUT(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }

    const body = await req.json().catch(() => null);
    if (!body) {
      return NextResponse.json({ error: "요청 본문이 필요합니다" }, { status: 400 });
    }

    const parsed = RouteOrderSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json(
        { error: "잘못된 요청", details: parsed.error.format() },
        { status: 400 },
      );
    }

    const { updates } = parsed.data;

    const results = await Promise.allSettled(
      updates.map(({ bookingId, routeOrder }) =>
        updateBooking(bookingId, { routeOrder } as Partial<Booking>),
      ),
    );

    const succeeded: string[] = [];
    const failed: string[] = [];

    results.forEach((result, idx) => {
      if (result.status === "fulfilled" && result.value !== null) {
        succeeded.push(updates[idx].bookingId);
      } else {
        
GET function · typescript · L12-L74 (63 LOC)
src/app/api/admin/dispatch/route.ts
export async function GET(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }

    const date = req.nextUrl.searchParams.get("date");
    if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
      return NextResponse.json({ error: "date 파라미터가 필요합니다 (YYYY-MM-DD)" }, { status: 400 });
    }

    const [bookings, drivers] = await Promise.all([
      getBookings(date),
      getDrivers(true),
    ]);

    // 기사별 통계 계산
    const driverStatsMap = new Map<string, {
      driverId: string;
      driverName: string;
      vehicleType: string;
      vehicleCapacity: number;
      licensePlate: string | null;
      assignedCount: number;
      totalLoadingCube: number;
    }>();

    // 모든 활성 기사를 초기화
    for (const driver of drivers) {
      driverStatsMap.set(driver.id, {
        driverId: driver.id,
        driverName: driver.name,
        vehicleType: driver.vehicleType,
        vehicleCapacity: driver.vehicleCa
POST function · typescript · L87-L201 (115 LOC)
src/app/api/admin/dispatch/route.ts
export async function POST(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }

    const body = await req.json();
    const parsed = BatchDispatchSchema.safeParse(body);

    if (!parsed.success) {
      return NextResponse.json(
        { error: "잘못된 요청", details: parsed.error.format() },
        { status: 400 }
      );
    }

    const { bookingIds, driverId, date } = parsed.data;

    // 항상 활성 기사 목록 조회 (기사 유효성 검증 + 근무요일 체크에 사용)
    const drivers = await getDrivers(true);
    const driver = drivers.find((d) => d.id === driverId);

    // 기사 유효성 검증: 비활성 또는 존재하지 않는 기사 차단
    if (!driver) {
      return NextResponse.json(
        { error: "기사를 찾을 수 없거나 비활성 상태입니다" },
        { status: 422 }
      );
    }

    // 근무요일 체크: date가 제공된 경우 해당 기사의 근무요일 확인
    if (date && driver.workDays) {
      const KO_DAYS = ["일", "월", "화", "수", "목", "금", "토"] as const;
      const dayOfWeek = KO_DAYS[new Date(date + "T0
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
GET function · typescript · L12-L33 (22 LOC)
src/app/api/admin/drivers/route.ts
export async function GET(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json(
        { error: "인증이 필요합니다" },
        { status: 401 },
      );
    }

    const activeParam = req.nextUrl.searchParams.get("active");
    const activeOnly = activeParam !== "false";

    const drivers = await getDrivers(activeOnly);
    return NextResponse.json({ drivers });
  } catch (e) {
    console.error("[admin/drivers/GET]", e);
    return NextResponse.json(
      { error: "조회 실패" },
      { status: 500 },
    );
  }
}
validateWorkDays function · typescript · L39-L42 (4 LOC)
src/app/api/admin/drivers/route.ts
function validateWorkDays(val: string): boolean {
  if (!val) return false;
  return val.split(",").every((d) => VALID_WORK_DAYS.includes(d.trim()));
}
validateWorkSlots function · typescript · L46-L49 (4 LOC)
src/app/api/admin/drivers/route.ts
function validateWorkSlots(val: string): boolean {
  if (!val) return true; // 빈 문자열 = 모든 슬롯 허용
  return val.split(",").every((s) => VALID_WORK_SLOTS.includes(s.trim()));
}
POST function · typescript · L64-L106 (43 LOC)
src/app/api/admin/drivers/route.ts
export async function POST(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json(
        { error: "인증이 필요합니다" },
        { status: 401 },
      );
    }

    let body: unknown;
    try { body = await req.json(); } catch {
      return NextResponse.json({ error: "유효하지 않은 JSON입니다" }, { status: 400 });
    }

    const parsed = createDriverSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json(
        { error: parsed.error.issues[0].message },
        { status: 400 },
      );
    }

    const { name, phone, vehicleType, vehicleCapacity, licensePlate, workDays, workSlots, initialLoadCube, startAddress, endAddress } = parsed.data;
    // 주소 → 좌표 변환 (병렬)
    const [startCoords, endCoords] = await Promise.all([
      startAddress ? geocodeAddress(startAddress) : Promise.resolve(null),
      endAddress ? geocodeAddress(endAddress) : Promise.resolve(null),
    ]);
    const driver = await createDriver(
      name, phone, vehicleT
PUT function · typescript · L123-L212 (90 LOC)
src/app/api/admin/drivers/route.ts
export async function PUT(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json(
        { error: "인증이 필요합니다" },
        { status: 401 },
      );
    }

    let body: unknown;
    try { body = await req.json(); } catch {
      return NextResponse.json({ error: "유효하지 않은 JSON입니다" }, { status: 400 });
    }

    const parsed = updateDriverSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json(
        { error: parsed.error.issues[0].message },
        { status: 400 },
      );
    }

    const { id, name, phone, active, vehicleType, vehicleCapacity, licensePlate, workDays, workSlots, initialLoadCube, startAddress, endAddress } = parsed.data;

    const updates: {
      name?: string;
      phone?: string;
      active?: boolean;
      vehicleType?: string;
      vehicleCapacity?: number;
      licensePlate?: string;
      workDays?: string;
      workSlots?: string;
      initialLoadCube?: number;
      startAddress?: string 
DELETE function · typescript · L214-L253 (40 LOC)
src/app/api/admin/drivers/route.ts
export async function DELETE(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json(
        { error: "인증이 필요합니다" },
        { status: 401 },
      );
    }

    const id = req.nextUrl.searchParams.get("id");
    if (!id) {
      return NextResponse.json(
        { error: "id 파라미터가 필요합니다" },
        { status: 400 },
      );
    }
    if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
      return NextResponse.json(
        { error: "유효하지 않은 id 형식입니다" },
        { status: 400 },
      );
    }

    const deleted = await deleteDriver(id);
    if (!deleted) {
      return NextResponse.json(
        { error: "기사를 찾을 수 없습니다" },
        { status: 404 },
      );
    }

    return NextResponse.json({ success: true });
  } catch (e) {
    console.error("[admin/drivers/DELETE]", e);
    return NextResponse.json(
      { error: "삭제 실패" },
      { status: 500 },
    );
  }
}
GET function · typescript · L11-L22 (12 LOC)
src/app/api/admin/unloading-points/route.ts
export async function GET(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }
    const points = await getUnloadingPoints(false); // 비활성 포함 전체 조회
    return NextResponse.json({ points });
  } catch (e) {
    console.error("[unloading-points/GET]", e);
    return NextResponse.json({ error: "조회 실패" }, { status: 500 });
  }
}
POST function · typescript · L34-L59 (26 LOC)
src/app/api/admin/unloading-points/route.ts
export async function POST(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }
    const body = await req.json();
    const parsed = CreateSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json({ error: "잘못된 요청", details: parsed.error.format() }, { status: 400 });
    }

    const { name, address } = parsed.data;

    // 지오코딩
    const coords = await geocodeAddress(address);
    if (!coords) {
      return NextResponse.json({ error: "주소를 찾을 수 없습니다. 정확한 도로명 주소를 입력해주세요." }, { status: 400 });
    }

    const point = await createUnloadingPoint(name, address, coords.lat, coords.lng);
    return NextResponse.json({ point }, { status: 201 });
  } catch (e) {
    console.error("[unloading-points/POST]", e);
    return NextResponse.json({ error: "생성 실패" }, { status: 500 });
  }
}
Repobility — the code-quality scanner for AI-generated software · https://repobility.com
PUT function · typescript · L77-L109 (33 LOC)
src/app/api/admin/unloading-points/route.ts
export async function PUT(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }
    const body = await req.json();
    const parsed = UpdateSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json({ error: "잘못된 요청", details: parsed.error.format() }, { status: 400 });
    }

    const { id, ...updates } = parsed.data;

    // 주소 변경 시 재지오코딩
    let coords: { latitude: number; longitude: number } | undefined;
    if (updates.address) {
      const geo = await geocodeAddress(updates.address);
      if (!geo) {
        return NextResponse.json({ error: "주소를 찾을 수 없습니다" }, { status: 400 });
      }
      coords = { latitude: geo.lat, longitude: geo.lng };
    }

    const point = await updateUnloadingPoint(id, { ...updates, ...coords });
    if (!point) {
      return NextResponse.json({ error: "하차지를 찾을 수 없습니다" }, { status: 404 });
    }
    return NextResponse.json({ point });
  } cat
DELETE function · typescript · L117-L137 (21 LOC)
src/app/api/admin/unloading-points/route.ts
export async function DELETE(req: NextRequest) {
  try {
    if (!validateToken(req)) {
      return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
    }
    const body = await req.json();
    const parsed = DeleteSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json({ error: "잘못된 요청" }, { status: 400 });
    }

    const deleted = await deleteUnloadingPoint(parsed.data.id);
    if (!deleted) {
      return NextResponse.json({ error: "하차지를 찾을 수 없습니다" }, { status: 404 });
    }
    return NextResponse.json({ success: true });
  } catch (e) {
    console.error("[unloading-points/DELETE]", e);
    return NextResponse.json({ error: "삭제 실패" }, { status: 500 });
  }
}
GET function · typescript · L4-L6 (3 LOC)
src/app/api/areas/route.ts
export async function GET() {
  return NextResponse.json({ areas: SPOT_AREAS });
}
GET function · typescript · L39-L86 (48 LOC)
src/app/api/bookings/[id]/route.ts
export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  try {
    const { id } = await params;
    const booking = await getBookingById(id);
    if (!booking) {
      return NextResponse.json(
        { error: "예약을 찾을 수 없습니다" },
        { status: 404 },
      );
    }
    // GET by ID: UUID로만 접근 가능 (추측 불가)
    // 토큰 없는 요청에는 전화번호 마스킹 (IDOR 대응)
    const hasToken = validateBookingToken(_req, booking.phone);
    if (!hasToken) {
      const masked = { ...booking };
      // 전화번호 마스킹: 010-1234-5678 → 010-****-5678
      if (masked.phone) {
        masked.phone = masked.phone.replace(/(\d{3})[-]?\d{4}[-]?(\d{4})/, "$1-****-$2");
      }
      // 이름 마스킹: "홍길동" → "홍*동", "홍길" → "홍*"
      if (masked.customerName && masked.customerName.length >= 2) {
        masked.customerName =
          masked.customerName[0] +
          "*".repeat(Math.max(1, masked.customerName.length - 2)) +
          (masked.customerName.length >= 3 ? masked.customerName.s
PUT function · typescript · L88-L206 (119 LOC)
src/app/api/bookings/[id]/route.ts
export async function PUT(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  try {
    const { id } = await params;
    const existing = await getBookingById(id);
    if (!existing) {
      return NextResponse.json(
        { error: "예약을 찾을 수 없습니다" },
        { status: 404 },
      );
    }

    // 토큰 검증: phone 기반 토큰 필수
    if (!validateBookingToken(req, existing.phone)) {
      return NextResponse.json(
        { error: "인증이 필요합니다" },
        { status: 401 },
      );
    }

    const body = await req.json();

    // 유저 견적 확인 완료 처리 (action: "user_confirm")
    if (body.action === "user_confirm") {
      if (existing.status !== "quote_confirmed") {
        return NextResponse.json(
          { error: "견적 확정 상태에서만 확인할 수 있습니다" },
          { status: 400 },
        );
      }
      const updated = await updateBooking(id, { status: "user_confirmed" });
      if (!updated) {
        return NextResponse.json({ error: "수정 실패" }, { status: 500 });
      }
      sendS
DELETE function · typescript · L208-L256 (49 LOC)
src/app/api/bookings/[id]/route.ts
export async function DELETE(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  try {
    const { id } = await params;
    const booking = await getBookingById(id);
    if (!booking) {
      return NextResponse.json(
        { error: "예약을 찾을 수 없습니다" },
        { status: 404 },
      );
    }

    // 토큰 검증: phone 기반 토큰 필수
    if (!validateBookingToken(req, booking.phone)) {
      return NextResponse.json(
        { error: "인증이 필요합니다" },
        { status: 401 },
      );
    }

    // pending, quote_confirmed, user_confirmed, change_requested 상태에서만 취소 가능
    if (booking.status !== "pending" && booking.status !== "quote_confirmed" && booking.status !== "user_confirmed" && booking.status !== "change_requested") {
      return NextResponse.json(
        { error: "수거 진행 중에는 취소할 수 없습니다" },
        { status: 400 },
      );
    }

    // 수거일 전날 22시(KST) 이후에는 취소 불가
    if (new Date() >= getCustomerDeadline(booking.date)) {
      return NextResponse.json(
        { err
GET function · typescript · L19-L79 (61 LOC)
src/app/api/bookings/route.ts
export async function GET(req: NextRequest) {
  try {
    // Rate limiting: 20 requests per IP per 60s
    const ip = getRateLimitKey(req);
    const rl = rateLimit(`${ip}:/api/bookings/GET`, 20, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { error: "잠시 후 다시 시도해주세요", retryAfter: rl.retryAfter },
        {
          status: 429,
          headers: { "Retry-After": String(rl.retryAfter) },
        },
      );
    }

    const phone = req.nextUrl.searchParams.get("phone");
    if (!phone) {
      return NextResponse.json(
        { error: "phone 파라미터가 필요합니다" },
        { status: 400 },
      );
    }

    // 전화번호 형식 검증
    const digits = phone.replace(/[^\d]/g, "");
    const parsed = PhoneSchema.safeParse(digits);
    if (!parsed.success) {
      return NextResponse.json(
        { error: "올바른 전화번호 형식이 아닙니다" },
        { status: 400 },
      );
    }


    // 전화번호별 rate limit: 5회/5분 (전화번호 열거 공격 방어 — 동일 번호로 반복 시도 제한)
    const phoneRl = rateLimit(`phone:${digits
POST function · typescript · L81-L200 (120 LOC)
src/app/api/bookings/route.ts
export async function POST(req: NextRequest) {
  try {
    // Rate limiting: 5 requests per IP per 60s (prevents spam bookings)
    const ip = getRateLimitKey(req);
    const rl = rateLimit(`${ip}:/api/bookings/POST`, 5, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { error: "잠시 후 다시 시도해주세요", retryAfter: rl.retryAfter },
        {
          status: 429,
          headers: { "Retry-After": String(rl.retryAfter) },
        },
      );
    }

    const body = await req.json();

    // Zod 서버사이드 검증
    const parsed = BookingCreateSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json(
        { error: "입력값이 올바르지 않습니다", fields: parsed.error.flatten().fieldErrors },
        { status: 400 },
      );
    }

    const validData = parsed.data;

    // 전날 12시 마감 정책 검증
    if (!isDateBookable(validData.date)) {
      return NextResponse.json(
        { error: "예약 마감된 날짜입니다. 전날 12시까지 신청 가능합니다." },
        { status: 400 },
      );
    }

    con
Repobility · code-quality intelligence platform · https://repobility.com
POST function · typescript · L31-L85 (55 LOC)
src/app/api/driver/auth/route.ts
export async function POST(req: NextRequest) {
  try {
    const body = await req.json().catch(() => null);
    if (!body || typeof body.phone !== "string") {
      return NextResponse.json({ error: "전화번호를 입력해주세요" }, { status: 400 });
    }

    const rawPhone = body.phone.trim();
    if (!rawPhone) {
      return NextResponse.json({ error: "전화번호를 입력해주세요" }, { status: 400 });
    }

    // 입력 길이 제한 (DoS 방어)
    if (rawPhone.length > MAX_PHONE_LENGTH) {
      return NextResponse.json({ error: "전화번호를 확인해주세요" }, { status: 400 });
    }

    const { formatted, digits } = normalizePhone(rawPhone);

    // 하이픈 포함/미포함 두 형식 모두 조회 (DB 저장 방식 불일치 방어)
    // .in() 사용: .or() raw string 대비 SQL injection 안전
    const phoneVariants = [...new Set([formatted, digits])];
    const { data, error } = await supabase
      .from("drivers")
      .select("id, name, phone, active")
      .eq("active", true)
      .in("phone", phoneVariants)
      .limit(1);

    if (error) {
      console.error("[driver/auth P
PUT function · typescript · L17-L99 (83 LOC)
src/app/api/driver/bookings/[id]/route.ts
export async function PUT(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = validateDriverToken(req);
  if (!auth) {
    return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
  }

  const { id } = await params;
  if (!id || typeof id !== "string") {
    return NextResponse.json({ error: "잘못된 요청입니다" }, { status: 400 });
  }

  const body = await req.json().catch(() => null);
  const newStatus = body?.status;
  if (!newStatus || typeof newStatus !== "string") {
    return NextResponse.json({ error: "상태값이 필요합니다" }, { status: 400 });
  }

  // 허용된 목표 상태인지 확인
  const validTargets = Object.values(ALLOWED_TRANSITIONS);
  if (!validTargets.includes(newStatus)) {
    return NextResponse.json({ error: "변경 불가능한 상태입니다" }, { status: 403 });
  }

  // 현재 예약 조회 (소유권 + 현재 상태 + 고객 전화번호 확인)
  const { data: booking, error: fetchError } = await supabase
    .from("bookings")
    .select("id, status, driver_id, phone")
    .eq("id", id)
    .single();

getKSTDate function · typescript · L10-L14 (5 LOC)
src/app/api/driver/bookings/route.ts
function getKSTDate(offset = 0): string {
  const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
  const kstMs = Date.now() + KST_OFFSET_MS + offset * 24 * 60 * 60 * 1000;
  return new Date(kstMs).toISOString().slice(0, 10);
}
rowToDriverBooking function · typescript · L20-L48 (29 LOC)
src/app/api/driver/bookings/route.ts
function rowToDriverBooking(row: Record<string, unknown>): Partial<Booking> {
  return {
    id: row.id as string,
    date: row.date as string,
    timeSlot: row.time_slot as string,
    area: row.area as string,
    items: (row.items as Booking["items"]) || [],
    totalPrice: (row.total_price as number) || 0,
    crewSize: (row.crew_size as number) || 1,
    needLadder: (row.need_ladder as boolean) || false,
    ladderType: (row.ladder_type as string) || undefined,
    ladderHours: row.ladder_hours != null ? (row.ladder_hours as number) : undefined,
    ladderPrice: (row.ladder_price as number) || 0,
    customerName: row.customer_name as string,
    phone: row.phone as string,
    address: row.address as string,
    addressDetail: (row.address_detail as string) || "",
    memo: (row.memo as string) || "",
    status: row.status as Booking["status"],
    confirmedTime: (row.confirmed_time as string) || null,
    hasElevator: (row.has_elevator as boolean) || false,
    hasParking: (r
GET function · typescript · L51-L84 (34 LOC)
src/app/api/driver/bookings/route.ts
export async function GET(req: NextRequest) {
  const auth = validateDriverToken(req);
  if (!auth) {
    return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
  }

  const today = getKSTDate(0);
  const tomorrow = getKSTDate(1);

  const { data, error } = await supabase
    .from("bookings")
    .select(
      "id, date, time_slot, area, items, total_price, crew_size, " +
      "need_ladder, ladder_type, ladder_hours, ladder_price, " +
      "customer_name, phone, address, address_detail, memo, status, " +
      "confirmed_time, has_elevator, has_parking, driver_id, driver_name, " +
      "total_loading_cube, route_order",
    )
    .eq("driver_id", auth.driverId)
    .in("date", [today, tomorrow])
    .in("status", ["quote_confirmed", "in_progress", "completed"])
    .order("date", { ascending: true })
    .order("route_order", { ascending: true });

  if (error) {
    console.error("[driver/bookings GET]", error);
    return NextResponse.json({ error: "조회 실패" }, { statu
GET function · typescript · L13-L70 (58 LOC)
src/app/api/items/popular/route.ts
export async function GET() {
  try {
    // 3개월 전 날짜 계산
    const threeMonthsAgo = new Date();
    threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
    const since = threeMonthsAgo.toISOString();

    // 실제 예약 데이터에서 items 컬럼만 조회
    // 유효한 상태: 견적확정 ~ 결제완료 (취소/거절/대기 제외)
    const { data, error } = await supabase
      .from("bookings")
      .select("items")
      .in("status", [
        "quote_confirmed",
        "in_progress",
        "completed",
        "payment_requested",
        "payment_completed",
      ])
      .gte("created_at", since);

    if (error || !data) {
      return NextResponse.json({ items: [] });
    }

    // {category, name} 쌍별 빈도 집계
    const freq = new Map<string, PopularItem>();

    for (const row of data) {
      const items = row.items as ItemEntry[] | null;
      if (!Array.isArray(items)) continue;

      for (const item of items) {
        if (!item.category || !item.name) continue;
        const key = `${item.category}::${item.name}`;
        
GET function · typescript · L4-L6 (3 LOC)
src/app/api/items/route.ts
export async function GET() {
  return NextResponse.json({ categories: SPOT_CATEGORIES });
}
POST function · typescript · L20-L61 (42 LOC)
src/app/api/leads/route.ts
export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const parsed = LeadSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json(
        { error: "입력값이 올바르지 않습니다" },
        { status: 400 },
      );
    }

    const d = parsed.data;
    const { data, error } = await supabase
      .from("leads")
      .insert({
        customer_name: d.customerName,
        phone: d.phone,
        address: d.address || null,
        address_detail: d.addressDetail || null,
        items: d.items || null,
        date: d.date || null,
        time_slot: d.timeSlot || null,
        area: d.area || null,
        has_elevator: d.hasElevator ?? null,
        has_parking: d.hasParking ?? null,
        need_ladder: d.needLadder ?? null,
        memo: d.memo || null,
      })
      .select("id")
      .single();

    if (error) throw error;

    return NextResponse.json({ id: data.id }, { status: 201 });
  } catch (e) {
    console.error("[lea
Powered by Repobility — scan your code at https://repobility.com
GET function · typescript · L5-L47 (43 LOC)
src/app/api/og/route.tsx
export async function GET() {
  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          alignItems: "center",
          background: "linear-gradient(135deg, #0A1628 0%, #1A2B4A 100%)",
          padding: "60px",
        }}
      >
        <div
          style={{
            fontSize: 72,
            fontWeight: 700,
            color: "#1AA3FF",
            marginBottom: 20,
          }}
        >
          커버링 방문수거
        </div>
        <div
          style={{
            fontSize: 36,
            color: "#F8FAFC",
            textAlign: "center",
            lineHeight: 1.4,
          }}
        >
          대형폐기물 수거, 온라인으로 바로 예약
        </div>
        <div style={{ fontSize: 24, color: "#94A3B8", marginTop: 20 }}>
          서울 · 경기 · 인천 전 지역 | 사전 견적 = 최종 금액
        </div>
      </div>
    ),
    { width: 1200, height: 630 
POST function · typescript · L16-L91 (76 LOC)
src/app/api/push/send/route.ts
export async function POST(req: NextRequest) {
  try {
    // admin 토큰 검증 (HMAC) 또는 내부 호출 (x-internal-token)
    // ⚠️ ADMIN_PASSWORD 재사용 금지 — 별도 INTERNAL_PUSH_SECRET 환경변수 사용
    const internalSecret = process.env.INTERNAL_PUSH_SECRET;
    const isInternalCall = !!internalSecret && req.headers.get("x-internal-token") === internalSecret;
    if (!isInternalCall && !validateToken(req)) {
      return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 });
    }

    let body: unknown;
    try { body = await req.json(); } catch {
      return NextResponse.json({ error: "유효하지 않은 JSON입니다" }, { status: 400 });
    }

    const parsed = SendSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
    }

    const { bookingId, title, message, url } = parsed.data;

    // 구독 조회
    const { data: subs } = await supabase
      .from("push_subscriptions")
      .select("endpoint, keys")
      .eq("booking_id",
POST function · typescript · L19-L59 (41 LOC)
src/app/api/push/subscribe/route.ts
export async function POST(req: NextRequest) {
  try {
    let body: unknown;
    try { body = await req.json(); } catch {
      return NextResponse.json({ error: "유효하지 않은 JSON입니다" }, { status: 400 });
    }

    const parsed = SubscribeSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
    }

    const { bookingId, subscription } = parsed.data;

    // bookingId가 실제 DB에 존재하는지 검증
    const booking = await getBookingById(bookingId);
    if (!booking) {
      return NextResponse.json({ error: "존재하지 않는 예약입니다" }, { status: 404 });
    }

    // upsert: 같은 booking + endpoint면 업데이트
    const { error } = await supabase.from("push_subscriptions").upsert(
      {
        booking_id: bookingId,
        endpoint: subscription.endpoint,
        keys: subscription.keys,
      },
      { onConflict: "booking_id,endpoint" },
    );

    if (error) {
      console.error("[push/subscribe]", error);
      return 
POST function · typescript · L21-L54 (34 LOC)
src/app/api/quote/route.ts
export async function POST(req: NextRequest) {
  try {
    // Rate limiting: 30 requests per IP per 60s
    const ip = getRateLimitKey(req);
    const rl = rateLimit(`${ip}:/api/quote/POST`, 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { error: "잠시 후 다시 시도해주세요", retryAfter: rl.retryAfter },
        {
          status: 429,
          headers: { "Retry-After": String(rl.retryAfter) },
        },
      );
    }

    const body = await req.json();
    const parsed = QuoteRequestSchema.safeParse(body);
    if (!parsed.success) {
      return NextResponse.json(
        { error: "지역과 품목을 선택해주세요" },
        { status: 400 },
      );
    }

    const result = calculateQuote(parsed.data);
    return NextResponse.json(result);
  } catch (e) {
    console.error("[quote/POST]", e);
    return NextResponse.json(
      { error: "견적 계산 실패" },
      { status: 500 },
    );
  }
}
mapTo2HourSlot function · typescript · L20-L38 (19 LOC)
src/app/api/slots/route.ts
function mapTo2HourSlot(time: string): string {
  const [h, m] = time.split(":").map(Number);
  const totalMinutes = h * 60 + m;

  // 구 슬롯(09:00~10:00) 호환: 가장 가까운 10:00 슬롯으로 매핑
  if (totalMinutes >= 9 * 60 && totalMinutes < 10 * 60) return "10:00";
  // 10:00~12:00 -> "10:00"
  if (totalMinutes >= 10 * 60 && totalMinutes < 12 * 60) return "10:00";
  // 12:00~14:00 -> "12:00"
  if (totalMinutes >= 12 * 60 && totalMinutes < 14 * 60) return "12:00";
  // 14:00~16:00 -> "14:00"
  if (totalMinutes >= 14 * 60 && totalMinutes < 16 * 60) return "14:00";
  // 16:00~18:00 -> "16:00"
  if (totalMinutes >= 16 * 60 && totalMinutes < 18 * 60) return "16:00";
  // 구 슬롯(18:00~19:00) 호환: 가장 가까운 16:00 슬롯으로 매핑
  if (totalMinutes >= 18 * 60 && totalMinutes < 19 * 60) return "16:00";

  return time; // fallback
}
isSlotBlockedByRange function · typescript · L41-L55 (15 LOC)
src/app/api/slots/route.ts
function isSlotBlockedByRange(slotStart: string, timeStart: string, timeEnd: string): boolean {
  // 슬롯의 2시간 범위 계산 (예: "10:00" -> 10:00~12:00)
  const [sh, sm] = slotStart.split(":").map(Number);
  const slotStartMinutes = sh * 60 + sm;
  const slotEndMinutes = slotStartMinutes + 120; // 2시간 = 120분

  // blocked 범위 계산
  const [bh1, bm1] = timeStart.split(":").map(Number);
  const [bh2, bm2] = timeEnd.split(":").map(Number);
  const blockedStartMinutes = bh1 * 60 + bm1;
  const blockedEndMinutes = bh2 * 60 + bm2;

  // 겹침 판정: 슬롯 범위와 blocked 범위가 교집합이 있는지
  return !(slotEndMinutes <= blockedStartMinutes || slotStartMinutes >= blockedEndMinutes);
}
GET function · typescript · L57-L229 (173 LOC)
src/app/api/slots/route.ts
export async function GET(req: NextRequest) {
  try {
    // Rate limiting: 30 requests per IP per 60s
    const ip = getRateLimitKey(req);
    const rl = rateLimit(`${ip}:/api/slots/GET`, 30, 60_000);
    if (!rl.allowed) {
      return NextResponse.json(
        { error: "잠시 후 다시 시도해주세요", retryAfter: rl.retryAfter },
        {
          status: 429,
          headers: { "Retry-After": String(rl.retryAfter) },
        },
      );
    }

    const date = req.nextUrl.searchParams.get("date");
    if (!date) {
      return NextResponse.json(
        { error: "date 파라미터가 필요합니다" },
        { status: 400 },
      );
    }

    // 전날 12시 마감 정책 검증
    if (!isDateBookable(date)) {
      return NextResponse.json(
        { error: "예약 가능 기간이 아닙니다. 전날 12시까지 신청 가능합니다." },
        { status: 400 },
      );
    }

    // 자기 자신의 예약 제외 (admin 시간 확정 시 사용)
    const excludeId = req.nextUrl.searchParams.get("excludeId");

    // 신청 품목의 총 적재량 (m³). 없으면 0 → 기존 방식 fallback
    const loadingCubeParam = req.n
POST function · typescript · L4-L75 (72 LOC)
src/app/api/upload/route.ts
export async function POST(req: NextRequest) {
  try {
    const formData = await req.formData();
    const files = formData.getAll("photos") as File[];

    if (files.length === 0) {
      return NextResponse.json(
        { error: "파일이 필요합니다" },
        { status: 400 },
      );
    }

    if (files.length > 10) {
      return NextResponse.json(
        { error: "최대 10개 파일까지 업로드 가능합니다" },
        { status: 400 },
      );
    }

    const urls: string[] = [];

    for (const file of files) {
      // 안전한 이미지 MIME 타입만 허용 (SVG/HTML 등 XSS 위험 차단)
      const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
      if (!ALLOWED_TYPES.includes(file.type)) {
        return NextResponse.json(
          { error: "JPG, PNG, WebP, HEIC 파일만 업로드 가능합니다" },
          { status: 400 },
        );
      }

      if (file.size > 5 * 1024 * 1024) {
        return NextResponse.json(
          { error: "파일 크기는 5MB 이하만 가능합니다" },
          { status: 400 },
        );
    
Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
BookingCompletePage function · typescript · L15-L28 (14 LOC)
src/app/booking/complete/page.tsx
export default function BookingCompletePage() {
  return (
    <Suspense
      fallback={
        <div className="text-center py-20">
          <LoadingSpinner size="lg" />
          <p className="text-text-muted mt-4 text-sm">로딩 중...</p>
        </div>
      }
    >
      <BookingCompleteContent />
    </Suspense>
  );
}
BookingCompleteContent function · typescript · L30-L322 (293 LOC)
src/app/booking/complete/page.tsx
function BookingCompleteContent() {
  const searchParams = useSearchParams();
  const id = searchParams.get("id");
  const [booking, setBooking] = useState<Booking | null>(null);
  const [error, setError] = useState("");
  const [pushSubscribed, setPushSubscribed] = useState(false);
  const [pushDenied, setPushDenied] = useState(false);
  const [supportsNotification, setSupportsNotification] = useState(false);

  // 마운트 시 알림 권한 체크
  useEffect(() => {
    if (typeof window !== "undefined" && "Notification" in window) {
      setSupportsNotification(true);
      if (Notification.permission === "granted") {
        setPushSubscribed(true);
      } else if (Notification.permission === "denied") {
        setPushDenied(true);
      }
    }
  }, []);

  useEffect(() => {
    if (!id) return;
    fetch(`/api/bookings/${id}`)
      .then((r) => r.json())
      .then((d) => {
        if (d.booking) setBooking(d.booking);
        else setError("신청 정보를 찾을 수 없습니다");
      })
      .catch(() => set
BookingLayout function · typescript · L3-L32 (30 LOC)
src/app/booking/layout.tsx
export default function BookingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-bg-warm">
      <header className="sticky top-0 z-50 bg-bg/80 backdrop-blur-[20px] border-b border-border-light">
        <div className="max-w-[42rem] mx-auto px-4 h-14 flex items-center justify-between">
          <Link href="/" className="flex items-center gap-2">
            <img src="/images/logo.png" alt="커버링" className="w-7 h-7 rounded-sm" />
            <span className="text-lg font-bold text-primary">커버링 방문 수거</span>
          </Link>
          <Link
            href="/booking/manage"
            className="text-sm font-medium text-text-sub hover:text-text-primary transition-colors duration-200"
          >
            신청 조회
          </Link>
        </div>
      </header>
      <main className="max-w-[42rem] mx-auto px-4 py-8">{children}</main>
      <footer className="border-t border-border-light py-6 text-center text-xs text-text-muted sp
canEdit function · typescript · L16-L18 (3 LOC)
src/app/booking/manage/page.tsx
function canEdit(b: Booking): boolean {
  return b.status === "pending" && isBeforeDeadline(b.date);
}
canReschedule function · typescript · L21-L23 (3 LOC)
src/app/booking/manage/page.tsx
function canReschedule(b: Booking): boolean {
  return b.status === "quote_confirmed" && isBeforeDeadline(b.date);
}
canCancel function · typescript · L26-L28 (3 LOC)
src/app/booking/manage/page.tsx
function canCancel(b: Booking): boolean {
  return (b.status === "pending" || b.status === "quote_confirmed" || b.status === "user_confirmed" || b.status === "change_requested") && isBeforeDeadline(b.date);
}
getBookingToken function · typescript · L110-L116 (7 LOC)
src/app/booking/manage/page.tsx
  function getBookingToken(): string | null {
    try {
      return localStorage.getItem("covering_spot_booking_token");
    } catch {
      return null;
    }
  }
handleSearch function · typescript · L118-L134 (17 LOC)
src/app/booking/manage/page.tsx
  async function handleSearch(e: React.FormEvent) {
    e.preventDefault();
    if (!phone.trim()) return;
    setLoading(true);
    setSearched(true);
    try {
      const token = getBookingToken();
      const tokenParam = token ? `&token=${encodeURIComponent(token)}` : "";
      const res = await fetch(`/api/bookings?phone=${encodeURIComponent(phone.trim())}${tokenParam}`);
      const data = await res.json();
      setBookings(data.bookings || []);
    } catch {
      setBookings([]);
    } finally {
      setLoading(false);
    }
  }
Repobility — the code-quality scanner for AI-generated software · https://repobility.com
handleCancel function · typescript · L136-L160 (25 LOC)
src/app/booking/manage/page.tsx
  async function handleCancel(id: string) {
    if (!confirm("정말 신청을 취소하시겠습니까?")) return;
    track("booking_cancel", { bookingId: id });
    setCancelling(id);
    try {
      const token = getBookingToken();
      const headers: Record<string, string> = {};
      if (token) headers["x-booking-token"] = token;
      const res = await fetch(`/api/bookings/${id}`, { method: "DELETE", headers });
      if (res.ok) {
        setBookings((prev) =>
          prev.map((b) =>
            b.id === id ? { ...b, status: "cancelled" as const } : b,
          ),
        );
      } else {
        const err = await res.json().catch(() => ({}));
        alert(err.error || "취소 실패");
      }
    } catch {
      alert("네트워크 오류");
    } finally {
      setCancelling(null);
    }
  }
startEdit function · typescript · L162-L173 (12 LOC)
src/app/booking/manage/page.tsx
  function startEdit(b: Booking) {
    setEditingId(b.id);
    setEditForm({
      date: b.date,
      timeSlot: b.timeSlot,
      addressDetail: b.addressDetail,
      hasElevator: b.hasElevator,
      hasParking: b.hasParking,
      hasGroundAccess: b.hasGroundAccess,
      memo: b.memo,
    });
  }
cancelEdit function · typescript · L175-L178 (4 LOC)
src/app/booking/manage/page.tsx
  function cancelEdit() {
    setEditingId(null);
    setEditForm(null);
  }
‹ prevpage 3 / 8next ›