Function bodies 369 total
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.unPUT 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 dispatchedBPUT 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.vehicleCaPOST 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 + "T0Want 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, vehicleTPUT 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 });
} catDELETE 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.sPUT 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 });
}
sendSDELETE 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(
{ errGET 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:${digitsPOST 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 },
);
}
conRepobility · 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 PPUT 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: (rGET 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: "조회 실패" }, { statuGET 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("[leaPowered 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.nPOST 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(() => setBookingLayout 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 spcanEdit 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);
}