From 32ccbbdc5d056b90adb4a3a63bcf9a2c5f7affa8 Mon Sep 17 00:00:00 2001 From: pearmini Date: Mon, 13 Apr 2026 14:33:05 +0200 Subject: [PATCH 01/51] Support request limit --- booking-app/app/api/bookings/route.ts | 165 ++++++++++++++++ .../routes/components/SchemaProvider.tsx | 12 ++ .../src/client/routes/super/schemaEditor.tsx | 106 +++++++++++ booking-app/package-lock.json | 15 ++ .../scripts/setAutoCancelAllTenants.ts | 115 ++++++++++++ .../tests/unit/request-limits.unit.test.ts | 177 ++++++++++++++++++ 6 files changed, 590 insertions(+) create mode 100644 booking-app/scripts/setAutoCancelAllTenants.ts create mode 100644 booking-app/tests/unit/request-limits.unit.test.ts diff --git a/booking-app/app/api/bookings/route.ts b/booking-app/app/api/bookings/route.ts index 543a5200e..73fb9a381 100644 --- a/booking-app/app/api/bookings/route.ts +++ b/booking-app/app/api/bookings/route.ts @@ -27,6 +27,7 @@ import { serverGetNextSequentialId, serverSaveDataToFirestore, serverGetDocumentById, + serverFetchAllDataFromCollection, } from "@/lib/firebase/server/adminDb"; import { itpBookingMachine } from "@/lib/stateMachines/itpBookingMachine"; import { mcBookingMachine } from "@/lib/stateMachines/mcBookingMachine"; @@ -50,6 +51,7 @@ import { getAffiliationDisplayValues, getOtherDisplayFields, } from "./shared"; +import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; // Common function to create XState data structure export function createXStateData( @@ -183,6 +185,136 @@ const getXStateMachine = (tenant?: string) => { return null; }; +type RequestLimitPeriod = "perDay" | "perWeek" | "perMonth" | "perSemester"; + +function getUtcWindowForPeriod(now: Date, period: RequestLimitPeriod): { start: Date; end: Date } { + const y = now.getUTCFullYear(); + const m = now.getUTCMonth(); // 0-11 + const d = now.getUTCDate(); + + if (period === "perDay") { + const start = new Date(Date.UTC(y, m, d, 0, 0, 0, 0)); + const end = new Date(Date.UTC(y, m, d + 1, 0, 0, 0, 0)); + return { start, end }; + } + + if (period === "perWeek") { + // Week starts Monday (UTC) + const dayOfWeek = now.getUTCDay(); // 0=Sun..6=Sat + const daysSinceMonday = (dayOfWeek + 6) % 7; // Mon->0, Sun->6 + const start = new Date(Date.UTC(y, m, d - daysSinceMonday, 0, 0, 0, 0)); + const end = new Date(Date.UTC(y, m, d - daysSinceMonday + 7, 0, 0, 0, 0)); + return { start, end }; + } + + if (period === "perMonth") { + const start = new Date(Date.UTC(y, m, 1, 0, 0, 0, 0)); + const end = new Date(Date.UTC(y, m + 1, 1, 0, 0, 0, 0)); + return { start, end }; + } + + // perSemester: 4-month windows starting in Jan (UTC): Jan–Apr, May–Aug, Sep–Dec + const semesterStartMonth = Math.floor(m / 4) * 4; + const start = new Date(Date.UTC(y, semesterStartMonth, 1, 0, 0, 0, 0)); + const end = new Date(Date.UTC(y, semesterStartMonth + 4, 1, 0, 0, 0, 0)); + return { start, end }; +} + +function parseRoomIdsFromBooking(doc: any): number[] { + if (Array.isArray(doc?.roomIds)) { + return doc.roomIds + .map((x: any) => Number(x)) + .filter((n: number) => Number.isFinite(n)); + } + + const raw = doc?.roomId; + if (typeof raw === "string") { + return raw + .split(",") + .map((s) => Number(String(s).trim())) + .filter((n) => Number.isFinite(n)); + } + + return []; +} + +async function enforceRequestLimits({ + tenant, + email, + role, + selectedRoomIds, +}: { + tenant: string; + email: string; + role: string; + selectedRoomIds: number[]; +}): Promise<{ ok: true } | { ok: false; message: string }> { + const schema = await serverGetDocumentById( + TableNames.TENANT_SCHEMA, + tenant, + tenant, + ); + + if (!schema?.resources || schema.resources.length === 0) return { ok: true }; + + const resourcesByRoomId = new Map(); + for (const r of schema.resources) { + if (typeof (r as any)?.roomId === "number") { + resourcesByRoomId.set((r as any).roomId, r); + } + } + + const now = new Date(); + const periods: RequestLimitPeriod[] = [ + "perDay", + "perWeek", + "perMonth", + "perSemester", + ]; + + for (const roomId of selectedRoomIds) { + const resource = resourcesByRoomId.get(roomId); + if (!resource) continue; + + for (const period of periods) { + const limit = resource.requestLimits?.[period]?.[role]; + if (limit == null) continue; // missing means unlimited + if (typeof limit !== "number" || !Number.isFinite(limit) || limit < 0) continue; + + const { start, end } = getUtcWindowForPeriod(now, period); + const startTs = Timestamp.fromDate(start); + const endTs = Timestamp.fromDate(end); + + const candidates = await serverFetchAllDataFromCollection( + TableNames.BOOKING, + [ + { field: "email", operator: "==", value: email }, + { field: "requestedAt", operator: ">=", value: startTs }, + { field: "requestedAt", operator: "<", value: endTs }, + // Treat missing fields as null => excluded from count if canceled/declined + { field: "canceledAt", operator: "==", value: null }, + { field: "declinedAt", operator: "==", value: null }, + ], + tenant, + ); + + const count = candidates.filter((doc) => + parseRoomIdsFromBooking(doc).includes(roomId), + ).length; + + if (count >= limit) { + const resourceName = resource.name ? `"${resource.name}"` : `Room ${roomId}`; + return { + ok: false, + message: `Request limit reached for ${resourceName} (${period}). Limit: ${limit}.`, + }; + } + } + } + + return { ok: true }; +} + // Helper to build booking contents object for calendar descriptions const buildBookingContents = ( data: any, @@ -565,6 +697,35 @@ export async function POST(request: NextRequest) { ); } + // Enforce per-resource request limits (per user email + role) + try { + const role = String(data?.role ?? "").trim(); + const selectedRoomIdsNums: number[] = Array.isArray(selectedRooms) + ? selectedRooms + .map((r: any) => Number(r?.roomId)) + .filter((n: number) => Number.isFinite(n)) + : []; + + if (tenant && email && role && selectedRoomIdsNums.length > 0) { + const enforcement = await enforceRequestLimits({ + tenant, + email, + role, + selectedRoomIds: selectedRoomIdsNums, + }); + + if (!enforcement.ok) { + return NextResponse.json( + { error: enforcement.message }, + { status: 429 }, + ); + } + } + } catch (e: any) { + console.error("Error enforcing request limits:", e); + // Fail open to avoid blocking bookings if enforcement throws unexpectedly + } + console.log("data", data); // Determine initial status and auto-approval using XState for ITP @@ -773,6 +934,9 @@ export async function POST(request: NextRequest) { const selectedRoomIds = selectedRooms .map((r: { roomId: number }) => r.roomId) .join(", "); + const selectedRoomIdsArray = selectedRooms + .map((r: { roomId: number }) => r.roomId) + .filter((n: number) => Number.isFinite(n)); // Build booking contents for description const startDateObj = new Date(bookingCalendarInfo.startStr); @@ -827,6 +991,7 @@ export async function POST(request: NextRequest) { const bookingData = { calendarEventId, roomId: selectedRoomIds, + roomIds: selectedRoomIdsArray, email, startDate: toFirebaseTimestampFromString(bookingCalendarInfo.startStr), endDate: toFirebaseTimestampFromString(bookingCalendarInfo.endStr), diff --git a/booking-app/components/src/client/routes/components/SchemaProvider.tsx b/booking-app/components/src/client/routes/components/SchemaProvider.tsx index 2aea3113d..471bb3251 100644 --- a/booking-app/components/src/client/routes/components/SchemaProvider.tsx +++ b/booking-app/components/src/client/routes/components/SchemaProvider.tsx @@ -13,6 +13,10 @@ export type StaffingSection = { indexes: number[]; }; +export type RequestLimitPeriod = "perDay" | "perWeek" | "perMonth" | "perSemester"; + +export type RequestLimits = Partial>>; + export type Resource = { capacity: number; name: string; @@ -25,6 +29,13 @@ export type Resource = { isWalkIn: boolean; isWalkInCanBookTwo: boolean; services: string[]; // ["equipment", "staffing", "setup", "security", "cleaning", "catering", "campus-media"] + /** + * Limit how many requests a user can make per period for this resource. + * Missing values mean “unlimited”. + * + * Shape: { perDay: { [role]: number }, perWeek: { ... }, perMonth: { ... }, perSemester: { ... } } + */ + requestLimits?: RequestLimits; autoApproval?: { minHour?: { admin: number; @@ -200,6 +211,7 @@ export const defaultResource: Resource = { isWalkIn: false, isWalkInCanBookTwo: false, services: [], + requestLimits: undefined, autoApproval: { minHour: { admin: -1, faculty: -1, student: -1 }, maxHour: { admin: -1, faculty: -1, student: -1 }, diff --git a/booking-app/components/src/client/routes/super/schemaEditor.tsx b/booking-app/components/src/client/routes/super/schemaEditor.tsx index 9dad8314a..1e5f6ace6 100644 --- a/booking-app/components/src/client/routes/super/schemaEditor.tsx +++ b/booking-app/components/src/client/routes/super/schemaEditor.tsx @@ -454,11 +454,13 @@ function AgreementsSection({ function ResourceEditor({ resource, index, + roleKeys, onUpdate, onRemove, }: { resource: Resource; index: number; + roleKeys: string[]; onUpdate: (index: number, resource: Resource) => void; onRemove: (index: number) => void; }) { @@ -472,6 +474,66 @@ function ResourceEditor({ } }; + const requestLimitPeriods = [ + { key: "perDay", label: "Per Day" }, + { key: "perWeek", label: "Per Week" }, + { key: "perMonth", label: "Per Month" }, + { key: "perSemester", label: "Per Semester" }, + ] as const; + + const derivedRoleKeys = Array.from( + new Set([ + ...(Array.isArray(roleKeys) ? roleKeys : []), + ...Object.keys(resource.maxHour ?? {}), + ...Object.keys(resource.minHour ?? {}), + ...Object.keys(resource.requestLimits?.perDay ?? {}), + ...Object.keys(resource.requestLimits?.perWeek ?? {}), + ...Object.keys(resource.requestLimits?.perMonth ?? {}), + ...Object.keys(resource.requestLimits?.perSemester ?? {}), + ]), + ).filter(Boolean); + + const setRequestLimit = ( + period: (typeof requestLimitPeriods)[number]["key"], + role: string, + nextValue: string, + ) => { + const trimmed = nextValue.trim(); + + const currentLimits = resource.requestLimits ?? {}; + const periodLimits = { ...(currentLimits as any)[period] } as Record< + string, + number + >; + + if (trimmed === "") { + delete periodLimits[role]; + } else { + const n = Number(trimmed); + if (Number.isFinite(n) && n >= 0) { + periodLimits[role] = Math.floor(n); + } + } + + const nextLimits: any = { ...currentLimits }; + if (Object.keys(periodLimits).length === 0) { + delete nextLimits[period]; + } else { + nextLimits[period] = periodLimits; + } + + const hasAny = + Object.keys(nextLimits.perDay ?? {}).length > 0 || + Object.keys(nextLimits.perWeek ?? {}).length > 0 || + Object.keys(nextLimits.perMonth ?? {}).length > 0 || + Object.keys(nextLimits.perSemester ?? {}).length > 0; + + onUpdate(index, { + ...resource, + requestLimits: hasAny ? nextLimits : undefined, + }); + }; + return ( }> @@ -736,6 +798,48 @@ function ResourceEditor({ )} + {/* Request Limits */} + + }> + Request Limits + + + + Leave blank for unlimited. Limits apply per user (email), per resource, using the request creation time. + + + {requestLimitPeriods.map((p) => ( + + }> + {p.label} + + + + {derivedRoleKeys.map((role) => { + const current = + (resource.requestLimits as any)?.[p.key]?.[role]; + return ( + + setRequestLimit(p.key, role, e.target.value) + } + size="small" + sx={{ width: 140 }} + inputProps={{ min: 0 }} + /> + ); + })} + + + + ))} + + + + } + > + {loadError} + + ); + } + + if (resources.length === 0) { + return ( + + No resources in tenant schema. + + ); + } + + return ( + + {resources.map((resource) => ( + + ))} + + ); +}; diff --git a/booking-app/components/src/policy.ts b/booking-app/components/src/policy.ts index 1c1cb8edd..531bdbc6e 100644 --- a/booking-app/components/src/policy.ts +++ b/booking-app/components/src/policy.ts @@ -6,6 +6,7 @@ import { getApprovedCcEmail, getCanceledCcEmail } from "./tenantPolicy"; export enum TableNames { ADMINS = "usersAdmin", APPROVERS = "usersApprovers", + RESOURCE_APPROVERS = "usersResourceApprovers", BANNED = "usersBanned", BOOKING = "bookings", BOOKING_TYPES = "bookingTypes", @@ -45,6 +46,7 @@ export const getTenantCollectionName = ( "settings", "usersWhitelist", "usersApprovers", + "usersResourceApprovers", "usersRights", "counters", ]; @@ -100,6 +102,15 @@ export enum ApproverLevel { EQUIPMENT = 3, } +export const getResourceApproverDocumentId = ( + resourceId: string, + email: string, +): string => { + const encodedResourceId = encodeURIComponent(resourceId); + const encodedEmail = encodeURIComponent(email.trim().toLowerCase()); + return `${encodedResourceId.length}-${encodedResourceId}${encodedEmail}`; +}; + /** ******** CONTACTS ************/ export const clientGetFinalApproverEmail = async (): Promise => { diff --git a/booking-app/components/src/server/admin.ts b/booking-app/components/src/server/admin.ts index 23e6c992e..86eacd38f 100644 --- a/booking-app/components/src/server/admin.ts +++ b/booking-app/components/src/server/admin.ts @@ -6,6 +6,7 @@ import { serverGetDataByCalendarEventId, serverGetDocumentById, serverGetFinalApproverEmail, + serverResolveResourceApproverEmails, serverUpdateInFirestore, } from "@/lib/firebase/server/adminDb"; import { Timestamp } from "firebase-admin/firestore"; @@ -34,6 +35,24 @@ interface HistoryItem { note?: string; } +const parseBookingResourceIds = (roomId: unknown): string[] => + String(roomId ?? "") + .split(",") + .map((resourceId) => resourceId.trim()) + .filter(Boolean); + +const resolveBookingResourceApproverEmails = async ( + roomId: unknown, + tenant?: string, +): Promise => { + const resourceIds = parseBookingResourceIds(roomId); + if (resourceIds.length > 0) { + return serverResolveResourceApproverEmails(resourceIds, tenant); + } + const fallback = await serverGetFinalApproverEmail(tenant); + return fallback ? [fallback] : []; +}; + const getBookingHistory = async ( booking: Booking, tenant?: string, @@ -288,6 +307,7 @@ export const serverFirstApproveOnly = async ( const doc = await serverGetDataByCalendarEventId<{ id: string; requestNumber: number; + roomId?: string; }>(TableNames.BOOKING, id, tenant); if (!doc) { @@ -313,36 +333,41 @@ export const serverFirstApproveOnly = async ( ...contents, headerMessage: emailConfig.emailNotifications.reviewedNeedsApproval, }; - const recipient = await serverGetFinalApproverEmail(tenant); - if (!recipient) { - + const recipients = await resolveBookingResourceApproverEmails( + doc.roomId, + tenant, + ); + if (recipients.length === 0) { return; } - const formData = { - templateName: "booking_detail", - contents: emailContents, - targetEmail: recipient, - status: BookingStatusLabel.PRE_APPROVED, - eventTitle: contents.title || "", - requestNumber: contents.requestNumber, - bodyMessage: "", - approverType: ApproverType.FINAL_APPROVER, - replyTo: contents.email, - }; - const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-tenant": tenant || DEFAULT_TENANT, - }, - body: JSON.stringify(formData), - }); + const results = await Promise.all( + recipients.map((recipient) => + fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-tenant": tenant || DEFAULT_TENANT, + }, + body: JSON.stringify({ + templateName: "booking_detail", + contents: emailContents, + targetEmail: recipient, + status: BookingStatusLabel.PRE_APPROVED, + eventTitle: contents.title || "", + requestNumber: contents.requestNumber, + bodyMessage: "", + approverType: ApproverType.FINAL_APPROVER, + replyTo: contents.email, + }), + }), + ), + ); console.log( `✅ FIRST APPROVAL COMPLETED [${tenant?.toUpperCase() || "UNKNOWN"}]:`, { calendarEventId: id, - emailSent: res.ok, + emailSent: results.some((result) => result.ok), status: BookingStatusLabel.PRE_APPROVED, }, ); @@ -442,6 +467,7 @@ const firstApprove = async (id: string, email: string, tenant?: string) => { const doc = await serverGetDataByCalendarEventId<{ id: string; requestNumber: number; + roomId?: string; }>(TableNames.BOOKING, id, tenant); if (!doc) { console.error("Booking document not found for calendar event id:", id); @@ -484,32 +510,37 @@ const firstApprove = async (id: string, email: string, tenant?: string) => { ...contents, headerMessage: emailConfig.emailNotifications.reviewedNeedsApproval, }; - const recipient = await serverGetFinalApproverEmail(tenant); - if (!recipient) { - + const recipients = await resolveBookingResourceApproverEmails( + doc.roomId, + tenant, + ); + if (recipients.length === 0) { return; } - const formData = { - templateName: "booking_detail", - contents: emailContents, - targetEmail: recipient, - status: BookingStatusLabel.PRE_APPROVED, - eventTitle: contents.title || "", - requestNumber: contents.requestNumber, - bodyMessage: "", - approverType: ApproverType.FINAL_APPROVER, - replyTo: contents.email, - schemaName: emailConfig.schemaName, - }; - const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-tenant": tenant || DEFAULT_TENANT, - }, - body: JSON.stringify(formData), - }); + await Promise.all( + recipients.map((recipient) => + fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-tenant": tenant || DEFAULT_TENANT, + }, + body: JSON.stringify({ + templateName: "booking_detail", + contents: emailContents, + targetEmail: recipient, + status: BookingStatusLabel.PRE_APPROVED, + eventTitle: contents.title || "", + requestNumber: contents.requestNumber, + bodyMessage: "", + approverType: ApproverType.FINAL_APPROVER, + replyTo: contents.email, + schemaName: emailConfig.schemaName, + }), + }), + ), + ); }; export const finalApprove = async ( @@ -556,6 +587,7 @@ interface SendConfirmationEmailOptions { headerMessage: string; guestEmail: string; tenant?: string; + roomId?: unknown; } export const serverSendBookingDetailEmail = async ({ @@ -602,20 +634,24 @@ export const serverSendConfirmationEmail = async ({ headerMessage, guestEmail, tenant, + roomId, }: SendConfirmationEmailOptions) => { - const email = await serverGetFinalApproverEmail(tenant); - if (!email) { - + const emails = await resolveBookingResourceApproverEmails(roomId, tenant); + if (emails.length === 0) { return; } - serverSendBookingDetailEmail({ - calendarEventId, - targetEmail: email, - headerMessage, - status, - replyTo: guestEmail, - tenant, - }); + await Promise.all( + emails.map((email) => + serverSendBookingDetailEmail({ + calendarEventId, + targetEmail: email, + headerMessage, + status, + replyTo: guestEmail, + tenant, + }), + ), + ); }; // server @@ -632,6 +668,8 @@ export const serverApproveEvent = async (id: string, tenant?: string) => { // @ts-ignore const guestEmail = doc.email; + // @ts-ignore + const bookingRoomId = doc.roomId; // Get tenant email configuration for approval notice const emailConfig = await getTenantEmailConfig(tenant); @@ -656,6 +694,7 @@ export const serverApproveEvent = async (id: string, tenant?: string) => { headerMessage: otherHeaderMessage, guestEmail, tenant, + roomId: bookingRoomId, }); // for Samantha diff --git a/booking-app/lib/api/authz.ts b/booking-app/lib/api/authz.ts index df689e688..224e731be 100644 --- a/booking-app/lib/api/authz.ts +++ b/booking-app/lib/api/authz.ts @@ -90,6 +90,7 @@ const POLICY: Record = { [TableNames.SUPER_ADMINS]: { read: "anyNYU", write: "superOnly" }, [TableNames.USERS_RIGHTS]: { read: "anyNYU", write: "adminOrSuper" }, [TableNames.APPROVERS]: { read: "anyNYU", write: "adminOrSuper" }, + [TableNames.RESOURCE_APPROVERS]: { read: "adminOrSuper", write: "adminOrSuper" }, [TableNames.ADMINS]: { read: "anyNYU", write: "adminOrSuper" }, [TableNames.PAS]: { read: "anyNYU", write: "adminOrSuper" }, diff --git a/booking-app/lib/api/firestoreShared.ts b/booking-app/lib/api/firestoreShared.ts index 166dec1c5..6bb6b6b45 100644 --- a/booking-app/lib/api/firestoreShared.ts +++ b/booking-app/lib/api/firestoreShared.ts @@ -82,6 +82,13 @@ export type MutateRequest = docId: string; data: Record; } + | { + op: "set"; + collection: string; + tenant?: string; + docId: string; + data: Record; + } | { op: "delete"; collection: string; diff --git a/booking-app/lib/firebase/firebase.ts b/booking-app/lib/firebase/firebase.ts index b6ad3c017..93b4c3dac 100644 --- a/booking-app/lib/firebase/firebase.ts +++ b/booking-app/lib/firebase/firebase.ts @@ -1,6 +1,7 @@ import { ApproverLevel, TableNames, + getResourceApproverDocumentId, getTenantCollectionName, } from "@/components/src/policy"; import { Timestamp } from "firebase/firestore"; @@ -48,6 +49,25 @@ export type AdminUserData = { createdAt: Timestamp; }; +export type ResourceApproverData = { + id: string; + email: string; + resourceId: string; + createdAt: Timestamp; +}; + +type ResourceApproverSetRequest = { + op: "set"; + collection: TableNames.RESOURCE_APPROVERS; + tenant?: string; + docId: string; + data: { + email: string; + resourceId: string; + createdAt: { __ts: number }; + }; +}; + /** * Walk the parsed JSON tree and convert serialized Firestore Timestamps * back into client SDK `Timestamp` instances so callers can keep using @@ -214,6 +234,82 @@ export const clientFetchAllDataFromCollection = async ( return docs as unknown as T[]; }; +export const clientListResourceApprovers = async ( + tenant?: string, +): Promise => + clientFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [], + tenant, + ); + +export const clientAddResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + const normalizedEmail = email.trim().toLowerCase(); + await postJson("/api/firestore/mutate", { + op: "set", + collection: TableNames.RESOURCE_APPROVERS, + tenant: resolveTenantArg(tenant), + docId: getResourceApproverDocumentId(resourceId, normalizedEmail), + data: { + email: normalizedEmail, + resourceId, + createdAt: { __ts: Date.now() }, + }, + }); +}; + +export const clientRemoveResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + await postJson("/api/firestore/mutate", { + op: "delete", + collection: TableNames.RESOURCE_APPROVERS, + tenant: resolveTenantArg(tenant), + docId: getResourceApproverDocumentId(resourceId, email), + }); +}; + +export const clientResolveResourceApproverEmails = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = [...new Set(resourceIds)]; + if (uniqueResourceIds.length === 0) return []; + + const approvers = await clientListResourceApprovers(tenant); + const requestedResourceIds = new Set(uniqueResourceIds); + const coveredResourceIds = new Set(); + const recipients = new Set(); + + for (const approver of approvers) { + if (!requestedResourceIds.has(approver.resourceId)) continue; + coveredResourceIds.add(approver.resourceId); + recipients.add(approver.email.trim().toLowerCase()); + } + + if (coveredResourceIds.size < uniqueResourceIds.length) { + const { docs } = await postJson< + ListRequest, + { docs: Array<{ email?: string }> } + >("/api/firestore/list", { + collection: TableNames.APPROVERS, + tenant: resolveTenantArg(tenant), + where: [{ field: "level", op: "==", value: ApproverLevel.FINAL }], + limit: 1, + }); + const finalApproverEmail = docs[0]?.email?.trim().toLowerCase(); + if (finalApproverEmail) recipients.add(finalApproverEmail); + } + + return [...recipients]; +}; + export const clientFetchAllDataFromCollectionWithLimitAndOffset = async ( collectionName: TableNames, limitNumber: number, diff --git a/booking-app/lib/firebase/server/adminDb.ts b/booking-app/lib/firebase/server/adminDb.ts index bb99cfe12..472d92dee 100644 --- a/booking-app/lib/firebase/server/adminDb.ts +++ b/booking-app/lib/firebase/server/adminDb.ts @@ -1,6 +1,7 @@ import { ApproverLevel, TableNames, + getResourceApproverDocumentId, getTenantCollectionName, } from "@/components/src/policy"; import { @@ -31,6 +32,93 @@ export type AdminUserData = { createdAt: admin.firestore.Timestamp; }; +export type ResourceApproverData = { + id: string; + email: string; + resourceId: string; + createdAt: admin.firestore.Timestamp; +}; + +export const serverListResourceApprovers = async ( + tenant?: string, +): Promise => + serverFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [], + tenant, + ); + +export const serverAddResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + const normalizedEmail = email.trim().toLowerCase(); + const tenantCollection = getServerTenantCollection( + TableNames.RESOURCE_APPROVERS, + tenant, + ); + const docId = getResourceApproverDocumentId(resourceId, normalizedEmail); + await traceDatabase("set", `Firestore/${tenantCollection}`, () => + db.collection(tenantCollection).doc(docId).set({ + email: normalizedEmail, + resourceId, + createdAt: admin.firestore.Timestamp.now(), + }), + ); +}; + +export const serverRemoveResourceApprover = async ( + resourceId: string, + email: string, + tenant?: string, +): Promise => { + const tenantCollection = getServerTenantCollection( + TableNames.RESOURCE_APPROVERS, + tenant, + ); + const docId = getResourceApproverDocumentId(resourceId, email); + await traceDatabase("delete", `Firestore/${tenantCollection}`, () => + db.collection(tenantCollection).doc(docId).delete(), + ); +}; + +export const serverResolveResourceApproverEmails = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = [ + ...new Set(resourceIds.map((resourceId) => resourceId.trim()).filter(Boolean)), + ]; + if (uniqueResourceIds.length === 0) return []; + + const approvers = await serverListResourceApprovers(tenant); + const requestedResourceIds = new Set(uniqueResourceIds); + const resourceIdsByEmail = new Map>(); + + for (const approver of approvers) { + if (!requestedResourceIds.has(approver.resourceId)) continue; + const email = approver.email.trim().toLowerCase(); + const approverResourceIds = resourceIdsByEmail.get(email) ?? new Set(); + approverResourceIds.add(approver.resourceId); + resourceIdsByEmail.set(email, approverResourceIds); + } + + const recipients = [...resourceIdsByEmail.entries()] + .filter(([, approverResourceIds]) => + uniqueResourceIds.every((resourceId) => approverResourceIds.has(resourceId)), + ) + .map(([email]) => email); + + if (recipients.length === 0) { + const finalApproverEmail = + await serverGetFinalApproverEmailFromDatabaseStrict(tenant); + if (finalApproverEmail) recipients.push(finalApproverEmail); + } + + return recipients; +}; + export const serverDeleteData = async ( collectionName: string, docId: string, @@ -286,6 +374,27 @@ export const serverGetFinalApproverEmailFromDatabase = async ( } }; +const serverGetFinalApproverEmailFromDatabaseStrict = async ( + tenant?: string, +): Promise => { + const tenantCollection = getServerTenantCollection( + TableNames.APPROVERS, + tenant, + ); + const querySnapshot = await traceDatabase( + "query", + `Firestore/${tenantCollection}`, + () => + db + .collection(tenantCollection) + .where("level", "==", ApproverLevel.FINAL) + .limit(1) + .get(), + ); + const email = querySnapshot.docs[0]?.data().email; + return typeof email === "string" ? email.trim().toLowerCase() : null; +}; + export const serverGetFinalApproverEmail = async ( tenant?: string, ): Promise => { diff --git a/booking-app/tests/unit/api-approve.unit.test.ts b/booking-app/tests/unit/api-approve.unit.test.ts index bc291f779..f0b1b75b7 100644 --- a/booking-app/tests/unit/api-approve.unit.test.ts +++ b/booking-app/tests/unit/api-approve.unit.test.ts @@ -19,6 +19,16 @@ vi.mock("@/components/src/server/admin", () => ({ vi.mock("@/lib/firebase/server/adminDb", () => ({ serverGetDataByCalendarEventId: vi.fn(), serverFetchAllDataFromCollection: vi.fn(), + serverGetFinalApproverEmail: vi.fn(), + serverListResourceApprovers: vi.fn(), +})); + +vi.mock("@/lib/api/requireSession", () => ({ + requireSession: vi.fn(), +})); + +vi.mock("@/lib/api/authz", () => ({ + resolveCallerRole: vi.fn(), })); vi.mock("@/components/src/server/emails", () => ({ @@ -34,6 +44,7 @@ vi.mock("@/components/src/utils/tenantUtils", () => ({ })); import { POST } from "@/app/api/approve/route"; import { DEFAULT_TENANT } from "@/components/src/constants/tenants"; +import { PagePermission } from "@/components/src/types"; import { finalApprove, serverApproveBooking, @@ -43,8 +54,12 @@ import { import { executeXStateTransition } from "@/lib/stateMachines/xstateUtilsV5"; import { serverFetchAllDataFromCollection, + serverGetFinalApproverEmail, serverGetDataByCalendarEventId, + serverListResourceApprovers, } from "@/lib/firebase/server/adminDb"; +import { resolveCallerRole } from "@/lib/api/authz"; +import { requireSession } from "@/lib/api/requireSession"; import { getMediaCommonsServices, isMediaCommons, @@ -74,12 +89,17 @@ const mockServerGetDataByCalendarEventId = vi.mocked( const mockServerFetchAllDataFromCollection = vi.mocked( serverFetchAllDataFromCollection, ); +const mockServerGetFinalApproverEmail = vi.mocked(serverGetFinalApproverEmail); +const mockServerListResourceApprovers = vi.mocked(serverListResourceApprovers); +const mockRequireSession = vi.mocked(requireSession); +const mockResolveCallerRole = vi.mocked(resolveCallerRole); const mockIsMediaCommons = vi.mocked(isMediaCommons); const mockGetMediaCommonsServices = vi.mocked(getMediaCommonsServices); const bookingId = "calendar-1"; -const approverEmail = "approver@nyu.edu"; +const sessionEmail = "session-approver@nyu.edu"; +const bodyEmail = "spoofed-approver@nyu.edu"; const parseJson = async (response: Response) => { const data = await response.json(); @@ -89,6 +109,13 @@ const parseJson = async (response: Response) => { describe("POST /api/approve", () => { beforeEach(() => { vi.clearAllMocks(); + mockRequireSession.mockResolvedValue({ + email: sessionEmail, + netId: "session-approver", + } as any); + mockResolveCallerRole.mockResolvedValue(PagePermission.ADMIN); + mockServerGetFinalApproverEmail.mockResolvedValue(null); + mockServerListResourceApprovers.mockResolvedValue([]); mockIsMediaCommons.mockReturnValue(false); mockGetMediaCommonsServices.mockReturnValue({ staff: false, @@ -116,26 +143,200 @@ describe("POST /api/approve", () => { mockFetch.mockResolvedValue({ ok: true }); }); + it("returns 401 when unauthenticated", async () => { + mockRequireSession.mockResolvedValue(null); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 401, + data: { error: "Unauthorized" }, + }); + expect(mockServerGetDataByCalendarEventId).not.toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("returns 400 for an invalid tenant", async () => { + const response = await POST( + createRequest( + { id: bookingId, email: bodyEmail }, + { "x-tenant": "invalid-tenant" }, + ) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 400, + data: { error: "Invalid tenant" }, + }); + expect(mockServerGetDataByCalendarEventId).not.toHaveBeenCalled(); + }); + + it("returns 404 when the booking does not exist", async () => { + mockServerGetDataByCalendarEventId.mockResolvedValue(null); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 404, + data: { error: "Booking not found" }, + }); + expect(mockResolveCallerRole).not.toHaveBeenCalled(); + }); + + it("returns 403 when the caller is not authorized", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + await expect(parseJson(response)).resolves.toEqual({ + status: 403, + data: { error: "You are not authorized to approve this booking" }, + }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("allows a liaison to perform the first approval using the session email", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.LIAISON); + mockExecute.mockResolvedValue({ success: true, newState: "Pre-approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(mockResolveCallerRole).toHaveBeenCalledWith( + { email: sessionEmail, netId: "session-approver" }, + DEFAULT_TENANT, + ); + expect(mockExecute).toHaveBeenCalledWith( + bookingId, + "approve", + DEFAULT_TENANT, + sessionEmail, + ); + expect(mockServerFirstApproveOnly).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + expect(response.status).toBe(200); + }); + + it("allows a resource approver to final approve only when assigned to every booking resource", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + mockServerGetDataByCalendarEventId.mockResolvedValue({ + id: "booking-db-id", + firstApprovedAt: "2026-06-14T12:00:00Z", + roomId: "room-1, room-2", + } as any); + mockServerListResourceApprovers.mockResolvedValue([ + { resourceId: "room-1", email: sessionEmail }, + { resourceId: "room-2", email: sessionEmail.toUpperCase() }, + ] as any); + mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(mockServerListResourceApprovers).toHaveBeenCalledWith(DEFAULT_TENANT); + expect(mockFinalApprove).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + expect(response.status).toBe(200); + }); + + it.each([ + ["unrelated", [{ resourceId: "room-3", email: sessionEmail }]], + ["partially assigned", [{ resourceId: "room-1", email: sessionEmail }]], + ])("denies a resource approver who is %s", async (_label, assignments) => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + mockServerGetDataByCalendarEventId.mockResolvedValue({ + id: "booking-db-id", + firstApprovedAt: "2026-06-14T12:00:00Z", + roomId: "room-1, room-2", + } as any); + mockServerListResourceApprovers.mockResolvedValue(assignments as any); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(response.status).toBe(403); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("allows the tenant final approver to final approve", async () => { + mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + mockServerGetDataByCalendarEventId.mockResolvedValue({ + id: "booking-db-id", + firstApprovedAt: "2026-06-14T12:00:00Z", + roomId: "room-1", + } as any); + mockServerGetFinalApproverEmail.mockResolvedValue( + ` ${sessionEmail.toUpperCase()} `, + ); + mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(response.status).toBe(200); + expect(mockFinalApprove).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + expect(mockServerListResourceApprovers).not.toHaveBeenCalled(); + }); + + it.each([PagePermission.ADMIN, PagePermission.SUPER_ADMIN])( + "allows a %s to approve", + async (role) => { + mockResolveCallerRole.mockResolvedValue(role); + mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); + + const response = await POST( + createRequest({ id: bookingId, email: bodyEmail }) as any, + ); + + expect(response.status).toBe(200); + expect(mockFinalApprove).toHaveBeenCalledWith( + bookingId, + sessionEmail, + DEFAULT_TENANT, + ); + }, + ); + it("falls back to serverApproveBooking when XState transition fails", async () => { mockExecute.mockResolvedValue({ success: false, error: "state error" }); const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, - { "x-tenant": "tenant-a" }, + { id: bookingId, email: bodyEmail }, + { "x-tenant": "itp" }, ) as any, ); expect(mockExecute).toHaveBeenCalledWith( bookingId, "approve", - "tenant-a", - approverEmail, + "itp", + sessionEmail, ); expect(mockServerApproveBooking).toHaveBeenCalledWith( bookingId, - approverEmail, - "tenant-a", + sessionEmail, + "itp", ); expect(mockFinalApprove).not.toHaveBeenCalled(); expect(mockServerFirstApproveOnly).not.toHaveBeenCalled(); @@ -168,7 +369,7 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, + { id: bookingId, email: bodyEmail }, { "x-tenant": "mc" }, ) as any, ); @@ -201,14 +402,14 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, + { id: bookingId, email: bodyEmail }, { "x-tenant": "mc" }, ) as any, ); expect(mockServerApproveBooking).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, "mc", ); expect(response.status).toBe(200); @@ -240,14 +441,14 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, + { id: bookingId, email: bodyEmail }, { "x-tenant": "mc" }, ) as any, ); expect(mockServerApproveBooking).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, "mc", ); expect(response.status).toBe(200); @@ -257,18 +458,18 @@ describe("POST /api/approve", () => { mockExecute.mockResolvedValue({ success: true, newState: "Approved" }); const response = await POST( - createRequest({ id: bookingId, email: approverEmail }) as any, + createRequest({ id: bookingId, email: bodyEmail }) as any, ); expect(mockExecute).toHaveBeenCalledWith( bookingId, "approve", DEFAULT_TENANT, - approverEmail, + sessionEmail, ); expect(mockFinalApprove).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, DEFAULT_TENANT, ); expect(mockServerApproveBooking).not.toHaveBeenCalled(); @@ -283,12 +484,12 @@ describe("POST /api/approve", () => { mockExecute.mockResolvedValue({ success: true, newState: "Pre-approved" }); const response = await POST( - createRequest({ id: bookingId, email: approverEmail }) as any, + createRequest({ id: bookingId, email: bodyEmail }) as any, ); expect(mockServerFirstApproveOnly).toHaveBeenCalledWith( bookingId, - approverEmail, + sessionEmail, DEFAULT_TENANT, ); expect(mockFinalApprove).not.toHaveBeenCalled(); @@ -307,8 +508,8 @@ describe("POST /api/approve", () => { const response = await POST( createRequest( - { id: bookingId, email: approverEmail }, - { "x-tenant": "tenant-b" }, + { id: bookingId, email: bodyEmail }, + { "x-tenant": "itp" }, ) as any, ); @@ -328,7 +529,7 @@ describe("POST /api/approve", () => { mockServerApproveBooking.mockRejectedValue(new Error("Fallback failed")); const response = await POST( - createRequest({ id: bookingId, email: approverEmail }) as any, + createRequest({ id: bookingId, email: bodyEmail }) as any, ); await expect(parseJson(response)).resolves.toEqual({ diff --git a/booking-app/tests/unit/firestoreAuthz.unit.test.ts b/booking-app/tests/unit/firestoreAuthz.unit.test.ts index 669fe48f2..34d01dfb9 100644 --- a/booking-app/tests/unit/firestoreAuthz.unit.test.ts +++ b/booking-app/tests/unit/firestoreAuthz.unit.test.ts @@ -60,6 +60,16 @@ describe("authorizeWrite", () => { expect(decision.ok).toBe(true); }); + it("allows admin write to usersResourceApprovers", async () => { + usersRightsSnap.mockReturnValue([{ isAdmin: true }]); + const decision = await authorizeWrite( + session, + "mc", + "usersResourceApprovers", + ); + expect(decision.ok).toBe(true); + }); + it("blocks admin from writing usersSuperAdmin (super-only)", async () => { usersRightsSnap.mockReturnValue([{ isAdmin: true }]); const decision = await authorizeWrite(session, "mc", "usersSuperAdmin"); @@ -101,6 +111,25 @@ describe("authorizeRead", () => { expect(decision.ok).toBe(true); }); + it("blocks non-admin read of usersResourceApprovers", async () => { + const decision = await authorizeRead( + session, + "mc", + "usersResourceApprovers", + ); + expect(decision.ok).toBe(false); + }); + + it("allows admin read of usersResourceApprovers", async () => { + usersRightsSnap.mockReturnValue([{ isAdmin: true }]); + const decision = await authorizeRead( + session, + "mc", + "usersResourceApprovers", + ); + expect(decision.ok).toBe(true); + }); + it("blocks non-admin read of preBanLogs", async () => { const decision = await authorizeRead(session, "mc", "preBanLogs"); expect(decision.ok).toBe(false); diff --git a/booking-app/tests/unit/resource-approvers-client.unit.test.ts b/booking-app/tests/unit/resource-approvers-client.unit.test.ts new file mode 100644 index 000000000..0dcc28362 --- /dev/null +++ b/booking-app/tests/unit/resource-approvers-client.unit.test.ts @@ -0,0 +1,116 @@ +import { + clientAddResourceApprover, + clientListResourceApprovers, + clientRemoveResourceApprover, + clientResolveResourceApproverEmails, +} from "@/lib/firebase/firebase"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +function response(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: async () => body, + }; +} + +function bodyAt(index: number) { + return JSON.parse(fetchMock.mock.calls[index][1].body as string); +} + +describe("resource approver client helpers", () => { + it("lists from the tenant resource approver collection", async () => { + fetchMock.mockResolvedValue(response({ docs: [{ id: "one" }] })); + + await expect(clientListResourceApprovers("mc")).resolves.toEqual([ + { id: "one" }, + ]); + expect(bodyAt(0)).toMatchObject({ + collection: "usersResourceApprovers", + tenant: "mc", + where: [], + }); + }); + + it("normalizes email and uses the same deterministic ID for add/remove", async () => { + fetchMock.mockResolvedValue(response({ ok: true })); + + await clientAddResourceApprover("room/a", " Person@NYU.EDU ", "mc"); + await clientRemoveResourceApprover("room/a", "person@nyu.edu", "mc"); + + expect(bodyAt(0)).toMatchObject({ + op: "set", + collection: "usersResourceApprovers", + tenant: "mc", + docId: bodyAt(1).docId, + data: { + email: "person@nyu.edu", + resourceId: "room/a", + }, + }); + expect(bodyAt(0).data.createdAt).toEqual({ __ts: expect.any(Number) }); + expect(bodyAt(1)).toMatchObject({ + op: "delete", + collection: "usersResourceApprovers", + tenant: "mc", + }); + }); + + it("falls back only when a requested resource is uncovered and deduplicates", async () => { + fetchMock + .mockResolvedValueOnce( + response({ + docs: [ + { resourceId: "a", email: "SAME@nyu.edu" }, + { resourceId: "a", email: "same@nyu.edu" }, + { resourceId: "unrequested", email: "other@nyu.edu" }, + ], + }), + ) + .mockResolvedValueOnce(response({ docs: [{ email: "FINAL@nyu.edu" }] })); + + await expect( + clientResolveResourceApproverEmails(["a", "b", "b"], "mc"), + ).resolves.toEqual(["same@nyu.edu", "final@nyu.edu"]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(bodyAt(1)).toMatchObject({ + collection: "usersApprovers", + tenant: "mc", + where: [{ field: "level", op: "==", value: 2 }], + }); + }); + + it("does not query final approvers when every resource is covered", async () => { + fetchMock.mockResolvedValue( + response({ + docs: [ + { resourceId: "a", email: "one@nyu.edu" }, + { resourceId: "b", email: "two@nyu.edu" }, + ], + }), + ); + + await expect( + clientResolveResourceApproverEmails(["a", "b"], "mc"), + ).resolves.toEqual(["one@nyu.edu", "two@nyu.edu"]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws instead of silently falling back after a resource query failure", async () => { + fetchMock.mockResolvedValue( + response({ error: "query failed" }, false, 500), + ); + + await expect( + clientResolveResourceApproverEmails(["a"], "mc"), + ).rejects.toThrow("query failed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/booking-app/tests/unit/resource-approvers-server.unit.test.ts b/booking-app/tests/unit/resource-approvers-server.unit.test.ts new file mode 100644 index 000000000..40578329a --- /dev/null +++ b/booking-app/tests/unit/resource-approvers-server.unit.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Doc = { id: string; data: Record }; + +const collections = new Map(); +const getFailures = new Map(); +const setMock = vi.fn(); +const deleteMock = vi.fn(); + +function queryFor(name: string, filters: Array<[string, unknown]> = []) { + return { + where(field: string, _op: string, value: unknown) { + return queryFor(name, [...filters, [field, value]]); + }, + limit() { + return this; + }, + async get() { + const failure = getFailures.get(name); + if (failure) throw failure; + const docs = (collections.get(name) ?? []).filter(({ data }) => + filters.every(([field, value]) => data[field] === value), + ); + return { + empty: docs.length === 0, + docs: docs.map((doc) => ({ id: doc.id, data: () => doc.data })), + }; + }, + }; +} + +vi.mock("@/lib/firebase/server/firebaseAdmin", () => ({ + default: { + firestore: Object.assign( + () => ({ + collection: (name: string) => ({ + ...queryFor(name), + doc: (id: string) => ({ + set: (data: unknown) => setMock(name, id, data), + delete: () => deleteMock(name, id), + }), + }), + }), + { Timestamp: { now: () => "timestamp" } }, + ), + }, +})); + +vi.mock("@/lib/newrelic-utils", () => ({ + traceDatabase: async ( + _op: string, + _label: string, + fn: () => Promise, + ) => fn(), +})); + +describe("resource approver server helpers", () => { + beforeEach(() => { + collections.clear(); + getFailures.clear(); + setMock.mockReset(); + deleteMock.mockReset(); + }); + + it("uses tenant collection, normalized email, and deterministic IDs", async () => { + setMock.mockResolvedValue(undefined); + deleteMock.mockResolvedValue(undefined); + const { serverAddResourceApprover, serverRemoveResourceApprover } = + await import("@/lib/firebase/server/adminDb"); + + await serverAddResourceApprover("room/a", " Person@NYU.EDU ", "mc"); + await serverRemoveResourceApprover("room/a", "person@nyu.edu", "mc"); + + expect(setMock).toHaveBeenCalledWith( + "mc-usersResourceApprovers", + deleteMock.mock.calls[0][1], + { + email: "person@nyu.edu", + resourceId: "room/a", + createdAt: "timestamp", + }, + ); + expect(deleteMock).toHaveBeenCalledWith( + "mc-usersResourceApprovers", + expect.any(String), + ); + }); + + it("uses final fallback when no approver is assigned to every resource", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "SAME@nyu.edu" } }, + { id: "2", data: { resourceId: "a", email: "same@nyu.edu" } }, + ]); + collections.set("mc-usersApprovers", [ + { id: "final", data: { level: 2, email: "FINAL@nyu.edu" } }, + ]); + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverResolveResourceApproverEmails(["a", "b", "b"], "mc"), + ).resolves.toEqual(["final@nyu.edu"]); + }); + + it("returns approvers assigned to every requested resource", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "one@nyu.edu" } }, + { id: "2", data: { resourceId: "b", email: "one@nyu.edu" } }, + { id: "3", data: { resourceId: "b", email: "other@nyu.edu" } }, + ]); + getFailures.set("mc-usersApprovers", new Error("must not query fallback")); + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverResolveResourceApproverEmails(["a", "b"], "mc"), + ).resolves.toEqual(["one@nyu.edu"]); + }); + + it("falls back when resources are only covered by different approvers", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "one@nyu.edu" } }, + { id: "2", data: { resourceId: "b", email: "two@nyu.edu" } }, + ]); + collections.set("mc-usersApprovers", [ + { id: "final", data: { level: 2, email: "FINAL@nyu.edu" } }, + ]); + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverResolveResourceApproverEmails(["a", "b"], "mc"), + ).resolves.toEqual(["final@nyu.edu"]); + }); + + it("throws resource and final-approver query failures", async () => { + const { serverResolveResourceApproverEmails } = + await import("@/lib/firebase/server/adminDb"); + + getFailures.set("mc-usersResourceApprovers", new Error("resource failure")); + await expect( + serverResolveResourceApproverEmails(["a"], "mc"), + ).rejects.toThrow("resource failure"); + + getFailures.clear(); + getFailures.set("mc-usersApprovers", new Error("final failure")); + await expect( + serverResolveResourceApproverEmails(["a"], "mc"), + ).rejects.toThrow("final failure"); + }); +}); diff --git a/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx b/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx new file mode 100644 index 000000000..e485e36c0 --- /dev/null +++ b/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResourceSpecific } from "@/components/src/client/routes/admin/components/ResourceSpecific"; +import { + defaultResource, + generateDefaultSchema, + SchemaProvider, +} from "@/components/src/client/routes/components/SchemaProvider"; +import { + clientAddResourceApprover, + clientListResourceApprovers, +} from "@/lib/firebase/firebase"; + +vi.mock("@/lib/firebase/firebase", () => ({ + clientAddResourceApprover: vi.fn(), + clientListResourceApprovers: vi.fn(), + clientRemoveResourceApprover: vi.fn(), +})); + +const listApproversMock = vi.mocked(clientListResourceApprovers); +const addApproverMock = vi.mocked(clientAddResourceApprover); + +describe("ResourceSpecific", () => { + beforeEach(() => { + vi.clearAllMocks(); + listApproversMock.mockResolvedValue([]); + addApproverMock.mockResolvedValue(); + }); + + it("adds a normalized approver for an opaque resource ID", async () => { + const user = userEvent.setup(); + const schema = generateDefaultSchema("tenant-one"); + schema.resources = [ + { + ...defaultResource, + name: "Audio Studio", + resourceId: "studio/a:floor-2", + }, + ]; + + render( + + + , + ); + + const input = await screen.findByLabelText( + "Approver email for Audio Studio", + ); + await user.type(input, " Person@NYU.EDU "); + await user.click( + screen.getByRole("button", { name: "Add approver for Audio Studio" }), + ); + + await waitFor(() => + expect(addApproverMock).toHaveBeenCalledWith( + "studio/a:floor-2", + "person@nyu.edu", + "tenant-one", + ), + ); + expect(screen.queryByText("Service Approvers")).not.toBeInTheDocument(); + }); +}); diff --git a/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts b/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts index eee7e0e57..da14d81a8 100644 --- a/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts +++ b/booking-app/tests/unit/xstate-approval-real-integration.unit.test.ts @@ -106,6 +106,7 @@ const mockServerDeleteData = vi.fn(); const mockServerDeleteDocumentFields = vi.fn(); const mockServerGetDocumentById = vi.fn(); const mockServerGetFinalApproverEmail = vi.fn(); +const mockServerResolveResourceApproverEmails = vi.fn(); // Mock XState utilities - but we'll use real XState machine in tests const mockExecuteXStateTransition = vi.fn(); @@ -120,6 +121,7 @@ vi.mock("@/lib/firebase/server/adminDb", () => ({ serverDeleteDocumentFields: mockServerDeleteDocumentFields, serverGetDocumentById: mockServerGetDocumentById, serverGetFinalApproverEmail: mockServerGetFinalApproverEmail, + serverResolveResourceApproverEmails: mockServerResolveResourceApproverEmails, })); // Don't mock XState utilities - we want to test the real XState machine @@ -188,6 +190,9 @@ describe("XState Approval Real Integration Tests", () => { mockServerDeleteDocumentFields.mockResolvedValue(undefined); mockServerGetDocumentById.mockResolvedValue(null); mockServerGetFinalApproverEmail.mockResolvedValue("finalapprover@nyu.edu"); + mockServerResolveResourceApproverEmails.mockResolvedValue([ + "finalapprover@nyu.edu", + ]); }); describe("1. XState Machine Transitions", () => { From b15bcac3fbccda692c7b24eb12ef8c4c953bb39c Mon Sep 17 00:00:00 2001 From: n3xta Date: Sun, 14 Jun 2026 23:28:16 -0400 Subject: [PATCH 25/51] Revert "feat(schema): migrate tenant resources to string resource IDs" This reverts commit 113d8634cd7107250f2e7ea95ab4a97ef296cfca. --- booking-app/app/api/bookings/edit/route.ts | 2 +- booking-app/app/api/bookings/export/route.ts | 2 +- .../app/api/bookings/modification/route.ts | 2 +- .../app/api/bookings/request-limits/route.ts | 6 +- booking-app/app/api/bookings/route.ts | 29 +- booking-app/app/api/bookings/shared.ts | 2 +- booking-app/app/api/bookingsDirect/route.ts | 4 +- .../app/api/cancel-processing/route.ts | 5 +- booking-app/app/api/inviteUser/route.ts | 2 +- .../app/api/safety_training_form/route.ts | 4 +- booking-app/app/api/syncCalendars/route.ts | 12 +- .../api/syncSemesterPregameBookings/route.ts | 4 +- .../policySettings/BookingBlackoutPeriods.tsx | 114 ++++---- .../policySettings/OperationalHours.tsx | 22 +- .../policySettings/OperationalHoursRow.tsx | 15 +- .../routes/admin/hooks/useExistingBooking.tsx | 2 +- .../client/routes/booking/bookingProvider.tsx | 6 +- .../components/BookingFormMediaServices.tsx | 4 +- .../components/CalendarVerticalResource.tsx | 35 +-- .../routes/booking/components/SelectRooms.tsx | 51 ++-- .../booking/formPages/SelectRoomPage.tsx | 9 +- .../hooks/useBookingDateRestrictions.tsx | 48 +--- .../booking/hooks/useCalculateOverlap.tsx | 4 +- .../booking/hooks/useCheckAutoApproval.tsx | 11 +- .../booking/hooks/useCheckDurationLimits.tsx | 2 +- .../booking/hooks/useCheckRequestLimits.tsx | 4 +- .../booking/utils/buildBlockPastTimes.ts | 4 +- .../src/client/routes/components/Provider.tsx | 5 +- .../client/routes/components/schemaTypes.ts | 4 +- .../src/client/routes/super/schemaEditor.tsx | 15 +- booking-app/components/src/server/admin.ts | 10 +- .../components/src/server/calendars.ts | 6 +- booking-app/components/src/server/db.ts | 2 +- .../src/testHelpers/testTenantSchemas.ts | 6 +- booking-app/components/src/types.ts | 6 +- .../components/src/utils/blackoutUtils.ts | 4 +- booking-app/lib/bookingRequestLimits.ts | 64 ++--- booking-app/lib/tenant/coerceTenantSchema.ts | 64 +---- booking-app/lib/utils/testTenantSchema.ts | 12 +- booking-app/package.json | 4 +- .../scripts/migrateTenantResourceIds.js | 251 ------------------ .../unit/coerce-tenant-schema.unit.test.ts | 65 ----- .../migrate-tenant-resource-ids.unit.test.ts | 175 ------------ .../tests/unit/request-limits.unit.test.ts | 104 +------- .../unit/schema-editor-resource.unit.test.tsx | 29 -- .../unit/schemaDefaults.addOnly.unit.test.ts | 4 - 46 files changed, 251 insertions(+), 984 deletions(-) delete mode 100644 booking-app/scripts/migrateTenantResourceIds.js delete mode 100644 booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts delete mode 100644 booking-app/tests/unit/schema-editor-resource.unit.test.tsx diff --git a/booking-app/app/api/bookings/edit/route.ts b/booking-app/app/api/bookings/edit/route.ts index c1abc01d1..17a669ac9 100644 --- a/booking-app/app/api/bookings/edit/route.ts +++ b/booking-app/app/api/bookings/edit/route.ts @@ -251,7 +251,7 @@ export async function PUT(request: NextRequest) { ); const selectedRoomIds = selectedRooms - .map((r: { roomId: string }) => r.roomId) + .map((r: { roomId: number }) => r.roomId) .join(", "); console.log("✏️ EDIT: Deleting old calendar events"); diff --git a/booking-app/app/api/bookings/export/route.ts b/booking-app/app/api/bookings/export/route.ts index e436776e7..0c1c45423 100644 --- a/booking-app/app/api/bookings/export/route.ts +++ b/booking-app/app/api/bookings/export/route.ts @@ -169,7 +169,7 @@ export async function GET(request: NextRequest) { : []; const rooms: RoomSetting[] = resourcesWithCorrectCalendarIds.map( (resource: any) => ({ - roomId: String(resource.resourceId ?? resource.roomId), + roomId: resource.roomId, name: resource.name, capacity: resource.capacity.toString(), calendarId: resource.calendarId, diff --git a/booking-app/app/api/bookings/modification/route.ts b/booking-app/app/api/bookings/modification/route.ts index 5248cf4d0..6c3c053cc 100644 --- a/booking-app/app/api/bookings/modification/route.ts +++ b/booking-app/app/api/bookings/modification/route.ts @@ -124,7 +124,7 @@ export async function PUT(request: NextRequest) { ); const selectedRoomIds = selectedRooms - .map((r: { roomId: string }) => r.roomId) + .map((r: { roomId: number }) => r.roomId) .join(", "); console.log("🔧 MODIFICATION: Deleting old calendar events"); diff --git a/booking-app/app/api/bookings/request-limits/route.ts b/booking-app/app/api/bookings/request-limits/route.ts index 5b1faf22c..dcec8dfc7 100644 --- a/booking-app/app/api/bookings/request-limits/route.ts +++ b/booking-app/app/api/bookings/request-limits/route.ts @@ -18,10 +18,10 @@ export async function POST(request: NextRequest) { const formContext = body?.formContext as FormContextLevel | undefined; const roomIdsRaw = body?.roomIds; - const roomIds: string[] = Array.isArray(roomIdsRaw) + const roomIds: number[] = Array.isArray(roomIdsRaw) ? roomIdsRaw - .map((x: unknown) => (x == null ? "" : String(x).trim())) - .filter((resourceId: string) => resourceId.length > 0) + .map((x: unknown) => Number(x)) + .filter((n: number) => Number.isFinite(n)) : []; const tenant = extractTenantFromRequest(request); diff --git a/booking-app/app/api/bookings/route.ts b/booking-app/app/api/bookings/route.ts index e34de94eb..e66e470a1 100644 --- a/booking-app/app/api/bookings/route.ts +++ b/booking-app/app/api/bookings/route.ts @@ -165,7 +165,7 @@ const getTenantRooms = async (tenant?: string) => { ); return resourcesWithCorrectCalendarIds.map((resource: any) => ({ - roomId: String(resource.resourceId ?? resource.roomId), + roomId: resource.roomId, name: resource.name, capacity: resource.capacity?.toString(), calendarId: resource.calendarId, @@ -233,7 +233,9 @@ async function createBookingCalendarEvent( throw Error(`calendarId not found for room ${room.roomId}`); } - const selectedRoomIds = selectedRooms.map((r) => r.roomId); + const selectedRoomIds = selectedRooms.map( + (r: { roomId: number }) => r.roomId, + ); const otherRoomEmails = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); @@ -576,18 +578,13 @@ export async function POST(request: NextRequest) { FormContextLevel.FULL_FORM, bookingRoleField, ); - const selectedRoomIdsForLimits: string[] = Array.isArray(selectedRooms) + const selectedRoomIdsNums: number[] = Array.isArray(selectedRooms) ? selectedRooms - .map((r: any) => (r?.roomId == null ? "" : String(r.roomId).trim())) - .filter((resourceId: string) => resourceId.length > 0) + .map((r: any) => Number(r?.roomId)) + .filter((n: number) => Number.isFinite(n)) : []; - if ( - tenant && - email && - bookingRoleField && - selectedRoomIdsForLimits.length > 0 - ) { + if (tenant && email && bookingRoleField && selectedRoomIdsNums.length > 0) { const tenantSchema = await serverGetDocumentById( TableNames.TENANT_SCHEMA, tenant, @@ -599,7 +596,7 @@ export async function POST(request: NextRequest) { email, bookingRoleField, limitRoleKey, - selectedRoomIds: selectedRoomIdsForLimits, + selectedRoomIds: selectedRoomIdsNums, schema: tenantSchema, }); @@ -820,10 +817,12 @@ export async function POST(request: NextRequest) { // Generate Sequential ID early so it can be used in calendar description const sequentialId = await serverGetNextSequentialId("bookings", tenant); + const selectedRoomIds = selectedRooms + .map((r: { roomId: number }) => r.roomId) + .join(", "); const selectedRoomIdsArray = selectedRooms - .map((r: { roomId: number | string }) => String(r.roomId).trim()) - .filter((resourceId: string) => resourceId.length > 0); - const selectedRoomIds = selectedRoomIdsArray.join(", "); + .map((r: { roomId: number | string }) => Number((r as any)?.roomId)) + .filter((n: number) => Number.isFinite(n)); // Build booking contents for description const startDateObj = new Date(bookingCalendarInfo.startStr); diff --git a/booking-app/app/api/bookings/shared.ts b/booking-app/app/api/bookings/shared.ts index 0759fd21a..287f23e2e 100644 --- a/booking-app/app/api/bookings/shared.ts +++ b/booking-app/app/api/bookings/shared.ts @@ -104,7 +104,7 @@ export const getTenantRooms = async (tenant?: string) => { ); return resourcesWithCorrectCalendarIds.map((resource: any) => ({ - roomId: String(resource.resourceId ?? resource.roomId), + roomId: resource.roomId, name: resource.name, capacity: resource.capacity?.toString(), calendarId: resource.calendarId, diff --git a/booking-app/app/api/bookingsDirect/route.ts b/booking-app/app/api/bookingsDirect/route.ts index 8d766b513..67e3cae22 100644 --- a/booking-app/app/api/bookingsDirect/route.ts +++ b/booking-app/app/api/bookingsDirect/route.ts @@ -98,7 +98,9 @@ export async function POST(request: NextRequest) { const { departmentDisplay, schoolDisplay } = getAffiliationDisplayValues(data); const [room, ...otherRooms] = selectedRooms; - const selectedRoomIds = selectedRooms.map((r: { roomId: string }) => r.roomId); + const selectedRoomIds = selectedRooms.map( + (r: { roomId: number }) => r.roomId, + ); const otherRoomIds = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); diff --git a/booking-app/app/api/cancel-processing/route.ts b/booking-app/app/api/cancel-processing/route.ts index fdb459e42..efdd1f114 100644 --- a/booking-app/app/api/cancel-processing/route.ts +++ b/booking-app/app/api/cancel-processing/route.ts @@ -55,8 +55,7 @@ export async function POST(req: NextRequest) { const roomIds = booking.roomId.split(",").map(x => x.trim()); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => - roomIds.includes(String(resource.resourceId ?? resource.roomId)), + (resource: any) => roomIds.includes(`${resource.roomId}`), ); console.log( @@ -65,7 +64,7 @@ export async function POST(req: NextRequest) { calendarEventId, roomIds, rooms: rooms.map((r: any) => ({ - roomId: String(r.resourceId ?? r.roomId), + roomId: r.roomId, calendarId: r.calendarId, })), }, diff --git a/booking-app/app/api/inviteUser/route.ts b/booking-app/app/api/inviteUser/route.ts index 22bc2bc08..554e54905 100644 --- a/booking-app/app/api/inviteUser/route.ts +++ b/booking-app/app/api/inviteUser/route.ts @@ -15,7 +15,7 @@ export async function POST(request: NextRequest) { await inviteUserToCalendarEvent( calendarEventId, guestEmail, - String(roomId), + parseInt(roomId, 10), tenant, ); return NextResponse.json({ calendarEventId }, { status: 200 }); diff --git a/booking-app/app/api/safety_training_form/route.ts b/booking-app/app/api/safety_training_form/route.ts index 3fccd2347..4add26094 100644 --- a/booking-app/app/api/safety_training_form/route.ts +++ b/booking-app/app/api/safety_training_form/route.ts @@ -37,7 +37,9 @@ export async function GET(request: NextRequest) { let formId: string | null = null; if (resourceId) { - const resource = schema.resources?.find(r => r.resourceId === resourceId); + const resource = schema.resources?.find( + (r) => r.roomId?.toString() === resourceId.toString(), + ); const resourceForm = resource?.training?.formId; if (resourceForm) { formId = extractGoogleFormId(resourceForm); diff --git a/booking-app/app/api/syncCalendars/route.ts b/booking-app/app/api/syncCalendars/route.ts index a3a6ce596..ea39826c1 100644 --- a/booking-app/app/api/syncCalendars/route.ts +++ b/booking-app/app/api/syncCalendars/route.ts @@ -99,20 +99,20 @@ const findRoomIds = (event: any, resources: any[]): string => { r => r.calendarId === event.organizer.email, ); if (currentResource) { - roomIds.add(String(currentResource.resourceId ?? currentResource.roomId)); + roomIds.add(currentResource.roomId); } // Add other room IDs attendees.forEach((attendee: any) => { const resource = resources.find(r => r.calendarId === attendee.email); if (resource) { - roomIds.add(String(resource.resourceId ?? resource.roomId)); + roomIds.add(resource.roomId); } }); - // Resource IDs are opaque strings; numeric sorting is only a display aid. + // Convert to array, sort numerically, and join return Array.from(roomIds) - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .sort((a, b) => parseInt(a) - parseInt(b)) .join(","); }; @@ -135,9 +135,9 @@ export async function POST(request: Request) { : []; const resources = resourcesWithCorrectCalendarIds.map((resource: any) => ({ - id: String(resource.resourceId ?? resource.roomId), + id: resource.roomId.toString(), calendarId: resource.calendarId, - roomId: String(resource.resourceId ?? resource.roomId), + roomId: resource.roomId, })); let totalNewBookings = 0; diff --git a/booking-app/app/api/syncSemesterPregameBookings/route.ts b/booking-app/app/api/syncSemesterPregameBookings/route.ts index ba031862b..6637c9a9a 100644 --- a/booking-app/app/api/syncSemesterPregameBookings/route.ts +++ b/booking-app/app/api/syncSemesterPregameBookings/route.ts @@ -594,9 +594,9 @@ export async function POST(request: NextRequest) { : []; const resources = resourcesWithCorrectCalendarIds.map((resource: any) => ({ - id: String(resource.resourceId ?? resource.roomId), + id: resource.roomId.toString(), calendarId: resource.calendarId, - roomId: String(resource.resourceId ?? resource.roomId), + roomId: resource.roomId, })); if (testMode) { diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx index 5b6d3d607..9d5131d82 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx @@ -62,13 +62,6 @@ type RoomApplicationType = | "multi" | "specific"; -type RuntimeBlackoutPeriod = Omit & { - roomIds?: string[]; -}; - -const sortResourceIds = (resourceIds: string[]) => - resourceIds.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - /** * Check if two sorted arrays are equal */ @@ -81,17 +74,16 @@ function areSortedArraysEqual(arr1: T[], arr2: T[]): boolean { export default function BookingBlackoutPeriods() { const { roomSettings } = useContext(DatabaseContext); - const [blackoutPeriods, setBlackoutPeriods] = useState< - RuntimeBlackoutPeriod[] - >([]); + const [blackoutPeriods, setBlackoutPeriods] = useState([]); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string; } | null>(null); const [dialogOpen, setDialogOpen] = useState(false); - const [editingPeriod, setEditingPeriod] = - useState(null); + const [editingPeriod, setEditingPeriod] = useState( + null, + ); // Form state const [periodName, setPeriodName] = useState(""); @@ -99,7 +91,7 @@ export default function BookingBlackoutPeriods() { const [endDate, setEndDate] = useState(null); const [startTime, setStartTime] = useState(null); const [endTime, setEndTime] = useState(null); - const [selectedRooms, setSelectedRooms] = useState([]); + const [selectedRooms, setSelectedRooms] = useState([]); const [roomApplicationType, setRoomApplicationType] = useState("all"); @@ -114,15 +106,6 @@ export default function BookingBlackoutPeriods() { }); }, [roomSettings]); - const getCategoryResourceIds = (legacyRoomIds: readonly number[]) => { - const legacyIds = new Set(legacyRoomIds.map(String)); - return sortResourceIds( - roomSettings - .filter((room) => legacyIds.has(room.roomId)) - .map((room) => room.roomId), - ); - }; - const fetchBlackoutPeriods = async () => { try { const fetchedData = @@ -130,22 +113,17 @@ export default function BookingBlackoutPeriods() { TableNames.BLACKOUT_PERIODS, ); setBlackoutPeriods( - fetchedData - .map((period) => ({ - ...period, - roomIds: period.roomIds?.map(String), - })) - .sort( - (a, b) => - a.startDate.toDate().getTime() - b.startDate.toDate().getTime(), - ), + fetchedData.sort( + (a, b) => + a.startDate.toDate().getTime() - b.startDate.toDate().getTime(), + ), ); } catch (error) { console.error("Error fetching blackout periods:", error); } }; - const handleOpenDialog = (period?: RuntimeBlackoutPeriod) => { + const handleOpenDialog = (period?: BlackoutPeriod) => { if (period) { setEditingPeriod(period); setPeriodName(period.name); @@ -169,13 +147,19 @@ export default function BookingBlackoutPeriods() { if (period.roomIds && period.roomIds.length > 0) { // Check what type of room application this is - const allRoomIds = sortResourceIds( - roomSettings.map((room) => room.roomId), - ); - const periodRoomIds = sortResourceIds([...period.roomIds]); - const productionRoomIds = getCategoryResourceIds(PRODUCTION_ROOMS); - const eventRoomIds = getCategoryResourceIds(EVENT_ROOMS); - const multiRoomIds = getCategoryResourceIds(MULTI_ROOMS); + const allRoomIds = roomSettings + .map((room) => room.roomId) + .sort((a, b) => a - b); + const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); + const productionRoomIds = PRODUCTION_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).sort((a, b) => a - b); + const eventRoomIds = EVENT_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).sort((a, b) => a - b); + const multiRoomIds = MULTI_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).sort((a, b) => a - b); const isAllRooms = allRoomIds.length === periodRoomIds.length && @@ -275,26 +259,32 @@ export default function BookingBlackoutPeriods() { try { // Determine which rooms to apply the blackout to - let roomIds: string[] = []; + let roomIds: number[] = []; switch (roomApplicationType) { case "all": roomIds = roomSettings.map((room) => room.roomId); break; case "production": - roomIds = getCategoryResourceIds(PRODUCTION_ROOMS); + roomIds = PRODUCTION_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ); break; case "event": - roomIds = getCategoryResourceIds(EVENT_ROOMS); + roomIds = EVENT_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ); break; case "multi": - roomIds = getCategoryResourceIds(MULTI_ROOMS); + roomIds = MULTI_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ); break; case "specific": roomIds = selectedRooms; break; } - const periodData: Omit = { + const periodData: Omit = { name: periodName.trim(), startDate: Timestamp.fromDate(startDate.toDate()), endDate: Timestamp.fromDate(endDate.toDate()), @@ -340,7 +330,7 @@ export default function BookingBlackoutPeriods() { } }; - const handleDeletePeriod = async (period: RuntimeBlackoutPeriod) => { + const handleDeletePeriod = async (period: BlackoutPeriod) => { if (!window.confirm(`Are you sure you want to delete "${period.name}"?`)) { return; } @@ -378,17 +368,25 @@ export default function BookingBlackoutPeriods() { return dateStr; }; - const getRoomNames = (period: RuntimeBlackoutPeriod) => { + const getRoomNames = (period: BlackoutPeriod) => { if (!period.roomIds || period.roomIds.length === 0) { return "All Rooms"; } // Check if this matches any of our predefined categories - const allRoomIds = sortResourceIds(roomSettings.map((room) => room.roomId)); - const periodRoomIds = sortResourceIds([...period.roomIds]); - const productionRoomIds = getCategoryResourceIds(PRODUCTION_ROOMS); - const eventRoomIds = getCategoryResourceIds(EVENT_ROOMS); - const multiRoomIds = getCategoryResourceIds(MULTI_ROOMS); + const allRoomIds = roomSettings + .map((room) => room.roomId) + .sort((a, b) => a - b); + const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); + const productionRoomIds = PRODUCTION_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).sort((a, b) => a - b); + const eventRoomIds = EVENT_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).sort((a, b) => a - b); + const multiRoomIds = MULTI_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).sort((a, b) => a - b); const isAllRooms = areSortedArraysEqual(allRoomIds, periodRoomIds); const isProductionRooms = areSortedArraysEqual( @@ -420,13 +418,19 @@ export default function BookingBlackoutPeriods() { }; const getProductionRoomNumbers = () => - getCategoryResourceIds(PRODUCTION_ROOMS).join(", "); + PRODUCTION_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).join(", "); const getEventRoomNumbers = () => - getCategoryResourceIds(EVENT_ROOMS).join(", "); + EVENT_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).join(", "); const getMultiRoomNumbers = () => - getCategoryResourceIds(MULTI_ROOMS).join(", "); + MULTI_ROOMS.filter((roomId) => + roomSettings.some((room) => room.roomId === roomId), + ).join(", "); return ( @@ -627,7 +631,7 @@ export default function BookingBlackoutPeriods() { multiple value={selectedRooms} onChange={(e) => { - const value = e.target.value as string[]; + const value = e.target.value as number[]; setSelectedRooms(value); }} input={} diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx index 79eae255d..cca00ac49 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx @@ -34,15 +34,10 @@ const MenuProps = { export default function OperationalHours() { const { operationHours, roomSettings } = useContext(DatabaseContext); - const [specialHourRooms, setSpecialHourRooms] = useState( + const [specialHourRooms, setSpecialHourRooms] = useState( Array.from( - new Set( - operationHours - .map((x) => x.roomId) - .filter((x) => x != null) - .map(String), - ), - ).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), + new Set(operationHours.map((x) => x.roomId).filter((x) => x != null)), + ).sort((a, b) => a - b), ); // roomIds const handleChange = (event: SelectChangeEvent) => { @@ -50,10 +45,11 @@ export default function OperationalHours() { target: { value }, } = event; // On autofill we get a stringified value. - const list = typeof value === "string" ? value.split(",") : value; - setSpecialHourRooms( - list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), - ); + const list = + typeof value === "string" + ? value.split(",").map((x) => Number(x)) + : value; + setSpecialHourRooms(list.sort((a, b) => a - b)); }; return ( @@ -125,7 +121,7 @@ export default function OperationalHours() { x.day === day && String(x.roomId) === roomId, + (x) => x.day === day && x.roomId === roomId, )} roomId={roomId} key={day} diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx index 675c62f1d..619872b86 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx @@ -18,18 +18,11 @@ const Row = styled(Box)` interface Props { day: Days; setting: OperationHours; - roomId?: string; + roomId?: number; } export default function OperationalHoursRow({ day, setting, roomId }: Props) { const { reloadOperationHours } = useContext(DatabaseContext); - const updateResourceOperationHours = updateOperationHours as unknown as ( - day: Days, - open: number, - close: number, - isClosed: boolean, - roomId?: string, - ) => Promise; const [closed, setClosed] = useState(setting?.isClosed || false); const [openDate, setOpenDate] = useState( @@ -41,7 +34,7 @@ export default function OperationalHoursRow({ day, setting, roomId }: Props) { const handleSwitch = (e) => { setClosed(!e.target.checked); - updateResourceOperationHours( + updateOperationHours( day, openDate.hour(), closeDate.hour(), @@ -52,12 +45,12 @@ export default function OperationalHoursRow({ day, setting, roomId }: Props) { const handleOpenChange = (e) => { setOpenDate(e); - updateResourceOperationHours(day, e.$H, closeDate.hour(), closed, roomId); + updateOperationHours(day, e.$H, closeDate.hour(), closed, roomId); }; const handleCloseChange = (e) => { setCloseDate(e); - updateResourceOperationHours(day, openDate.hour(), e.$H, closed, roomId); + updateOperationHours(day, openDate.hour(), e.$H, closed, roomId); }; // when we leave the page, the app reloads the newly set operation hours. diff --git a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx index 954d66a6f..9fd20bfca 100644 --- a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx +++ b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx @@ -25,7 +25,7 @@ export default function useExistingBooking() { setDepartment(booking.department as Department); setRole(booking.role as Role); - const roomIds = booking.roomId.split(",").map((roomId) => roomId.trim()); + const roomIds = booking.roomId.split(", ").map((x) => Number(x)); const rooms = roomSettings.filter((roomSetting) => roomIds.includes(roomSetting.roomId), ); diff --git a/booking-app/components/src/client/routes/booking/bookingProvider.tsx b/booking-app/components/src/client/routes/booking/bookingProvider.tsx index e93aa6152..c26a6db7e 100644 --- a/booking-app/components/src/client/routes/booking/bookingProvider.tsx +++ b/booking-app/components/src/client/routes/booking/bookingProvider.tsx @@ -161,13 +161,9 @@ export function BookingProvider({ children }) { const bookingStart = dayjs(bookingCalendarInfo.start); const bookingEnd = dayjs(bookingCalendarInfo.end); const selectedRoomIds = selectedRooms.map((room) => room.roomId); - const stringIdBlackoutPeriods = blackoutPeriods.map((period) => ({ - ...period, - roomIds: period.roomIds?.map(String), - })); const affectingPeriods = getAffectingBlackoutPeriods( - stringIdBlackoutPeriods, + blackoutPeriods, bookingStart, bookingEnd, selectedRoomIds, diff --git a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx index 076b38a32..4076af768 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx @@ -51,11 +51,11 @@ export default function BookingFormMediaServices(props: Props) { // Check for specific room services selectedRooms.forEach((room) => { if (showStaffing) { - if (room.roomId === "103") { + if (room.roomId === 103) { options.push(MediaServices.AUDIO_TECH_103); options.push(MediaServices.LIGHTING_TECH_103); } - if (room.roomId === "230") { + if (room.roomId === 230) { options.push(MediaServices.AUDIO_TECH_230); } if (room.services?.includes("campus-media")) { diff --git a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx index bf4469a1c..66aaefe33 100644 --- a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx +++ b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx @@ -141,17 +141,11 @@ export default function CalendarVerticalResource({ const resources = useMemo( () => - [...rooms] - .sort((a, b) => - String(a.roomId).localeCompare(String(b.roomId), undefined, { - numeric: true, - }), - ) - .map((room, index) => ({ - id: room.roomId, - title: `${room.roomId} ${room.name}`, - index, - })), + rooms.map((room) => ({ + id: `${room.roomId}`, + title: `${room.roomId} ${room.name}`, + index: Number(room.roomId), + })), [rooms], ); @@ -175,7 +169,7 @@ export default function CalendarVerticalResource({ start: blackoutRange.start.toISOString(), end: blackoutRange.end.toISOString(), id: `blackout-${room.roomId}-${period.id}`, - resourceId: room.roomId, + resourceId: `${room.roomId}`, title: blackoutRange.title, overlap: false, display: "background", @@ -214,11 +208,12 @@ export default function CalendarVerticalResource({ start: bookingCalendarInfo.startStr, end: bookingCalendarInfo.endStr, id: room.roomId + bookingCalendarInfo.startStr, - resourceId: room.roomId, + resourceId: `${room.roomId}`, title: NEW_TITLE_TAG, overlap: true, durationEditable: true, - startEditable: formContext !== FormContextLevel.MODIFICATION || isAdmin, + startEditable: + formContext !== FormContextLevel.MODIFICATION || isAdmin, groupId: "new", url: `${index}:${rooms.length}`, // some hackiness to let us render multiple events visually as one big block })); @@ -246,9 +241,11 @@ export default function CalendarVerticalResource({ const selectedResourceId = selectInfo.resource?.id; if (!isAdmin && selectedResourceId) { + const roomId = parseInt(selectedResourceId); + // Use the new time-aware blackout checking const { inBlackout } = isBookingTimeInBlackout(bookingStart, bookingEnd, [ - selectedResourceId, + roomId, ]); if (inBlackout) { @@ -270,9 +267,11 @@ export default function CalendarVerticalResource({ const selectedResourceId = selectInfo.resource?.id; if (!isAdmin && selectedResourceId) { + const roomId = parseInt(selectedResourceId); + // Use the new time-aware blackout checking const { inBlackout } = isBookingTimeInBlackout(bookingStart, bookingEnd, [ - selectedResourceId, + roomId, ]); if (inBlackout) { @@ -408,7 +407,9 @@ export default function CalendarVerticalResource({ googleCalendarPlugin, interactionPlugin, ]} - selectable={formContext !== FormContextLevel.MODIFICATION || isAdmin} + selectable={ + formContext !== FormContextLevel.MODIFICATION || isAdmin + } select={handleEventSelect} selectAllow={handleEventSelecting} selectOverlap={handleSelectOverlap} diff --git a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx index 2bb30d207..36e0922fd 100644 --- a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx +++ b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx @@ -32,18 +32,13 @@ export const SelectRooms = ({ } = useContext(BookingContext); const { isBookingTimeInBlackout } = useBookingDateRestrictions(); const { resources, calendarConfig } = useTenantSchema(); - const selectedIds = selected.map((room) => String(room.roomId)); + const selectedIds = selected.map((room) => room.roomId); const allowMultipleResourceSelect = calendarConfig?.multipleResourceSelect ?? true; // Sort rooms by room number for consistent display order const sortedRooms = useMemo( - () => - [...allRooms].sort((a, b) => - String(a.roomId).localeCompare(String(b.roomId), undefined, { - numeric: true, - }), - ), + () => [...allRooms].sort((a, b) => a.roomId - b.roomId), [allRooms], ); @@ -58,7 +53,7 @@ export const SelectRooms = ({ // Check if a room is in blackout for the selected booking time const isRoomInBlackout = ( - roomId: string, + roomId: number, ): { inBlackout: boolean; periods: any[] } => { if (!bookingCalendarInfo) return { inBlackout: false, periods: [] }; @@ -74,7 +69,7 @@ export const SelectRooms = ({ }; // walk-ins can only book 1 room unless it's 2 ballroom bays (221-224) - const isDisabled = (roomId: string) => { + const isDisabled = (roomId: number) => { if (!allowMultipleResourceSelect) { if (selectedIds.length === 0) return false; if (selectedIds.includes(roomId)) return false; @@ -90,11 +85,9 @@ export const SelectRooms = ({ // Check if both selected room and current room can book two const selectedRoom = resources.find( - (resource) => resource.resourceId === selectedIds[0], - ); - const currentRoom = resources.find( - (resource) => resource.resourceId === roomId, + (r: any) => r.roomId === selectedIds[0], ); + const currentRoom = resources.find((r: any) => r.roomId === roomId); if (selectedRoom?.isWalkInCanBookTwo && currentRoom?.isWalkInCanBookTwo) { return false; @@ -102,7 +95,7 @@ export const SelectRooms = ({ return true; }; - const getDisabledReason = (roomId: string): string | null => { + const getDisabledReason = (roomId: number): string | null => { const blackoutInfo = isRoomInBlackout(roomId); if (blackoutInfo.inBlackout) { const periodNames = blackoutInfo.periods.map((p) => p.name).join(", "); @@ -118,11 +111,9 @@ export const SelectRooms = ({ // Check if both selected room and current room can book two const selectedRoom = resources.find( - (resource) => resource.resourceId === selectedIds[0], - ); - const currentRoom = resources.find( - (resource) => resource.resourceId === roomId, + (r: any) => r.roomId === selectedIds[0], ); + const currentRoom = resources.find((r: any) => r.roomId === roomId); if (selectedRoom?.isWalkInCanBookTwo && currentRoom?.isWalkInCanBookTwo) { return null; @@ -133,27 +124,22 @@ export const SelectRooms = ({ const handleCheckChange = (e: any, room: RoomSetting) => { const newVal: boolean = e.target.checked; - const normalizedRoom = { ...room, roomId: String(room.roomId) }; setSelected((prev: RoomSetting[]) => { if (newVal) { if ( !allowMultipleResourceSelect && prev.length > 0 && - !prev.some((r) => String(r.roomId) === normalizedRoom.roomId) + !prev.some((r) => r.roomId === room.roomId) ) { return prev; } - const newSelection = [...prev, normalizedRoom].sort((a, b) => - String(a.roomId).localeCompare(String(b.roomId), undefined, { - numeric: true, - }), + const newSelection = [...prev, room].sort( + (a, b) => a.roomId - b.roomId, ); return newSelection; } - const newSelection = prev.filter( - (r) => String(r.roomId) !== normalizedRoom.roomId, - ); + const newSelection = prev.filter((r) => r.roomId !== room.roomId); if (newSelection.length === 0) { setBookingCalendarInfo(null); } @@ -164,20 +150,17 @@ export const SelectRooms = ({ return ( {sortedRooms.map((room: RoomSetting) => { - const roomId = String(room.roomId); - const disabled = isDisabled(roomId); - const disabledReason = getDisabledReason(roomId); + const disabled = isDisabled(room.roomId); + const disabledReason = getDisabledReason(room.roomId); const checkbox = ( handleCheckChange(e, room)} icon={ - allowMultipleResourceSelect ? undefined : ( - - ) + allowMultipleResourceSelect ? undefined : } checkedIcon={ allowMultipleResourceSelect ? undefined : diff --git a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx index 845f67a1b..5256d6531 100644 --- a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx @@ -47,7 +47,7 @@ export default function SelectRoomPage({ // Convert schema resources to RoomSetting format for compatibility const convertedResources = resources.map((resource) => ({ ...resource, - roomId: resource.resourceId, + roomId: resource.roomId, name: resource.name, capacity: resource.capacity.toString(), calendarId: resource.calendarId, @@ -68,7 +68,12 @@ export default function SelectRoomPage({ const allRooms = !isWalkIn ? convertedResources - : convertedResources.filter((room) => room.isWalkIn); + : convertedResources.filter((room) => { + const resource = schema.resources.find( + (r: any) => r.roomId === room.roomId, + ); + return resource?.isWalkIn || false; + }); return allRooms; }, [schema.resources, isWalkIn]); diff --git a/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx b/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx index 1771db171..6c87fc561 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx @@ -16,15 +16,6 @@ export const useBookingDateRestrictions = () => { return blackoutPeriods.filter((period) => period.isActive); }, [blackoutPeriods]); - const stringIdBlackoutPeriods = useMemo( - () => - activeBlackoutPeriods.map((period) => ({ - ...period, - roomIds: period.roomIds?.map(String), - })), - [activeBlackoutPeriods], - ); - const isDateDisabled = (date: Dayjs) => { // Always disable past dates if (date.isBefore(dayjs(), "day")) { @@ -42,12 +33,8 @@ export const useBookingDateRestrictions = () => { if (roomSettings && roomSettings.length > 0 && period.roomIds) { const allRoomIds = roomSettings .map((room) => room.roomId) - .sort((a, b) => - String(a).localeCompare(String(b), undefined, { numeric: true }), - ); - const periodRoomIds = period.roomIds - .map(String) - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + .sort((a, b) => a - b); + const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); const isAllRooms = allRoomIds.length === periodRoomIds.length && allRoomIds.every((id, index) => id === periodRoomIds[index]); @@ -61,9 +48,7 @@ export const useBookingDateRestrictions = () => { }; // Check if a specific date is disabled for specific rooms - const isDateDisabledForRooms = (date: Dayjs, roomIds: string[]) => { - const normalizedRoomIds = roomIds.map(String); - + const isDateDisabledForRooms = (date: Dayjs, roomIds: number[]) => { // Always disable past dates if (date.isBefore(dayjs(), "day")) { return true; @@ -78,10 +63,7 @@ export const useBookingDateRestrictions = () => { // If period has roomIds, check if any of the selected rooms are in the blackout period if (period.roomIds) { - const periodRoomIds = period.roomIds.map(String); - return normalizedRoomIds.some((roomId) => - periodRoomIds.includes(roomId), - ); + return roomIds.some((roomId) => period.roomIds.includes(roomId)); } // If no roomIds, treat as global blackout that applies to all rooms @@ -96,9 +78,8 @@ export const useBookingDateRestrictions = () => { }); // Get blackout periods that apply to specific rooms for a specific date - const getBlackoutPeriodsForDateAndRooms = (date: Dayjs, roomIds: string[]) => + const getBlackoutPeriodsForDateAndRooms = (date: Dayjs, roomIds: number[]) => activeBlackoutPeriods.filter((period) => { - const normalizedRoomIds = roomIds.map(String); const blackoutRange = getBlackoutTimeRangeForDate(period, date); if (!blackoutRange) { return false; // Date not in blackout period @@ -106,10 +87,7 @@ export const useBookingDateRestrictions = () => { // If period has roomIds, check if any of the selected rooms are in the blackout period if (period.roomIds) { - const periodRoomIds = period.roomIds.map(String); - return normalizedRoomIds.some((roomId) => - periodRoomIds.includes(roomId), - ); + return roomIds.some((roomId) => period.roomIds.includes(roomId)); } // Include global blackout periods (those without roomIds) @@ -120,17 +98,17 @@ export const useBookingDateRestrictions = () => { const isBookingTimeInBlackout = ( bookingStart: Dayjs, bookingEnd: Dayjs, - roomIds: string[], + roomIds: number[], ): { inBlackout: boolean; affectedPeriods: any[] } => { if (!roomIds || roomIds.length === 0) { return { inBlackout: false, affectedPeriods: [] }; } const affectedPeriods = getAffectingBlackoutPeriods( - stringIdBlackoutPeriods, + activeBlackoutPeriods, bookingStart, bookingEnd, - roomIds.map(String), + roomIds, ); return { @@ -143,17 +121,17 @@ export const useBookingDateRestrictions = () => { const getBlackoutPeriodsForBookingTime = ( bookingStart: Dayjs, bookingEnd: Dayjs, - roomIds: string[], + roomIds: number[], ) => getAffectingBlackoutPeriods( - stringIdBlackoutPeriods, + activeBlackoutPeriods, bookingStart, bookingEnd, - roomIds.map(String), + roomIds, ); const shouldDisableDate = (date: Dayjs) => isDateDisabled(date); - const shouldDisableDateForRooms = (date: Dayjs, roomIds: string[]) => + const shouldDisableDateForRooms = (date: Dayjs, roomIds: number[]) => isDateDisabledForRooms(date, roomIds); return { diff --git a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx index d6e36e707..4d5a166a9 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx @@ -21,10 +21,10 @@ export default function useCalculateOverlap() { calendarEventId = segments[segments.length - 1]; } - const selectedRoomIds = selectedRooms.map((x) => String(x.roomId)); + const selectedRoomIds = selectedRooms.map((x) => x.roomId); return existingCalendarEvents .map((event) => { - if (!selectedRoomIds.includes(String(event.resourceId))) return false; + if (!selectedRoomIds.includes(Number(event.resourceId))) return false; // for edit/modification mode, don't overlap with existing booking if ( calendarEventId && diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx index c39a90358..0f97218cf 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx @@ -13,7 +13,7 @@ import { BookingContext } from "../bookingProvider"; import { getBookingHourLimits } from "../utils/bookingHourLimits"; export function selectedAutoApprovalRooms( - selectedRoomIds: string[], + selectedRoomIds: number[], selectedRooms: any[], ) { if (selectedRoomIds.length < 2) return true; @@ -47,10 +47,7 @@ export default function useCheckAutoApproval(isWalkIn = false, isVIP = false) { useEffect(() => { // For ITP and Media Commons tenants, use XState machine for auto-approval logic - if ( - schema.tenantId === TENANTS.ITP || - isMediaCommonsTenant(schema.tenantId) - ) { + if (schema.tenantId === TENANTS.ITP || isMediaCommonsTenant(schema.tenantId)) { console.log( `🎭 CLIENT-SIDE XSTATE CHECK [${schema.tenantId?.toUpperCase()}]:`, { @@ -75,9 +72,7 @@ export default function useCheckAutoApproval(isWalkIn = false, isVIP = false) { try { // Choose the appropriate machine based on tenant const machine = - schema.tenantId === TENANTS.ITP - ? itpBookingMachine - : mcBookingMachine; + schema.tenantId === TENANTS.ITP ? itpBookingMachine : mcBookingMachine; // For Media Commons, prepare services data const servicesRequested = isMediaCommonsTenant(schema.tenantId) diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx index 370182650..e05bf20c3 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx @@ -5,7 +5,7 @@ import { Role } from "../../../../types"; import { getBookingHourLimits } from "../utils/bookingHourLimits"; interface DurationLimitError { - roomId: string; + roomId: number; roomName: string; currentDuration: number; maxDuration: number; diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx index 6741c1147..0c9983adf 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx @@ -52,7 +52,9 @@ export default function useCheckRequestLimits(formContext: FormContextLevel) { email = userEmail || formData?.missingEmail; } - const roomIds = selectedRooms.map((room) => room.roomId); + const roomIds = selectedRooms + .map((r) => Number(r.roomId)) + .filter((n) => Number.isFinite(n)); if (!email?.trim() || roomIds.length === 0) { setRequestLimitChecking(false); diff --git a/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts b/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts index 8f37858ef..d64a084de 100644 --- a/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts +++ b/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts @@ -25,7 +25,7 @@ export interface BlockPastTimeEvent { * as a parameter so unit tests can control the clock. */ export function buildBlockPastTimes( - rooms: Array<{ roomId: string }>, + rooms: Array<{ roomId: number | string }>, dateView: Date, startHour: string | undefined, slotUnit: number, @@ -96,7 +96,7 @@ export function buildBlockPastTimes( start: blockStart.toISOString(), end: blockEnd.toISOString(), id: `${room.roomId}bg`, - resourceId: String(room.roomId), + resourceId: `${room.roomId}`, overlap: false, display: "background", classNames: ["disabled"], diff --git a/booking-app/components/src/client/routes/components/Provider.tsx b/booking-app/components/src/client/routes/components/Provider.tsx index 69b760fde..be52361f1 100644 --- a/booking-app/components/src/client/routes/components/Provider.tsx +++ b/booking-app/components/src/client/routes/components/Provider.tsx @@ -314,7 +314,6 @@ export const DatabaseProvider = ({ const rooms: RoomSetting[] = schemaContext.resources .map((resource) => ({ ...resource, - roomId: resource.resourceId, capacity: String(resource.capacity), needsSafetyTraining: resource.training?.required || false, trainingFormUrl: resource.training?.formId || undefined, @@ -324,9 +323,7 @@ export const DatabaseProvider = ({ isEquipment: resource.isEquipment || false, services: resource.services || [], })) - .sort((a, b) => - a.roomId.localeCompare(b.roomId, undefined, { numeric: true }), - ); + .sort((a, b) => a.roomId - b.roomId); setRoomSettings(rooms); } }, [schemaContext?.resources]); diff --git a/booking-app/components/src/client/routes/components/schemaTypes.ts b/booking-app/components/src/client/routes/components/schemaTypes.ts index d6c262763..06a608de8 100644 --- a/booking-app/components/src/client/routes/components/schemaTypes.ts +++ b/booking-app/components/src/client/routes/components/schemaTypes.ts @@ -37,7 +37,7 @@ export type RequestLimits = Partial< export type Resource = { capacity: number; name: string; - resourceId: string; + roomId: number; isEquipment: boolean; calendarId: string; training?: ResourceTraining; @@ -246,7 +246,7 @@ export const defaultAttestation: Attestation = { export const defaultResource: Resource = { capacity: 0, name: "", - resourceId: "", + roomId: 0, isEquipment: false, calendarId: "", training: { diff --git a/booking-app/components/src/client/routes/super/schemaEditor.tsx b/booking-app/components/src/client/routes/super/schemaEditor.tsx index 40f991141..f14fbd327 100644 --- a/booking-app/components/src/client/routes/super/schemaEditor.tsx +++ b/booking-app/components/src/client/routes/super/schemaEditor.tsx @@ -478,7 +478,7 @@ function AttestationsSection({ } // ─── Section: Resources ─── -export function ResourceEditor({ +function ResourceEditor({ resource, index, onUpdate, @@ -505,7 +505,7 @@ export function ResourceEditor({ {resource.name || `Resource ${index + 1}`} - {resource.resourceId ? ` (${resource.resourceId})` : ""} + {resource.roomId ? ` (Room ${resource.roomId})` : ""} @@ -518,11 +518,12 @@ export function ResourceEditor({ size="small" /> set("resourceId", e.target.value)} + label="Room ID" + type="number" + value={resource.roomId ?? 0} + onChange={(e) => set("roomId", Number(e.target.value))} size="small" - sx={{ width: 180 }} + sx={{ width: 120 }} /> {resources.map((r, i) => ( { }; export const serverGetRoomCalendarIds = async ( - roomId: string | number, + roomId: number, tenant?: string, ): Promise => { try { @@ -871,8 +871,7 @@ export const serverGetRoomCalendarIds = async ( ); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => - String(resource.resourceId ?? resource.roomId) === String(roomId), + (resource: any) => resource.roomId === roomId, ); console.log(`Rooms: ${JSON.stringify(rooms)}`); @@ -890,7 +889,7 @@ export const serverGetRoomCalendarIds = async ( }; export const serverGetRoomCalendarId = async ( - roomId: string | number, + roomId: number, tenant?: string, ): Promise => { try { @@ -911,8 +910,7 @@ export const serverGetRoomCalendarId = async ( ); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => - String(resource.resourceId ?? resource.roomId) === String(roomId), + (resource: any) => resource.roomId === roomId, ); if (rooms.length > 0) { diff --git a/booking-app/components/src/server/calendars.ts b/booking-app/components/src/server/calendars.ts index fd7813458..d552bc43d 100644 --- a/booking-app/components/src/server/calendars.ts +++ b/booking-app/components/src/server/calendars.ts @@ -30,7 +30,7 @@ export const patchCalendarEvent = async ( export const inviteUserToCalendarEvent = async ( calendarEventId: string, guestEmail: string, - roomId: string, + roomId: number, tenant?: string, ) => { const roomCalendarIds = await serverGetRoomCalendarIds(roomId, tenant); @@ -326,7 +326,9 @@ export const updateCalendarEvent = async ( } const roomCalendarIds = await serverGetRoomCalendarIds( - String(bookingContents.roomId).split(",")[0].trim(), + typeof bookingContents.roomId === "string" + ? parseInt(bookingContents.roomId, 10) + : bookingContents.roomId, tenant, ); console.log(`Room Calendar Ids: ${roomCalendarIds}`); diff --git a/booking-app/components/src/server/db.ts b/booking-app/components/src/server/db.ts index 5f413cd17..f94e3a59d 100644 --- a/booking-app/components/src/server/db.ts +++ b/booking-app/components/src/server/db.ts @@ -813,7 +813,7 @@ export const updateOperationHours = async ( open: number, close: number, isClosed: boolean, - roomId?: string, + roomId?: number, ) => { const docs = await clientFetchAllDataFromCollection< OperationHours & { id: string } diff --git a/booking-app/components/src/testHelpers/testTenantSchemas.ts b/booking-app/components/src/testHelpers/testTenantSchemas.ts index b735acd8e..5d67c6e97 100644 --- a/booking-app/components/src/testHelpers/testTenantSchemas.ts +++ b/booking-app/components/src/testHelpers/testTenantSchemas.ts @@ -81,7 +81,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 30, name: "Lecture Hall 202", - resourceId: "202", + roomId: 202, isEquipment: false, calendarId: "mock-calendar-202", training: { required: false, formId: "", infoUrl: "" }, @@ -114,7 +114,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 20, name: "Studio 220", - resourceId: "220", + roomId: 220, isEquipment: false, calendarId: "mock-calendar-220", training: { required: false, formId: "", infoUrl: "" }, @@ -147,7 +147,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 15, name: "Training Room 230", - resourceId: "230", + roomId: 230, isEquipment: false, calendarId: "mock-calendar-230", training: { required: true, formId: "https://docs.google.com/forms/d/e/mock/viewform", infoUrl: "" }, diff --git a/booking-app/components/src/types.ts b/booking-app/components/src/types.ts index ea94bbe74..e5a9e0e1b 100644 --- a/booking-app/components/src/types.ts +++ b/booking-app/components/src/types.ts @@ -251,7 +251,7 @@ export type OperationHours = { open: number; close: number; isClosed: boolean; - roomId?: string; + roomId?: number; }; export type PaUser = { @@ -286,7 +286,7 @@ export type BlackoutPeriod = { isActive: boolean; createdAt: Timestamp; updatedAt?: Timestamp; - roomIds?: string[]; // Optional resource IDs; empty/undefined applies to all resources + roomIds?: number[]; // Optional array of room IDs - if empty/undefined, applies to all rooms }; export type PolicySettings = { @@ -310,7 +310,7 @@ export enum Role { } export type RoomSetting = { - roomId: string; + roomId: number; name: string; capacity: string; calendarId: string; diff --git a/booking-app/components/src/utils/blackoutUtils.ts b/booking-app/components/src/utils/blackoutUtils.ts index 9832fc0ee..dd99a3771 100644 --- a/booking-app/components/src/utils/blackoutUtils.ts +++ b/booking-app/components/src/utils/blackoutUtils.ts @@ -133,7 +133,7 @@ export function isBookingInBlackoutPeriod( period: BlackoutPeriod, bookingStart: Dayjs, bookingEnd: Dayjs, - selectedRoomIds: string[], + selectedRoomIds: number[], ): boolean { const startDate = dayjs(period.startDate.toDate()); const endDate = dayjs(period.endDate.toDate()); @@ -209,7 +209,7 @@ export function getAffectingBlackoutPeriods( blackoutPeriods: BlackoutPeriod[], bookingStart: Dayjs, bookingEnd: Dayjs, - selectedRoomIds: string[], + selectedRoomIds: number[], ): BlackoutPeriod[] { const activeBlackoutPeriods = blackoutPeriods.filter( (period) => period.isActive, diff --git a/booking-app/lib/bookingRequestLimits.ts b/booking-app/lib/bookingRequestLimits.ts index 4d5f30723..20cfe1df9 100644 --- a/booking-app/lib/bookingRequestLimits.ts +++ b/booking-app/lib/bookingRequestLimits.ts @@ -30,14 +30,13 @@ function nyCalendarParts(now: Date) { /** Start of that calendar date at 00:00 in {@link REQUEST_LIMITS_TIME_ZONE} (UTC instant). */ function nyMidnight(y: number, monthIndex0: number, day: number): Date { - return toDate(`${y}-${pad2(monthIndex0 + 1)}-${pad2(day)}T00:00:00.000`, { - timeZone: REQUEST_LIMITS_TIME_ZONE, - }); + return toDate( + `${y}-${pad2(monthIndex0 + 1)}-${pad2(day)}T00:00:00.000`, + { timeZone: REQUEST_LIMITS_TIME_ZONE }, + ); } -export function parseRoleEnumFromLabel( - label: string | undefined, -): Role | undefined { +export function parseRoleEnumFromLabel(label: string | undefined): Role | undefined { if (!label) return undefined; const normalized = label.trim().toLowerCase(); const entries = Object.values(Role) as string[]; @@ -171,29 +170,19 @@ export function getNewYorkWindowForPeriod( /** @deprecated Use {@link getNewYorkWindowForPeriod} */ export const getLocalWindowForPeriod = getNewYorkWindowForPeriod; -function normalizeResourceId(value: unknown): string | null { - if (value == null) return null; - const resourceId = String(value).trim(); - return resourceId || null; -} - -export function parseRoomIdsFromBooking(doc: any): string[] { +export function parseRoomIdsFromBooking(doc: any): number[] { if (Array.isArray(doc?.roomIds)) { return doc.roomIds - .map((x: unknown) => normalizeResourceId(x)) - .filter( - (resourceId: string | null): resourceId is string => resourceId != null, - ); + .map((x: any) => Number(x)) + .filter((n: number) => Number.isFinite(n)); } const raw = doc?.roomId; - if (raw != null) { - return String(raw) + if (typeof raw === "string") { + return raw .split(",") - .map((s) => normalizeResourceId(s)) - .filter( - (resourceId: string | null): resourceId is string => resourceId != null, - ); + .map((s) => Number(String(s).trim())) + .filter((n) => Number.isFinite(n)); } return []; @@ -296,9 +285,8 @@ function isActiveBookingByLastLog({ latestLogStatusByRequestNumber: Map; }): boolean { const req = Number(booking?.requestNumber); - const last = Number.isFinite(req) - ? (latestLogStatusByRequestNumber.get(req) ?? null) - : null; + const last = + Number.isFinite(req) ? latestLogStatusByRequestNumber.get(req) ?? null : null; // Strict "last-log wins": if we have a last log, use it. if (last) return !isTerminalInactiveStatus(last); @@ -321,18 +309,17 @@ export async function enforceRequestLimits({ bookingRoleField: string; /** Key used in `resource.requestLimits` maps: `student` | `faculty` | `admin` (no origin suffix). */ limitRoleKey: RequestLimitBucketKey; - selectedRoomIds: string[]; + selectedRoomIds: number[]; schema: SchemaContextType | null; }): Promise<{ ok: true } | { ok: false; message: string }> { if (!schema?.resources || schema.resources.length === 0) return { ok: true } as const; - const resourcesByRoomId = new Map(); + const resourcesByRoomId = new Map(); for (const r of schema.resources) { - const resourceId = - normalizeResourceId((r as any)?.resourceId) ?? - normalizeResourceId((r as any)?.roomId); - if (resourceId) resourcesByRoomId.set(resourceId, r); + if (typeof (r as any)?.roomId === "number") { + resourcesByRoomId.set((r as any).roomId, r); + } } const now = new Date(); @@ -358,7 +345,7 @@ export async function enforceRequestLimits({ if (periodsToQuery.length === 0) return { ok: true } as const; const countsByPeriod: Partial< - Record> + Record> > = {}; // Pre-compute windows so we can bound the Firestore query by the earliest @@ -407,10 +394,7 @@ export async function enforceRequestLimits({ const relevant = allByEmail.filter((d: any) => { if (d?.role !== bookingRoleField) return false; - return isActiveBookingByLastLog({ - booking: d, - latestLogStatusByRequestNumber, - }); + return isActiveBookingByLastLog({ booking: d, latestLogStatusByRequestNumber }); }); for (const period of periodsToQuery) { @@ -418,7 +402,7 @@ export async function enforceRequestLimits({ const startMs = start.getTime(); const endMs = end.getTime(); - const roomCounts = new Map(); + const roomCounts = new Map(); for (const doc of relevant) { const ms = getRequestedAtMillis(doc); if (ms == null) continue; @@ -449,9 +433,7 @@ export async function enforceRequestLimits({ // For limit 0, count < 0 is impossible, so booking is always blocked. if (count < limit) continue; - const resourceName = resource.name - ? `"${resource.name}"` - : `Room ${roomId}`; + const resourceName = resource.name ? `"${resource.name}"` : `Room ${roomId}`; return { ok: false, message: `Request limit reached for ${resourceName} (${period}). Limit: ${limit}.`, diff --git a/booking-app/lib/tenant/coerceTenantSchema.ts b/booking-app/lib/tenant/coerceTenantSchema.ts index 84033a4c6..3cf8b37a2 100644 --- a/booking-app/lib/tenant/coerceTenantSchema.ts +++ b/booking-app/lib/tenant/coerceTenantSchema.ts @@ -5,66 +5,6 @@ import type { } from "@/components/src/client/routes/components/schemaTypes"; import { generateDefaultSchema } from "@/components/src/client/routes/components/schemaTypes"; -function normalizeResourceId(value: unknown, field: string): string { - if ( - (typeof value !== "string" && typeof value !== "number") || - (typeof value === "number" && !Number.isFinite(value)) - ) { - throw new Error(`${field} must be a string or finite number`); - } - const resourceId = String(value); - if (!resourceId.trim()) { - throw new Error(`${field} must be non-empty`); - } - if (resourceId.includes(",")) { - throw new Error(`${field} must not contain commas`); - } - return resourceId; -} - -function coerceResource( - rawResource: Resource | Record, - index: number, -): Resource { - const { - roomId, - resourceId, - ...resource - } = rawResource as Record; - const canonicalId = - resourceId === undefined - ? normalizeResourceId(roomId, `resources[${index}].roomId`) - : normalizeResourceId(resourceId, `resources[${index}].resourceId`); - - if ( - roomId !== undefined && - normalizeResourceId(roomId, `resources[${index}].roomId`) !== canonicalId - ) { - throw new Error( - `resources[${index}] has conflicting resourceId and roomId`, - ); - } - - return { - ...resource, - resourceId: canonicalId, - } as Resource; -} - -function coerceResources( - resources: Array>, -): Resource[] { - const seen = new Set(); - return resources.map((resource, index) => { - const coerced = coerceResource(resource, index); - if (seen.has(coerced.resourceId)) { - throw new Error(`resources has duplicate resourceId "${coerced.resourceId}"`); - } - seen.add(coerced.resourceId); - return coerced; - }); -} - /** * Normalizes a tenant schema document from Firestore into SchemaContextType by * merging the stored (canonical nested-shape) document over the tenant @@ -121,9 +61,7 @@ export function coerceTenantSchema( }, }, resources: Array.isArray(raw.resources) - ? coerceResources( - raw.resources as Array>, - ) + ? (raw.resources as Resource[]) : base.resources, attestations: Array.isArray(raw.attestations) ? (raw.attestations as SchemaContextType["attestations"]) diff --git a/booking-app/lib/utils/testTenantSchema.ts b/booking-app/lib/utils/testTenantSchema.ts index 1f80010c8..2c6ae2a54 100644 --- a/booking-app/lib/utils/testTenantSchema.ts +++ b/booking-app/lib/utils/testTenantSchema.ts @@ -83,7 +83,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 30, name: "Lecture Hall 202", - resourceId: "202", + roomId: 202, calendarId: "mock-calendar-202", isWalkIn: false, autoApproval: { shouldAutoApprove: true }, @@ -91,7 +91,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 20, name: "Studio 220", - resourceId: "220", + roomId: 220, calendarId: "mock-calendar-220", isWalkIn: true, autoApproval: { shouldAutoApprove: false }, @@ -99,7 +99,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 25, name: "Seminar 203", - resourceId: "203", + roomId: 203, calendarId: "mock-calendar-203", isWalkIn: false, maxHour: { ...defaultResource.maxHour, faculty: 0.5 }, @@ -108,7 +108,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 15, name: "Workshop 230", - resourceId: "230", + roomId: 230, calendarId: "mock-calendar-230", isWalkIn: false, training: { @@ -186,7 +186,7 @@ function getItpTestSchema(): SchemaContextType { resource({ capacity: 20, name: "Room 408", - resourceId: "408", + roomId: 408, calendarId: "mock-calendar-408", isWalkIn: false, autoApproval: { @@ -198,7 +198,7 @@ function getItpTestSchema(): SchemaContextType { resource({ capacity: 10, name: "Room 410", - resourceId: "410", + roomId: 410, calendarId: "mock-calendar-410", isWalkIn: true, autoApproval: { diff --git a/booking-app/package.json b/booking-app/package.json index 150fcab24..a0cf41d62 100644 --- a/booking-app/package.json +++ b/booking-app/package.json @@ -18,9 +18,7 @@ "copy:production:dry-run": "node scripts/copyCollection.js --source-database development --target-database production --source-collection tenantSchema --target-collection tenantSchema --dry-run --report-file dry-run-production-details.json", "mergeUsers": "node scripts/mergeUsersCollections.js", "sync:schemas": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts", - "sync:schemas:dry-run": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts --dry-run", - "migrate:tenant-resource-ids": "node scripts/migrateTenantResourceIds.js", - "migrate:tenant-resource-ids:dry-run": "node scripts/migrateTenantResourceIds.js --dry-run" + "sync:schemas:dry-run": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts --dry-run" }, "dependencies": { "@emotion/styled": "^11.13.0", diff --git a/booking-app/scripts/migrateTenantResourceIds.js b/booking-app/scripts/migrateTenantResourceIds.js deleted file mode 100644 index 639d03ad5..000000000 --- a/booking-app/scripts/migrateTenantResourceIds.js +++ /dev/null @@ -1,251 +0,0 @@ -//@ts-nocheck -require("dotenv").config({ path: ".env.local" }); -const admin = require("firebase-admin"); -const { - TENANT_SCHEMA_COLLECTION, - backupTenantSchemaDocument, -} = require("./tenantSchemaBackup"); - -const BACKUP_TYPE = "resource-id-string"; -const DATABASES = { - development: "default", - staging: "booking-app-staging", - production: "booking-app-prod", -}; - -const hasOwn = (value, key) => - Object.prototype.hasOwnProperty.call(value, key); - -const validateId = (id, path) => { - if (typeof id !== "string") { - throw new Error(`${path} must be a string`); - } - if (id.trim().length === 0) { - throw new Error(`${path} must be non-empty`); - } - if (id.includes(",")) { - throw new Error(`${path} must not contain commas`); - } -}; - -const migrateTenantSchemaData = (schema, tenantId = "unknown") => { - if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { - throw new Error(`tenantSchema/${tenantId} must be an object`); - } - if (!hasOwn(schema, "resources")) { - return { schema, changed: false }; - } - if (!Array.isArray(schema.resources)) { - throw new Error(`tenantSchema/${tenantId}.resources must be an array`); - } - - let changed = false; - const seenIds = new Set(); - const resources = schema.resources.map((resource, index) => { - const path = `tenantSchema/${tenantId}.resources[${index}]`; - if (resource === null || typeof resource !== "object" || Array.isArray(resource)) { - throw new Error(`${path} must be an object`); - } - - const hasResourceId = hasOwn(resource, "resourceId"); - const hasRoomId = hasOwn(resource, "roomId"); - let resourceId; - - if (hasResourceId) { - resourceId = resource.resourceId; - validateId(resourceId, `${path}.resourceId`); - } - - if (hasRoomId) { - const roomId = resource.roomId; - if ( - (typeof roomId !== "string" && typeof roomId !== "number") || - (typeof roomId === "number" && !Number.isFinite(roomId)) - ) { - throw new Error(`${path}.roomId must be a string or finite number`); - } - - const legacyResourceId = String(roomId); - validateId(legacyResourceId, `${path}.roomId`); - if (hasResourceId && resourceId !== legacyResourceId) { - throw new Error( - `${path} has conflicting resourceId "${resourceId}" and roomId "${legacyResourceId}"` - ); - } - resourceId = legacyResourceId; - } - - if (!hasResourceId && !hasRoomId) { - throw new Error(`${path} must have resourceId or legacy roomId`); - } - if (seenIds.has(resourceId)) { - throw new Error( - `tenantSchema/${tenantId}.resources has duplicate resourceId "${resourceId}"` - ); - } - seenIds.add(resourceId); - - if (!hasRoomId) { - return resource; - } - - const { roomId: _legacyRoomId, ...withoutRoomId } = resource; - changed = true; - return { ...withoutRoomId, resourceId }; - }); - - return { - schema: changed ? { ...schema, resources } : schema, - changed, - }; -}; - -const getTenantDocuments = async (db, tenant) => { - if (tenant) { - const snapshot = await db.collection(TENANT_SCHEMA_COLLECTION).doc(tenant).get(); - if (!snapshot.exists) { - throw new Error(`No tenant schema found for "${tenant}"`); - } - return [{ id: tenant, data: () => snapshot.data() }]; - } - - const snapshot = await db.collection(TENANT_SCHEMA_COLLECTION).get(); - return snapshot.docs; -}; - -const migrateTenantResourceIds = async ( - db, - { dryRun = false, tenant } = {} -) => { - const documents = await getTenantDocuments(db, tenant); - - // Preflight every selected document so validation failures cannot cause a - // partially applied migration. - const migrations = documents.map((doc) => { - const existingSchema = doc.data(); - return { - tenantId: doc.id, - existingSchema, - ...migrateTenantSchemaData(existingSchema, doc.id), - }; - }); - const changedMigrations = migrations.filter((migration) => migration.changed); - - if (!dryRun) { - for (const migration of changedMigrations) { - await backupTenantSchemaDocument( - db, - migration.tenantId, - migration.existingSchema, - BACKUP_TYPE - ); - await db - .collection(TENANT_SCHEMA_COLLECTION) - .doc(migration.tenantId) - .set(migration.schema, { merge: false }); - } - } - - return { - dryRun, - scanned: migrations.length, - changed: changedMigrations.length, - unchanged: migrations.length - changedMigrations.length, - tenants: changedMigrations.map((migration) => migration.tenantId), - }; -}; - -const parseArgs = (args = process.argv.slice(2)) => { - const options = { dryRun: false, database: "development" }; - for (let index = 0; index < args.length; index++) { - switch (args[index]) { - case "--dry-run": - options.dryRun = true; - break; - case "--database": - options.database = args[++index]; - break; - case "--tenant": - options.tenant = args[++index]; - break; - case "--help": - options.help = true; - break; - default: - throw new Error(`Unknown argument: ${args[index]}`); - } - } - if (!DATABASES[options.database]) { - throw new Error( - `Invalid database "${options.database}". Expected one of: ${Object.keys(DATABASES).join(", ")}` - ); - } - return options; -}; - -const initializeDb = (databaseId) => { - if (!admin.apps.length) { - admin.initializeApp({ - credential: admin.credential.cert({ - projectId: process.env.FIREBASE_PROJECT_ID, - clientEmail: process.env.FIREBASE_CLIENT_EMAIL, - privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"), - }), - }); - } - const db = admin.firestore(); - if (databaseId !== "default") { - db.settings({ databaseId }); - } - return db; -}; - -const main = async () => { - const options = parseArgs(); - if (options.help) { - console.log(`Usage: node scripts/migrateTenantResourceIds.js [options] - -Options: - --dry-run Validate and report without writing - --database development, staging, or production - --tenant Migrate one tenant instead of the whole collection - --help Show this help message`); - return; - } - - const result = await migrateTenantResourceIds( - initializeDb(DATABASES[options.database]), - options - ); - console.log( - `${options.dryRun ? "[DRY RUN] " : ""}Scanned ${result.scanned}; ` + - `${result.changed} ${options.dryRun ? "would change" : "changed"}; ` + - `${result.unchanged} unchanged.` - ); - if (result.tenants.length > 0) { - console.log(`Tenants: ${result.tenants.join(", ")}`); - } -}; - -if (require.main === module) { - main() - .catch((error) => { - console.error(`Migration failed: ${error.message}`); - process.exitCode = 1; - }) - .finally(async () => { - for (const app of admin.apps) { - if (app) { - await app.delete(); - } - } - }); -} - -module.exports = { - BACKUP_TYPE, - DATABASES, - migrateTenantSchemaData, - migrateTenantResourceIds, - parseArgs, -}; diff --git a/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts b/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts index 300a48c35..e38536900 100644 --- a/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts +++ b/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts @@ -34,68 +34,3 @@ describe("coerceTenantSchema — timeSensitiveRequestWarning", () => { expect(c.calendarConfig?.timeSensitiveRequestWarning?.hours).toBe(99); }); }); - -describe("coerceTenantSchema — resources", () => { - it.each([ - ["number", 202, "202"], - ["string", "studio-a", "studio-a"], - ])("coerces a legacy %s roomId to resourceId", (_, roomId, resourceId) => { - const resource = { - roomId, - name: "Studio", - capacity: 12, - customField: { keep: true }, - }; - - const coerced = coerceTenantSchema({ resources: [resource] }, "mc"); - - expect(coerced.resources[0]).toEqual({ - resourceId, - name: "Studio", - capacity: 12, - customField: { keep: true }, - }); - expect(coerced.resources[0]).not.toHaveProperty("roomId"); - }); - - it("keeps a canonical resourceId and removes a matching legacy roomId", () => { - const coerced = coerceTenantSchema( - { - resources: [ - { - resourceId: "canonical-id", - roomId: "canonical-id", - name: "Canonical resource", - }, - ], - }, - "mc", - ); - - expect(coerced.resources[0].resourceId).toBe("canonical-id"); - expect(coerced.resources[0]).not.toHaveProperty("roomId"); - }); - - it("rejects conflicting legacy and canonical IDs", () => { - expect(() => - coerceTenantSchema( - { resources: [{ resourceId: "studio-a", roomId: 202 }] }, - "mc", - ), - ).toThrow("conflicting resourceId and roomId"); - }); - - it("rejects duplicate canonical IDs", () => { - expect(() => - coerceTenantSchema( - { - resources: [ - { resourceId: "studio-a" }, - { roomId: "studio-a" }, - ], - }, - "mc", - ), - ).toThrow('duplicate resourceId "studio-a"'); - }); -}); diff --git a/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts b/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts deleted file mode 100644 index c6a05cfe5..000000000 --- a/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { createRequire } from "module"; -import { describe, expect, it } from "vitest"; - -type DocumentData = Record; - -class MockFirestoreDb { - private collections = new Map>(); - readonly writes: Array<{ - collection: string; - id: string; - options: Record | undefined; - }> = []; - - constructor(seed: Record>) { - for (const [name, documents] of Object.entries(seed)) { - this.collections.set( - name, - new Map( - documents.map(({ id, data }) => [id, structuredClone(data)]), - ), - ); - } - } - - private getCollection(name: string) { - if (!this.collections.has(name)) { - this.collections.set(name, new Map()); - } - return this.collections.get(name)!; - } - - collection(name: string) { - return { - get: async () => ({ - docs: Array.from(this.getCollection(name), ([id, data]) => ({ - id, - data: () => structuredClone(data), - })), - }), - doc: (id: string) => ({ - get: async () => { - const data = this.getCollection(name).get(id); - return { - exists: data !== undefined, - data: () => structuredClone(data), - }; - }, - set: async (data: DocumentData, options?: Record) => { - this.writes.push({ collection: name, id, options }); - this.getCollection(name).set(id, structuredClone(data)); - }, - }), - }; - } - - read(name: string, id: string) { - return structuredClone(this.getCollection(name).get(id)); - } - - readCollection(name: string) { - return Array.from(this.getCollection(name), ([id, data]) => ({ - id, - data: structuredClone(data), - })); - } -} - -const requireModule = createRequire(import.meta.url); -const { migrateTenantSchemaData, migrateTenantResourceIds } = requireModule( - "../../scripts/migrateTenantResourceIds.js", -) as { - migrateTenantSchemaData: ( - schema: DocumentData, - tenantId?: string, - ) => { schema: DocumentData; changed: boolean }; - migrateTenantResourceIds: ( - db: MockFirestoreDb, - options?: { dryRun?: boolean; tenant?: string }, - ) => Promise; -}; - -describe("scripts/migrateTenantResourceIds", () => { - it("converts legacy numeric/string roomIds, removes roomId, and is idempotent", () => { - const existing = { - tenant: "mc", - resources: [ - { roomId: 101, name: "One" }, - { roomId: "room-two", name: "Two" }, - { resourceId: "canonical", name: "Three" }, - { resourceId: "same", roomId: "same", name: "Four" }, - ], - }; - - const first = migrateTenantSchemaData(existing, "mc"); - expect(first.changed).toBe(true); - expect(first.schema.resources).toEqual([ - { resourceId: "101", name: "One" }, - { resourceId: "room-two", name: "Two" }, - { resourceId: "canonical", name: "Three" }, - { resourceId: "same", name: "Four" }, - ]); - - const second = migrateTenantSchemaData(first.schema, "mc"); - expect(second).toEqual({ schema: first.schema, changed: false }); - }); - - it.each([ - [ - "conflicting IDs", - [{ resourceId: "new", roomId: "old" }], - /conflicting resourceId/, - ], - ["empty IDs", [{ roomId: " " }], /must be non-empty/], - ["comma-containing IDs", [{ resourceId: "one,two" }], /must not contain commas/], - [ - "duplicate canonical IDs", - [{ roomId: 1 }, { resourceId: "1" }], - /duplicate resourceId "1"/, - ], - ])("rejects %s", (_label, resources, message) => { - expect(() => - migrateTenantSchemaData({ resources }, "mc"), - ).toThrow(message); - }); - - it("backs up and replacement-writes only changed schemas", async () => { - const db = new MockFirestoreDb({ - tenantSchema: [ - { id: "mc", data: { resources: [{ roomId: 1, name: "One" }] } }, - { id: "itp", data: { resources: [{ resourceId: "existing" }] } }, - ], - }); - - const result = await migrateTenantResourceIds(db); - - expect(result).toMatchObject({ scanned: 2, changed: 1, unchanged: 1 }); - expect(db.read("tenantSchema", "mc")).toEqual({ - resources: [{ resourceId: "1", name: "One" }], - }); - expect(db.readCollection("tenantSchemaBackup")).toHaveLength(1); - expect(db.readCollection("tenantSchemaBackup")[0].data).toEqual({ - resources: [{ roomId: 1, name: "One" }], - }); - expect( - db.writes.find( - (write) => write.collection === "tenantSchema" && write.id === "mc", - )?.options, - ).toEqual({ merge: false }); - expect( - db.writes.some( - (write) => write.collection === "tenantSchema" && write.id === "itp", - ), - ).toBe(false); - }); - - it("performs no writes in dry-run mode or when preflight validation fails", async () => { - const dryRunDb = new MockFirestoreDb({ - tenantSchema: [{ id: "mc", data: { resources: [{ roomId: 1 }] } }], - }); - const result = await migrateTenantResourceIds(dryRunDb, { dryRun: true }); - expect(result).toMatchObject({ dryRun: true, changed: 1 }); - expect(dryRunDb.writes).toHaveLength(0); - - const invalidDb = new MockFirestoreDb({ - tenantSchema: [ - { id: "mc", data: { resources: [{ roomId: 1 }] } }, - { id: "itp", data: { resources: [{ roomId: "bad,id" }] } }, - ], - }); - await expect(migrateTenantResourceIds(invalidDb)).rejects.toThrow( - /must not contain commas/, - ); - expect(invalidDb.writes).toHaveLength(0); - }); -}); diff --git a/booking-app/tests/unit/request-limits.unit.test.ts b/booking-app/tests/unit/request-limits.unit.test.ts index 67e690d84..b4b48f922 100644 --- a/booking-app/tests/unit/request-limits.unit.test.ts +++ b/booking-app/tests/unit/request-limits.unit.test.ts @@ -55,10 +55,7 @@ vi.mock("xstate", () => ({ })); vi.mock("./shared", () => ({ extractTenantFromRequest: () => "mc", - getAffiliationDisplayValues: () => ({ - departmentDisplay: "", - schoolDisplay: "", - }), + getAffiliationDisplayValues: () => ({ departmentDisplay: "", schoolDisplay: "" }), getOtherDisplayFields: () => ({}), })); @@ -85,7 +82,7 @@ describe("Request limits enforcement (POST /api/bookings)", () => { tenant: "mc", resources: [ { - resourceId: "room-1201", + roomId: 1201, name: "Room 1201", requestLimits: { perSemester: { student: 1 }, @@ -98,11 +95,9 @@ describe("Request limits enforcement (POST /api/bookings)", () => { // Active booking in-window for same resource { id: "b1", - roomIds: ["room-1201"], + roomIds: [1201], role: "Student", - requestedAt: { - toMillis: () => new Date("2026-04-13T10:00:00Z").getTime(), - }, + requestedAt: { toMillis: () => new Date("2026-04-13T10:00:00Z").getTime() }, canceledAt: null, declinedAt: null, }, @@ -114,9 +109,7 @@ describe("Request limits enforcement (POST /api/bookings)", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "student@nyu.edu", - selectedRooms: [ - { roomId: "room-1201", calendarId: "cal-1201", name: "Room 1201" }, - ], + selectedRooms: [{ roomId: 1201, calendarId: "cal-1201", name: "Room 1201" }], bookingCalendarInfo: { startStr: "2026-04-13T14:00:00Z", endStr: "2026-04-13T15:00:00Z", @@ -140,86 +133,13 @@ describe("Request limits enforcement (POST /api/bookings)", () => { vi.useRealTimers(); }); - it("matches legacy numeric room IDs after coercing them to strings", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-13T12:00:00Z")); - - mockServerGetDocumentById.mockResolvedValue({ - tenant: "mc", - resources: [ - { - roomId: 1201, - name: "Legacy Room 1201", - requestLimits: { - perDay: { student: 1 }, - }, - }, - ], - }); - - mockServerFetchAllDataFromCollection.mockResolvedValue([ - { - id: "b1", - roomIds: [1201], - role: "Student", - requestedAt: { - toMillis: () => new Date("2026-04-13T10:00:00Z").getTime(), - }, - }, - ]); - - const { POST } = await import("@/app/api/bookings/route"); - const req = new Request("http://localhost:3000/api/bookings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: "student@nyu.edu", - selectedRooms: [ - { roomId: 1201, calendarId: "cal-1201", name: "Legacy Room 1201" }, - ], - bookingCalendarInfo: { - startStr: "2026-04-13T14:00:00Z", - endStr: "2026-04-13T15:00:00Z", - }, - data: { role: "Student", title: "Test", department: "ITP" }, - isAutoApproval: false, - }), - }) as any; - - const res = await POST(req); - - expect(res.status).toBe(429); - expect(await res.json()).toMatchObject({ - error: expect.stringContaining("Legacy Room 1201"), - }); - - vi.useRealTimers(); - }); - - it("parses opaque and legacy numeric booking resource IDs as strings", async () => { - const { parseRoomIdsFromBooking } = - await import("@/lib/bookingRequestLimits"); - - expect( - parseRoomIdsFromBooking({ roomIds: ["room-a", 1201, null] }), - ).toEqual(["room-a", "1201"]); - expect(parseRoomIdsFromBooking({ roomId: "room-a, 1201" })).toEqual([ - "room-a", - "1201", - ]); - }); - it("uses 4-month semester windows in America/New_York (May 1 starts a new window)", async () => { const { getNewYorkWindowForPeriod, REQUEST_LIMITS_TIME_ZONE } = await import("@/lib/bookingRequestLimits"); const { toDate } = await import("date-fns-tz"); const tz = REQUEST_LIMITS_TIME_ZONE; const now = toDate("2026-05-01T12:00:00.000", { timeZone: tz }); - const { start, end } = getNewYorkWindowForPeriod( - now, - "perSemester", - undefined, - ); + const { start, end } = getNewYorkWindowForPeriod(now, "perSemester", undefined); expect(start.getTime()).toBe( toDate("2026-05-01T00:00:00.000", { timeZone: tz }).getTime(), @@ -237,7 +157,7 @@ describe("Request limits enforcement (POST /api/bookings)", () => { tenant: "mc", resources: [ { - resourceId: "room-1201", + roomId: 1201, name: "Room 1201", requestLimits: { // perDay limit forces the window to be the current calendar day, @@ -257,7 +177,7 @@ describe("Request limits enforcement (POST /api/bookings)", () => { body: JSON.stringify({ email: "student@nyu.edu", selectedRooms: [ - { roomId: "room-1201", calendarId: "cal-1201", name: "Room 1201" }, + { roomId: 1201, calendarId: "cal-1201", name: "Room 1201" }, ], bookingCalendarInfo: { startStr: "2026-04-13T14:00:00Z", @@ -270,10 +190,9 @@ describe("Request limits enforcement (POST /api/bookings)", () => { await POST(req); - const bookingQueryCall = - mockServerFetchAllDataFromCollection.mock.calls.find( - (call) => call[0] === "bookings", - ); + const bookingQueryCall = mockServerFetchAllDataFromCollection.mock.calls.find( + (call) => call[0] === "bookings", + ); expect(bookingQueryCall).toBeDefined(); const constraints = bookingQueryCall![1] as Array<{ field: string; @@ -311,3 +230,4 @@ describe("Request limits enforcement (POST /api/bookings)", () => { ); }); }); + diff --git a/booking-app/tests/unit/schema-editor-resource.unit.test.tsx b/booking-app/tests/unit/schema-editor-resource.unit.test.tsx deleted file mode 100644 index 038cf3798..000000000 --- a/booking-app/tests/unit/schema-editor-resource.unit.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { defaultResource } from "../../components/src/client/routes/components/schemaTypes"; -import { ResourceEditor } from "../../components/src/client/routes/super/schemaEditor"; - -describe("SchemaEditor ResourceEditor", () => { - it("edits Resource ID as text without numeric coercion", () => { - const onUpdate = vi.fn(); - - render( - , - ); - - const input = screen.getByLabelText("Resource ID"); - expect(input).not.toHaveAttribute("type", "number"); - - fireEvent.change(input, { target: { value: "studio-b2" } }); - - expect(onUpdate).toHaveBeenCalledWith(0, { - ...defaultResource, - resourceId: "studio-b2", - }); - }); -}); diff --git a/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts b/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts index 7ca1f46b5..f21d554a7 100644 --- a/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts +++ b/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts @@ -58,8 +58,6 @@ describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { // Never delete extra keys (top-level and within resource items) expect(merged.someExtraTopLevelKey).toEqual({ keepMe: true }); expect(merged.resources[0].extraResourceKey).toBe("keep-me"); - expect(merged.resources[0].resourceId).toBe("999"); - expect(merged.resources[0]).not.toHaveProperty("roomId"); // Still adds missing template defaults (spot-check a known required field) expect(merged.tenant.contextLabels).toBeTruthy(); @@ -117,7 +115,5 @@ describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { expect(merged.resources[0].training.infoUrl).toBe( "https://info.example/abc", ); - expect(merged.resources[0].resourceId).toBe("1234"); - expect(merged.resources[0]).not.toHaveProperty("roomId"); }); }); From a5609e38d643d124011ed1fffa6b05f2da57bc28 Mon Sep 17 00:00:00 2001 From: n3xta Date: Mon, 15 Jun 2026 00:11:55 -0400 Subject: [PATCH 26/51] Reapply "feat(schema): migrate tenant resources to string resource IDs" This reverts commit b15bcac3fbccda692c7b24eb12ef8c4c953bb39c. --- booking-app/app/api/bookings/edit/route.ts | 2 +- booking-app/app/api/bookings/export/route.ts | 2 +- .../app/api/bookings/modification/route.ts | 2 +- .../app/api/bookings/request-limits/route.ts | 6 +- booking-app/app/api/bookings/route.ts | 29 +- booking-app/app/api/bookings/shared.ts | 2 +- booking-app/app/api/bookingsDirect/route.ts | 4 +- .../app/api/cancel-processing/route.ts | 5 +- booking-app/app/api/inviteUser/route.ts | 2 +- .../app/api/safety_training_form/route.ts | 4 +- booking-app/app/api/syncCalendars/route.ts | 12 +- .../api/syncSemesterPregameBookings/route.ts | 4 +- .../policySettings/BookingBlackoutPeriods.tsx | 114 ++++---- .../policySettings/OperationalHours.tsx | 22 +- .../policySettings/OperationalHoursRow.tsx | 15 +- .../routes/admin/hooks/useExistingBooking.tsx | 2 +- .../client/routes/booking/bookingProvider.tsx | 6 +- .../components/BookingFormMediaServices.tsx | 4 +- .../components/CalendarVerticalResource.tsx | 35 ++- .../routes/booking/components/SelectRooms.tsx | 51 ++-- .../booking/formPages/SelectRoomPage.tsx | 9 +- .../hooks/useBookingDateRestrictions.tsx | 48 +++- .../booking/hooks/useCalculateOverlap.tsx | 4 +- .../booking/hooks/useCheckAutoApproval.tsx | 11 +- .../booking/hooks/useCheckDurationLimits.tsx | 2 +- .../booking/hooks/useCheckRequestLimits.tsx | 4 +- .../booking/utils/buildBlockPastTimes.ts | 4 +- .../src/client/routes/components/Provider.tsx | 5 +- .../client/routes/components/schemaTypes.ts | 4 +- .../src/client/routes/super/schemaEditor.tsx | 15 +- booking-app/components/src/server/admin.ts | 10 +- .../components/src/server/calendars.ts | 6 +- booking-app/components/src/server/db.ts | 2 +- .../src/testHelpers/testTenantSchemas.ts | 6 +- booking-app/components/src/types.ts | 6 +- .../components/src/utils/blackoutUtils.ts | 4 +- booking-app/lib/bookingRequestLimits.ts | 64 +++-- booking-app/lib/tenant/coerceTenantSchema.ts | 64 ++++- booking-app/lib/utils/testTenantSchema.ts | 12 +- booking-app/package.json | 4 +- .../scripts/migrateTenantResourceIds.js | 251 ++++++++++++++++++ .../unit/coerce-tenant-schema.unit.test.ts | 65 +++++ .../migrate-tenant-resource-ids.unit.test.ts | 175 ++++++++++++ .../tests/unit/request-limits.unit.test.ts | 104 +++++++- .../unit/schema-editor-resource.unit.test.tsx | 29 ++ .../unit/schemaDefaults.addOnly.unit.test.ts | 4 + 46 files changed, 984 insertions(+), 251 deletions(-) create mode 100644 booking-app/scripts/migrateTenantResourceIds.js create mode 100644 booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts create mode 100644 booking-app/tests/unit/schema-editor-resource.unit.test.tsx diff --git a/booking-app/app/api/bookings/edit/route.ts b/booking-app/app/api/bookings/edit/route.ts index 17a669ac9..c1abc01d1 100644 --- a/booking-app/app/api/bookings/edit/route.ts +++ b/booking-app/app/api/bookings/edit/route.ts @@ -251,7 +251,7 @@ export async function PUT(request: NextRequest) { ); const selectedRoomIds = selectedRooms - .map((r: { roomId: number }) => r.roomId) + .map((r: { roomId: string }) => r.roomId) .join(", "); console.log("✏️ EDIT: Deleting old calendar events"); diff --git a/booking-app/app/api/bookings/export/route.ts b/booking-app/app/api/bookings/export/route.ts index 0c1c45423..e436776e7 100644 --- a/booking-app/app/api/bookings/export/route.ts +++ b/booking-app/app/api/bookings/export/route.ts @@ -169,7 +169,7 @@ export async function GET(request: NextRequest) { : []; const rooms: RoomSetting[] = resourcesWithCorrectCalendarIds.map( (resource: any) => ({ - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), name: resource.name, capacity: resource.capacity.toString(), calendarId: resource.calendarId, diff --git a/booking-app/app/api/bookings/modification/route.ts b/booking-app/app/api/bookings/modification/route.ts index 6c3c053cc..5248cf4d0 100644 --- a/booking-app/app/api/bookings/modification/route.ts +++ b/booking-app/app/api/bookings/modification/route.ts @@ -124,7 +124,7 @@ export async function PUT(request: NextRequest) { ); const selectedRoomIds = selectedRooms - .map((r: { roomId: number }) => r.roomId) + .map((r: { roomId: string }) => r.roomId) .join(", "); console.log("🔧 MODIFICATION: Deleting old calendar events"); diff --git a/booking-app/app/api/bookings/request-limits/route.ts b/booking-app/app/api/bookings/request-limits/route.ts index dcec8dfc7..5b1faf22c 100644 --- a/booking-app/app/api/bookings/request-limits/route.ts +++ b/booking-app/app/api/bookings/request-limits/route.ts @@ -18,10 +18,10 @@ export async function POST(request: NextRequest) { const formContext = body?.formContext as FormContextLevel | undefined; const roomIdsRaw = body?.roomIds; - const roomIds: number[] = Array.isArray(roomIdsRaw) + const roomIds: string[] = Array.isArray(roomIdsRaw) ? roomIdsRaw - .map((x: unknown) => Number(x)) - .filter((n: number) => Number.isFinite(n)) + .map((x: unknown) => (x == null ? "" : String(x).trim())) + .filter((resourceId: string) => resourceId.length > 0) : []; const tenant = extractTenantFromRequest(request); diff --git a/booking-app/app/api/bookings/route.ts b/booking-app/app/api/bookings/route.ts index e66e470a1..e34de94eb 100644 --- a/booking-app/app/api/bookings/route.ts +++ b/booking-app/app/api/bookings/route.ts @@ -165,7 +165,7 @@ const getTenantRooms = async (tenant?: string) => { ); return resourcesWithCorrectCalendarIds.map((resource: any) => ({ - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), name: resource.name, capacity: resource.capacity?.toString(), calendarId: resource.calendarId, @@ -233,9 +233,7 @@ async function createBookingCalendarEvent( throw Error(`calendarId not found for room ${room.roomId}`); } - const selectedRoomIds = selectedRooms.map( - (r: { roomId: number }) => r.roomId, - ); + const selectedRoomIds = selectedRooms.map((r) => r.roomId); const otherRoomEmails = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); @@ -578,13 +576,18 @@ export async function POST(request: NextRequest) { FormContextLevel.FULL_FORM, bookingRoleField, ); - const selectedRoomIdsNums: number[] = Array.isArray(selectedRooms) + const selectedRoomIdsForLimits: string[] = Array.isArray(selectedRooms) ? selectedRooms - .map((r: any) => Number(r?.roomId)) - .filter((n: number) => Number.isFinite(n)) + .map((r: any) => (r?.roomId == null ? "" : String(r.roomId).trim())) + .filter((resourceId: string) => resourceId.length > 0) : []; - if (tenant && email && bookingRoleField && selectedRoomIdsNums.length > 0) { + if ( + tenant && + email && + bookingRoleField && + selectedRoomIdsForLimits.length > 0 + ) { const tenantSchema = await serverGetDocumentById( TableNames.TENANT_SCHEMA, tenant, @@ -596,7 +599,7 @@ export async function POST(request: NextRequest) { email, bookingRoleField, limitRoleKey, - selectedRoomIds: selectedRoomIdsNums, + selectedRoomIds: selectedRoomIdsForLimits, schema: tenantSchema, }); @@ -817,12 +820,10 @@ export async function POST(request: NextRequest) { // Generate Sequential ID early so it can be used in calendar description const sequentialId = await serverGetNextSequentialId("bookings", tenant); - const selectedRoomIds = selectedRooms - .map((r: { roomId: number }) => r.roomId) - .join(", "); const selectedRoomIdsArray = selectedRooms - .map((r: { roomId: number | string }) => Number((r as any)?.roomId)) - .filter((n: number) => Number.isFinite(n)); + .map((r: { roomId: number | string }) => String(r.roomId).trim()) + .filter((resourceId: string) => resourceId.length > 0); + const selectedRoomIds = selectedRoomIdsArray.join(", "); // Build booking contents for description const startDateObj = new Date(bookingCalendarInfo.startStr); diff --git a/booking-app/app/api/bookings/shared.ts b/booking-app/app/api/bookings/shared.ts index 287f23e2e..0759fd21a 100644 --- a/booking-app/app/api/bookings/shared.ts +++ b/booking-app/app/api/bookings/shared.ts @@ -104,7 +104,7 @@ export const getTenantRooms = async (tenant?: string) => { ); return resourcesWithCorrectCalendarIds.map((resource: any) => ({ - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), name: resource.name, capacity: resource.capacity?.toString(), calendarId: resource.calendarId, diff --git a/booking-app/app/api/bookingsDirect/route.ts b/booking-app/app/api/bookingsDirect/route.ts index 67e3cae22..8d766b513 100644 --- a/booking-app/app/api/bookingsDirect/route.ts +++ b/booking-app/app/api/bookingsDirect/route.ts @@ -98,9 +98,7 @@ export async function POST(request: NextRequest) { const { departmentDisplay, schoolDisplay } = getAffiliationDisplayValues(data); const [room, ...otherRooms] = selectedRooms; - const selectedRoomIds = selectedRooms.map( - (r: { roomId: number }) => r.roomId, - ); + const selectedRoomIds = selectedRooms.map((r: { roomId: string }) => r.roomId); const otherRoomIds = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); diff --git a/booking-app/app/api/cancel-processing/route.ts b/booking-app/app/api/cancel-processing/route.ts index efdd1f114..fdb459e42 100644 --- a/booking-app/app/api/cancel-processing/route.ts +++ b/booking-app/app/api/cancel-processing/route.ts @@ -55,7 +55,8 @@ export async function POST(req: NextRequest) { const roomIds = booking.roomId.split(",").map(x => x.trim()); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => roomIds.includes(`${resource.roomId}`), + (resource: any) => + roomIds.includes(String(resource.resourceId ?? resource.roomId)), ); console.log( @@ -64,7 +65,7 @@ export async function POST(req: NextRequest) { calendarEventId, roomIds, rooms: rooms.map((r: any) => ({ - roomId: r.roomId, + roomId: String(r.resourceId ?? r.roomId), calendarId: r.calendarId, })), }, diff --git a/booking-app/app/api/inviteUser/route.ts b/booking-app/app/api/inviteUser/route.ts index 554e54905..22bc2bc08 100644 --- a/booking-app/app/api/inviteUser/route.ts +++ b/booking-app/app/api/inviteUser/route.ts @@ -15,7 +15,7 @@ export async function POST(request: NextRequest) { await inviteUserToCalendarEvent( calendarEventId, guestEmail, - parseInt(roomId, 10), + String(roomId), tenant, ); return NextResponse.json({ calendarEventId }, { status: 200 }); diff --git a/booking-app/app/api/safety_training_form/route.ts b/booking-app/app/api/safety_training_form/route.ts index 4add26094..3fccd2347 100644 --- a/booking-app/app/api/safety_training_form/route.ts +++ b/booking-app/app/api/safety_training_form/route.ts @@ -37,9 +37,7 @@ export async function GET(request: NextRequest) { let formId: string | null = null; if (resourceId) { - const resource = schema.resources?.find( - (r) => r.roomId?.toString() === resourceId.toString(), - ); + const resource = schema.resources?.find(r => r.resourceId === resourceId); const resourceForm = resource?.training?.formId; if (resourceForm) { formId = extractGoogleFormId(resourceForm); diff --git a/booking-app/app/api/syncCalendars/route.ts b/booking-app/app/api/syncCalendars/route.ts index ea39826c1..a3a6ce596 100644 --- a/booking-app/app/api/syncCalendars/route.ts +++ b/booking-app/app/api/syncCalendars/route.ts @@ -99,20 +99,20 @@ const findRoomIds = (event: any, resources: any[]): string => { r => r.calendarId === event.organizer.email, ); if (currentResource) { - roomIds.add(currentResource.roomId); + roomIds.add(String(currentResource.resourceId ?? currentResource.roomId)); } // Add other room IDs attendees.forEach((attendee: any) => { const resource = resources.find(r => r.calendarId === attendee.email); if (resource) { - roomIds.add(resource.roomId); + roomIds.add(String(resource.resourceId ?? resource.roomId)); } }); - // Convert to array, sort numerically, and join + // Resource IDs are opaque strings; numeric sorting is only a display aid. return Array.from(roomIds) - .sort((a, b) => parseInt(a) - parseInt(b)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) .join(","); }; @@ -135,9 +135,9 @@ export async function POST(request: Request) { : []; const resources = resourcesWithCorrectCalendarIds.map((resource: any) => ({ - id: resource.roomId.toString(), + id: String(resource.resourceId ?? resource.roomId), calendarId: resource.calendarId, - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), })); let totalNewBookings = 0; diff --git a/booking-app/app/api/syncSemesterPregameBookings/route.ts b/booking-app/app/api/syncSemesterPregameBookings/route.ts index 6637c9a9a..ba031862b 100644 --- a/booking-app/app/api/syncSemesterPregameBookings/route.ts +++ b/booking-app/app/api/syncSemesterPregameBookings/route.ts @@ -594,9 +594,9 @@ export async function POST(request: NextRequest) { : []; const resources = resourcesWithCorrectCalendarIds.map((resource: any) => ({ - id: resource.roomId.toString(), + id: String(resource.resourceId ?? resource.roomId), calendarId: resource.calendarId, - roomId: resource.roomId, + roomId: String(resource.resourceId ?? resource.roomId), })); if (testMode) { diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx index 9d5131d82..5b6d3d607 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/BookingBlackoutPeriods.tsx @@ -62,6 +62,13 @@ type RoomApplicationType = | "multi" | "specific"; +type RuntimeBlackoutPeriod = Omit & { + roomIds?: string[]; +}; + +const sortResourceIds = (resourceIds: string[]) => + resourceIds.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + /** * Check if two sorted arrays are equal */ @@ -74,16 +81,17 @@ function areSortedArraysEqual(arr1: T[], arr2: T[]): boolean { export default function BookingBlackoutPeriods() { const { roomSettings } = useContext(DatabaseContext); - const [blackoutPeriods, setBlackoutPeriods] = useState([]); + const [blackoutPeriods, setBlackoutPeriods] = useState< + RuntimeBlackoutPeriod[] + >([]); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string; } | null>(null); const [dialogOpen, setDialogOpen] = useState(false); - const [editingPeriod, setEditingPeriod] = useState( - null, - ); + const [editingPeriod, setEditingPeriod] = + useState(null); // Form state const [periodName, setPeriodName] = useState(""); @@ -91,7 +99,7 @@ export default function BookingBlackoutPeriods() { const [endDate, setEndDate] = useState(null); const [startTime, setStartTime] = useState(null); const [endTime, setEndTime] = useState(null); - const [selectedRooms, setSelectedRooms] = useState([]); + const [selectedRooms, setSelectedRooms] = useState([]); const [roomApplicationType, setRoomApplicationType] = useState("all"); @@ -106,6 +114,15 @@ export default function BookingBlackoutPeriods() { }); }, [roomSettings]); + const getCategoryResourceIds = (legacyRoomIds: readonly number[]) => { + const legacyIds = new Set(legacyRoomIds.map(String)); + return sortResourceIds( + roomSettings + .filter((room) => legacyIds.has(room.roomId)) + .map((room) => room.roomId), + ); + }; + const fetchBlackoutPeriods = async () => { try { const fetchedData = @@ -113,17 +130,22 @@ export default function BookingBlackoutPeriods() { TableNames.BLACKOUT_PERIODS, ); setBlackoutPeriods( - fetchedData.sort( - (a, b) => - a.startDate.toDate().getTime() - b.startDate.toDate().getTime(), - ), + fetchedData + .map((period) => ({ + ...period, + roomIds: period.roomIds?.map(String), + })) + .sort( + (a, b) => + a.startDate.toDate().getTime() - b.startDate.toDate().getTime(), + ), ); } catch (error) { console.error("Error fetching blackout periods:", error); } }; - const handleOpenDialog = (period?: BlackoutPeriod) => { + const handleOpenDialog = (period?: RuntimeBlackoutPeriod) => { if (period) { setEditingPeriod(period); setPeriodName(period.name); @@ -147,19 +169,13 @@ export default function BookingBlackoutPeriods() { if (period.roomIds && period.roomIds.length > 0) { // Check what type of room application this is - const allRoomIds = roomSettings - .map((room) => room.roomId) - .sort((a, b) => a - b); - const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); - const productionRoomIds = PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const eventRoomIds = EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const multiRoomIds = MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); + const allRoomIds = sortResourceIds( + roomSettings.map((room) => room.roomId), + ); + const periodRoomIds = sortResourceIds([...period.roomIds]); + const productionRoomIds = getCategoryResourceIds(PRODUCTION_ROOMS); + const eventRoomIds = getCategoryResourceIds(EVENT_ROOMS); + const multiRoomIds = getCategoryResourceIds(MULTI_ROOMS); const isAllRooms = allRoomIds.length === periodRoomIds.length && @@ -259,32 +275,26 @@ export default function BookingBlackoutPeriods() { try { // Determine which rooms to apply the blackout to - let roomIds: number[] = []; + let roomIds: string[] = []; switch (roomApplicationType) { case "all": roomIds = roomSettings.map((room) => room.roomId); break; case "production": - roomIds = PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ); + roomIds = getCategoryResourceIds(PRODUCTION_ROOMS); break; case "event": - roomIds = EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ); + roomIds = getCategoryResourceIds(EVENT_ROOMS); break; case "multi": - roomIds = MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ); + roomIds = getCategoryResourceIds(MULTI_ROOMS); break; case "specific": roomIds = selectedRooms; break; } - const periodData: Omit = { + const periodData: Omit = { name: periodName.trim(), startDate: Timestamp.fromDate(startDate.toDate()), endDate: Timestamp.fromDate(endDate.toDate()), @@ -330,7 +340,7 @@ export default function BookingBlackoutPeriods() { } }; - const handleDeletePeriod = async (period: BlackoutPeriod) => { + const handleDeletePeriod = async (period: RuntimeBlackoutPeriod) => { if (!window.confirm(`Are you sure you want to delete "${period.name}"?`)) { return; } @@ -368,25 +378,17 @@ export default function BookingBlackoutPeriods() { return dateStr; }; - const getRoomNames = (period: BlackoutPeriod) => { + const getRoomNames = (period: RuntimeBlackoutPeriod) => { if (!period.roomIds || period.roomIds.length === 0) { return "All Rooms"; } // Check if this matches any of our predefined categories - const allRoomIds = roomSettings - .map((room) => room.roomId) - .sort((a, b) => a - b); - const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); - const productionRoomIds = PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const eventRoomIds = EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); - const multiRoomIds = MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).sort((a, b) => a - b); + const allRoomIds = sortResourceIds(roomSettings.map((room) => room.roomId)); + const periodRoomIds = sortResourceIds([...period.roomIds]); + const productionRoomIds = getCategoryResourceIds(PRODUCTION_ROOMS); + const eventRoomIds = getCategoryResourceIds(EVENT_ROOMS); + const multiRoomIds = getCategoryResourceIds(MULTI_ROOMS); const isAllRooms = areSortedArraysEqual(allRoomIds, periodRoomIds); const isProductionRooms = areSortedArraysEqual( @@ -418,19 +420,13 @@ export default function BookingBlackoutPeriods() { }; const getProductionRoomNumbers = () => - PRODUCTION_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).join(", "); + getCategoryResourceIds(PRODUCTION_ROOMS).join(", "); const getEventRoomNumbers = () => - EVENT_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).join(", "); + getCategoryResourceIds(EVENT_ROOMS).join(", "); const getMultiRoomNumbers = () => - MULTI_ROOMS.filter((roomId) => - roomSettings.some((room) => room.roomId === roomId), - ).join(", "); + getCategoryResourceIds(MULTI_ROOMS).join(", "); return ( @@ -631,7 +627,7 @@ export default function BookingBlackoutPeriods() { multiple value={selectedRooms} onChange={(e) => { - const value = e.target.value as number[]; + const value = e.target.value as string[]; setSelectedRooms(value); }} input={} diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx index cca00ac49..79eae255d 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHours.tsx @@ -34,10 +34,15 @@ const MenuProps = { export default function OperationalHours() { const { operationHours, roomSettings } = useContext(DatabaseContext); - const [specialHourRooms, setSpecialHourRooms] = useState( + const [specialHourRooms, setSpecialHourRooms] = useState( Array.from( - new Set(operationHours.map((x) => x.roomId).filter((x) => x != null)), - ).sort((a, b) => a - b), + new Set( + operationHours + .map((x) => x.roomId) + .filter((x) => x != null) + .map(String), + ), + ).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), ); // roomIds const handleChange = (event: SelectChangeEvent) => { @@ -45,11 +50,10 @@ export default function OperationalHours() { target: { value }, } = event; // On autofill we get a stringified value. - const list = - typeof value === "string" - ? value.split(",").map((x) => Number(x)) - : value; - setSpecialHourRooms(list.sort((a, b) => a - b)); + const list = typeof value === "string" ? value.split(",") : value; + setSpecialHourRooms( + list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), + ); }; return ( @@ -121,7 +125,7 @@ export default function OperationalHours() { x.day === day && x.roomId === roomId, + (x) => x.day === day && String(x.roomId) === roomId, )} roomId={roomId} key={day} diff --git a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx index 619872b86..675c62f1d 100644 --- a/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx +++ b/booking-app/components/src/client/routes/admin/components/policySettings/OperationalHoursRow.tsx @@ -18,11 +18,18 @@ const Row = styled(Box)` interface Props { day: Days; setting: OperationHours; - roomId?: number; + roomId?: string; } export default function OperationalHoursRow({ day, setting, roomId }: Props) { const { reloadOperationHours } = useContext(DatabaseContext); + const updateResourceOperationHours = updateOperationHours as unknown as ( + day: Days, + open: number, + close: number, + isClosed: boolean, + roomId?: string, + ) => Promise; const [closed, setClosed] = useState(setting?.isClosed || false); const [openDate, setOpenDate] = useState( @@ -34,7 +41,7 @@ export default function OperationalHoursRow({ day, setting, roomId }: Props) { const handleSwitch = (e) => { setClosed(!e.target.checked); - updateOperationHours( + updateResourceOperationHours( day, openDate.hour(), closeDate.hour(), @@ -45,12 +52,12 @@ export default function OperationalHoursRow({ day, setting, roomId }: Props) { const handleOpenChange = (e) => { setOpenDate(e); - updateOperationHours(day, e.$H, closeDate.hour(), closed, roomId); + updateResourceOperationHours(day, e.$H, closeDate.hour(), closed, roomId); }; const handleCloseChange = (e) => { setCloseDate(e); - updateOperationHours(day, openDate.hour(), e.$H, closed, roomId); + updateResourceOperationHours(day, openDate.hour(), e.$H, closed, roomId); }; // when we leave the page, the app reloads the newly set operation hours. diff --git a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx index 9fd20bfca..954d66a6f 100644 --- a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx +++ b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx @@ -25,7 +25,7 @@ export default function useExistingBooking() { setDepartment(booking.department as Department); setRole(booking.role as Role); - const roomIds = booking.roomId.split(", ").map((x) => Number(x)); + const roomIds = booking.roomId.split(",").map((roomId) => roomId.trim()); const rooms = roomSettings.filter((roomSetting) => roomIds.includes(roomSetting.roomId), ); diff --git a/booking-app/components/src/client/routes/booking/bookingProvider.tsx b/booking-app/components/src/client/routes/booking/bookingProvider.tsx index c26a6db7e..e93aa6152 100644 --- a/booking-app/components/src/client/routes/booking/bookingProvider.tsx +++ b/booking-app/components/src/client/routes/booking/bookingProvider.tsx @@ -161,9 +161,13 @@ export function BookingProvider({ children }) { const bookingStart = dayjs(bookingCalendarInfo.start); const bookingEnd = dayjs(bookingCalendarInfo.end); const selectedRoomIds = selectedRooms.map((room) => room.roomId); + const stringIdBlackoutPeriods = blackoutPeriods.map((period) => ({ + ...period, + roomIds: period.roomIds?.map(String), + })); const affectingPeriods = getAffectingBlackoutPeriods( - blackoutPeriods, + stringIdBlackoutPeriods, bookingStart, bookingEnd, selectedRoomIds, diff --git a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx index 4076af768..076b38a32 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx @@ -51,11 +51,11 @@ export default function BookingFormMediaServices(props: Props) { // Check for specific room services selectedRooms.forEach((room) => { if (showStaffing) { - if (room.roomId === 103) { + if (room.roomId === "103") { options.push(MediaServices.AUDIO_TECH_103); options.push(MediaServices.LIGHTING_TECH_103); } - if (room.roomId === 230) { + if (room.roomId === "230") { options.push(MediaServices.AUDIO_TECH_230); } if (room.services?.includes("campus-media")) { diff --git a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx index 66aaefe33..bf4469a1c 100644 --- a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx +++ b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx @@ -141,11 +141,17 @@ export default function CalendarVerticalResource({ const resources = useMemo( () => - rooms.map((room) => ({ - id: `${room.roomId}`, - title: `${room.roomId} ${room.name}`, - index: Number(room.roomId), - })), + [...rooms] + .sort((a, b) => + String(a.roomId).localeCompare(String(b.roomId), undefined, { + numeric: true, + }), + ) + .map((room, index) => ({ + id: room.roomId, + title: `${room.roomId} ${room.name}`, + index, + })), [rooms], ); @@ -169,7 +175,7 @@ export default function CalendarVerticalResource({ start: blackoutRange.start.toISOString(), end: blackoutRange.end.toISOString(), id: `blackout-${room.roomId}-${period.id}`, - resourceId: `${room.roomId}`, + resourceId: room.roomId, title: blackoutRange.title, overlap: false, display: "background", @@ -208,12 +214,11 @@ export default function CalendarVerticalResource({ start: bookingCalendarInfo.startStr, end: bookingCalendarInfo.endStr, id: room.roomId + bookingCalendarInfo.startStr, - resourceId: `${room.roomId}`, + resourceId: room.roomId, title: NEW_TITLE_TAG, overlap: true, durationEditable: true, - startEditable: - formContext !== FormContextLevel.MODIFICATION || isAdmin, + startEditable: formContext !== FormContextLevel.MODIFICATION || isAdmin, groupId: "new", url: `${index}:${rooms.length}`, // some hackiness to let us render multiple events visually as one big block })); @@ -241,11 +246,9 @@ export default function CalendarVerticalResource({ const selectedResourceId = selectInfo.resource?.id; if (!isAdmin && selectedResourceId) { - const roomId = parseInt(selectedResourceId); - // Use the new time-aware blackout checking const { inBlackout } = isBookingTimeInBlackout(bookingStart, bookingEnd, [ - roomId, + selectedResourceId, ]); if (inBlackout) { @@ -267,11 +270,9 @@ export default function CalendarVerticalResource({ const selectedResourceId = selectInfo.resource?.id; if (!isAdmin && selectedResourceId) { - const roomId = parseInt(selectedResourceId); - // Use the new time-aware blackout checking const { inBlackout } = isBookingTimeInBlackout(bookingStart, bookingEnd, [ - roomId, + selectedResourceId, ]); if (inBlackout) { @@ -407,9 +408,7 @@ export default function CalendarVerticalResource({ googleCalendarPlugin, interactionPlugin, ]} - selectable={ - formContext !== FormContextLevel.MODIFICATION || isAdmin - } + selectable={formContext !== FormContextLevel.MODIFICATION || isAdmin} select={handleEventSelect} selectAllow={handleEventSelecting} selectOverlap={handleSelectOverlap} diff --git a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx index 36e0922fd..2bb30d207 100644 --- a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx +++ b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx @@ -32,13 +32,18 @@ export const SelectRooms = ({ } = useContext(BookingContext); const { isBookingTimeInBlackout } = useBookingDateRestrictions(); const { resources, calendarConfig } = useTenantSchema(); - const selectedIds = selected.map((room) => room.roomId); + const selectedIds = selected.map((room) => String(room.roomId)); const allowMultipleResourceSelect = calendarConfig?.multipleResourceSelect ?? true; // Sort rooms by room number for consistent display order const sortedRooms = useMemo( - () => [...allRooms].sort((a, b) => a.roomId - b.roomId), + () => + [...allRooms].sort((a, b) => + String(a.roomId).localeCompare(String(b.roomId), undefined, { + numeric: true, + }), + ), [allRooms], ); @@ -53,7 +58,7 @@ export const SelectRooms = ({ // Check if a room is in blackout for the selected booking time const isRoomInBlackout = ( - roomId: number, + roomId: string, ): { inBlackout: boolean; periods: any[] } => { if (!bookingCalendarInfo) return { inBlackout: false, periods: [] }; @@ -69,7 +74,7 @@ export const SelectRooms = ({ }; // walk-ins can only book 1 room unless it's 2 ballroom bays (221-224) - const isDisabled = (roomId: number) => { + const isDisabled = (roomId: string) => { if (!allowMultipleResourceSelect) { if (selectedIds.length === 0) return false; if (selectedIds.includes(roomId)) return false; @@ -85,9 +90,11 @@ export const SelectRooms = ({ // Check if both selected room and current room can book two const selectedRoom = resources.find( - (r: any) => r.roomId === selectedIds[0], + (resource) => resource.resourceId === selectedIds[0], + ); + const currentRoom = resources.find( + (resource) => resource.resourceId === roomId, ); - const currentRoom = resources.find((r: any) => r.roomId === roomId); if (selectedRoom?.isWalkInCanBookTwo && currentRoom?.isWalkInCanBookTwo) { return false; @@ -95,7 +102,7 @@ export const SelectRooms = ({ return true; }; - const getDisabledReason = (roomId: number): string | null => { + const getDisabledReason = (roomId: string): string | null => { const blackoutInfo = isRoomInBlackout(roomId); if (blackoutInfo.inBlackout) { const periodNames = blackoutInfo.periods.map((p) => p.name).join(", "); @@ -111,9 +118,11 @@ export const SelectRooms = ({ // Check if both selected room and current room can book two const selectedRoom = resources.find( - (r: any) => r.roomId === selectedIds[0], + (resource) => resource.resourceId === selectedIds[0], + ); + const currentRoom = resources.find( + (resource) => resource.resourceId === roomId, ); - const currentRoom = resources.find((r: any) => r.roomId === roomId); if (selectedRoom?.isWalkInCanBookTwo && currentRoom?.isWalkInCanBookTwo) { return null; @@ -124,22 +133,27 @@ export const SelectRooms = ({ const handleCheckChange = (e: any, room: RoomSetting) => { const newVal: boolean = e.target.checked; + const normalizedRoom = { ...room, roomId: String(room.roomId) }; setSelected((prev: RoomSetting[]) => { if (newVal) { if ( !allowMultipleResourceSelect && prev.length > 0 && - !prev.some((r) => r.roomId === room.roomId) + !prev.some((r) => String(r.roomId) === normalizedRoom.roomId) ) { return prev; } - const newSelection = [...prev, room].sort( - (a, b) => a.roomId - b.roomId, + const newSelection = [...prev, normalizedRoom].sort((a, b) => + String(a.roomId).localeCompare(String(b.roomId), undefined, { + numeric: true, + }), ); return newSelection; } - const newSelection = prev.filter((r) => r.roomId !== room.roomId); + const newSelection = prev.filter( + (r) => String(r.roomId) !== normalizedRoom.roomId, + ); if (newSelection.length === 0) { setBookingCalendarInfo(null); } @@ -150,17 +164,20 @@ export const SelectRooms = ({ return ( {sortedRooms.map((room: RoomSetting) => { - const disabled = isDisabled(room.roomId); - const disabledReason = getDisabledReason(room.roomId); + const roomId = String(room.roomId); + const disabled = isDisabled(roomId); + const disabledReason = getDisabledReason(roomId); const checkbox = ( handleCheckChange(e, room)} icon={ - allowMultipleResourceSelect ? undefined : + allowMultipleResourceSelect ? undefined : ( + + ) } checkedIcon={ allowMultipleResourceSelect ? undefined : diff --git a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx index 5256d6531..845f67a1b 100644 --- a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx @@ -47,7 +47,7 @@ export default function SelectRoomPage({ // Convert schema resources to RoomSetting format for compatibility const convertedResources = resources.map((resource) => ({ ...resource, - roomId: resource.roomId, + roomId: resource.resourceId, name: resource.name, capacity: resource.capacity.toString(), calendarId: resource.calendarId, @@ -68,12 +68,7 @@ export default function SelectRoomPage({ const allRooms = !isWalkIn ? convertedResources - : convertedResources.filter((room) => { - const resource = schema.resources.find( - (r: any) => r.roomId === room.roomId, - ); - return resource?.isWalkIn || false; - }); + : convertedResources.filter((room) => room.isWalkIn); return allRooms; }, [schema.resources, isWalkIn]); diff --git a/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx b/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx index 6c87fc561..1771db171 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useBookingDateRestrictions.tsx @@ -16,6 +16,15 @@ export const useBookingDateRestrictions = () => { return blackoutPeriods.filter((period) => period.isActive); }, [blackoutPeriods]); + const stringIdBlackoutPeriods = useMemo( + () => + activeBlackoutPeriods.map((period) => ({ + ...period, + roomIds: period.roomIds?.map(String), + })), + [activeBlackoutPeriods], + ); + const isDateDisabled = (date: Dayjs) => { // Always disable past dates if (date.isBefore(dayjs(), "day")) { @@ -33,8 +42,12 @@ export const useBookingDateRestrictions = () => { if (roomSettings && roomSettings.length > 0 && period.roomIds) { const allRoomIds = roomSettings .map((room) => room.roomId) - .sort((a, b) => a - b); - const periodRoomIds = [...period.roomIds].sort((a, b) => a - b); + .sort((a, b) => + String(a).localeCompare(String(b), undefined, { numeric: true }), + ); + const periodRoomIds = period.roomIds + .map(String) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); const isAllRooms = allRoomIds.length === periodRoomIds.length && allRoomIds.every((id, index) => id === periodRoomIds[index]); @@ -48,7 +61,9 @@ export const useBookingDateRestrictions = () => { }; // Check if a specific date is disabled for specific rooms - const isDateDisabledForRooms = (date: Dayjs, roomIds: number[]) => { + const isDateDisabledForRooms = (date: Dayjs, roomIds: string[]) => { + const normalizedRoomIds = roomIds.map(String); + // Always disable past dates if (date.isBefore(dayjs(), "day")) { return true; @@ -63,7 +78,10 @@ export const useBookingDateRestrictions = () => { // If period has roomIds, check if any of the selected rooms are in the blackout period if (period.roomIds) { - return roomIds.some((roomId) => period.roomIds.includes(roomId)); + const periodRoomIds = period.roomIds.map(String); + return normalizedRoomIds.some((roomId) => + periodRoomIds.includes(roomId), + ); } // If no roomIds, treat as global blackout that applies to all rooms @@ -78,8 +96,9 @@ export const useBookingDateRestrictions = () => { }); // Get blackout periods that apply to specific rooms for a specific date - const getBlackoutPeriodsForDateAndRooms = (date: Dayjs, roomIds: number[]) => + const getBlackoutPeriodsForDateAndRooms = (date: Dayjs, roomIds: string[]) => activeBlackoutPeriods.filter((period) => { + const normalizedRoomIds = roomIds.map(String); const blackoutRange = getBlackoutTimeRangeForDate(period, date); if (!blackoutRange) { return false; // Date not in blackout period @@ -87,7 +106,10 @@ export const useBookingDateRestrictions = () => { // If period has roomIds, check if any of the selected rooms are in the blackout period if (period.roomIds) { - return roomIds.some((roomId) => period.roomIds.includes(roomId)); + const periodRoomIds = period.roomIds.map(String); + return normalizedRoomIds.some((roomId) => + periodRoomIds.includes(roomId), + ); } // Include global blackout periods (those without roomIds) @@ -98,17 +120,17 @@ export const useBookingDateRestrictions = () => { const isBookingTimeInBlackout = ( bookingStart: Dayjs, bookingEnd: Dayjs, - roomIds: number[], + roomIds: string[], ): { inBlackout: boolean; affectedPeriods: any[] } => { if (!roomIds || roomIds.length === 0) { return { inBlackout: false, affectedPeriods: [] }; } const affectedPeriods = getAffectingBlackoutPeriods( - activeBlackoutPeriods, + stringIdBlackoutPeriods, bookingStart, bookingEnd, - roomIds, + roomIds.map(String), ); return { @@ -121,17 +143,17 @@ export const useBookingDateRestrictions = () => { const getBlackoutPeriodsForBookingTime = ( bookingStart: Dayjs, bookingEnd: Dayjs, - roomIds: number[], + roomIds: string[], ) => getAffectingBlackoutPeriods( - activeBlackoutPeriods, + stringIdBlackoutPeriods, bookingStart, bookingEnd, - roomIds, + roomIds.map(String), ); const shouldDisableDate = (date: Dayjs) => isDateDisabled(date); - const shouldDisableDateForRooms = (date: Dayjs, roomIds: number[]) => + const shouldDisableDateForRooms = (date: Dayjs, roomIds: string[]) => isDateDisabledForRooms(date, roomIds); return { diff --git a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx index 4d5a166a9..d6e36e707 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx @@ -21,10 +21,10 @@ export default function useCalculateOverlap() { calendarEventId = segments[segments.length - 1]; } - const selectedRoomIds = selectedRooms.map((x) => x.roomId); + const selectedRoomIds = selectedRooms.map((x) => String(x.roomId)); return existingCalendarEvents .map((event) => { - if (!selectedRoomIds.includes(Number(event.resourceId))) return false; + if (!selectedRoomIds.includes(String(event.resourceId))) return false; // for edit/modification mode, don't overlap with existing booking if ( calendarEventId && diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx index 0f97218cf..c39a90358 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckAutoApproval.tsx @@ -13,7 +13,7 @@ import { BookingContext } from "../bookingProvider"; import { getBookingHourLimits } from "../utils/bookingHourLimits"; export function selectedAutoApprovalRooms( - selectedRoomIds: number[], + selectedRoomIds: string[], selectedRooms: any[], ) { if (selectedRoomIds.length < 2) return true; @@ -47,7 +47,10 @@ export default function useCheckAutoApproval(isWalkIn = false, isVIP = false) { useEffect(() => { // For ITP and Media Commons tenants, use XState machine for auto-approval logic - if (schema.tenantId === TENANTS.ITP || isMediaCommonsTenant(schema.tenantId)) { + if ( + schema.tenantId === TENANTS.ITP || + isMediaCommonsTenant(schema.tenantId) + ) { console.log( `🎭 CLIENT-SIDE XSTATE CHECK [${schema.tenantId?.toUpperCase()}]:`, { @@ -72,7 +75,9 @@ export default function useCheckAutoApproval(isWalkIn = false, isVIP = false) { try { // Choose the appropriate machine based on tenant const machine = - schema.tenantId === TENANTS.ITP ? itpBookingMachine : mcBookingMachine; + schema.tenantId === TENANTS.ITP + ? itpBookingMachine + : mcBookingMachine; // For Media Commons, prepare services data const servicesRequested = isMediaCommonsTenant(schema.tenantId) diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx index e05bf20c3..370182650 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckDurationLimits.tsx @@ -5,7 +5,7 @@ import { Role } from "../../../../types"; import { getBookingHourLimits } from "../utils/bookingHourLimits"; interface DurationLimitError { - roomId: number; + roomId: string; roomName: string; currentDuration: number; maxDuration: number; diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx index 0c9983adf..6741c1147 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckRequestLimits.tsx @@ -52,9 +52,7 @@ export default function useCheckRequestLimits(formContext: FormContextLevel) { email = userEmail || formData?.missingEmail; } - const roomIds = selectedRooms - .map((r) => Number(r.roomId)) - .filter((n) => Number.isFinite(n)); + const roomIds = selectedRooms.map((room) => room.roomId); if (!email?.trim() || roomIds.length === 0) { setRequestLimitChecking(false); diff --git a/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts b/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts index d64a084de..8f37858ef 100644 --- a/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts +++ b/booking-app/components/src/client/routes/booking/utils/buildBlockPastTimes.ts @@ -25,7 +25,7 @@ export interface BlockPastTimeEvent { * as a parameter so unit tests can control the clock. */ export function buildBlockPastTimes( - rooms: Array<{ roomId: number | string }>, + rooms: Array<{ roomId: string }>, dateView: Date, startHour: string | undefined, slotUnit: number, @@ -96,7 +96,7 @@ export function buildBlockPastTimes( start: blockStart.toISOString(), end: blockEnd.toISOString(), id: `${room.roomId}bg`, - resourceId: `${room.roomId}`, + resourceId: String(room.roomId), overlap: false, display: "background", classNames: ["disabled"], diff --git a/booking-app/components/src/client/routes/components/Provider.tsx b/booking-app/components/src/client/routes/components/Provider.tsx index be52361f1..69b760fde 100644 --- a/booking-app/components/src/client/routes/components/Provider.tsx +++ b/booking-app/components/src/client/routes/components/Provider.tsx @@ -314,6 +314,7 @@ export const DatabaseProvider = ({ const rooms: RoomSetting[] = schemaContext.resources .map((resource) => ({ ...resource, + roomId: resource.resourceId, capacity: String(resource.capacity), needsSafetyTraining: resource.training?.required || false, trainingFormUrl: resource.training?.formId || undefined, @@ -323,7 +324,9 @@ export const DatabaseProvider = ({ isEquipment: resource.isEquipment || false, services: resource.services || [], })) - .sort((a, b) => a.roomId - b.roomId); + .sort((a, b) => + a.roomId.localeCompare(b.roomId, undefined, { numeric: true }), + ); setRoomSettings(rooms); } }, [schemaContext?.resources]); diff --git a/booking-app/components/src/client/routes/components/schemaTypes.ts b/booking-app/components/src/client/routes/components/schemaTypes.ts index 06a608de8..d6c262763 100644 --- a/booking-app/components/src/client/routes/components/schemaTypes.ts +++ b/booking-app/components/src/client/routes/components/schemaTypes.ts @@ -37,7 +37,7 @@ export type RequestLimits = Partial< export type Resource = { capacity: number; name: string; - roomId: number; + resourceId: string; isEquipment: boolean; calendarId: string; training?: ResourceTraining; @@ -246,7 +246,7 @@ export const defaultAttestation: Attestation = { export const defaultResource: Resource = { capacity: 0, name: "", - roomId: 0, + resourceId: "", isEquipment: false, calendarId: "", training: { diff --git a/booking-app/components/src/client/routes/super/schemaEditor.tsx b/booking-app/components/src/client/routes/super/schemaEditor.tsx index f14fbd327..40f991141 100644 --- a/booking-app/components/src/client/routes/super/schemaEditor.tsx +++ b/booking-app/components/src/client/routes/super/schemaEditor.tsx @@ -478,7 +478,7 @@ function AttestationsSection({ } // ─── Section: Resources ─── -function ResourceEditor({ +export function ResourceEditor({ resource, index, onUpdate, @@ -505,7 +505,7 @@ function ResourceEditor({ {resource.name || `Resource ${index + 1}`} - {resource.roomId ? ` (Room ${resource.roomId})` : ""} + {resource.resourceId ? ` (${resource.resourceId})` : ""} @@ -518,12 +518,11 @@ function ResourceEditor({ size="small" /> set("roomId", Number(e.target.value))} + label="Resource ID" + value={resource.resourceId ?? ""} + onChange={(e) => set("resourceId", e.target.value)} size="small" - sx={{ width: 120 }} + sx={{ width: 180 }} /> {resources.map((r, i) => ( { }; export const serverGetRoomCalendarIds = async ( - roomId: number, + roomId: string | number, tenant?: string, ): Promise => { try { @@ -871,7 +871,8 @@ export const serverGetRoomCalendarIds = async ( ); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => resource.roomId === roomId, + (resource: any) => + String(resource.resourceId ?? resource.roomId) === String(roomId), ); console.log(`Rooms: ${JSON.stringify(rooms)}`); @@ -889,7 +890,7 @@ export const serverGetRoomCalendarIds = async ( }; export const serverGetRoomCalendarId = async ( - roomId: number, + roomId: string | number, tenant?: string, ): Promise => { try { @@ -910,7 +911,8 @@ export const serverGetRoomCalendarId = async ( ); const rooms = resourcesWithCorrectCalendarIds.filter( - (resource: any) => resource.roomId === roomId, + (resource: any) => + String(resource.resourceId ?? resource.roomId) === String(roomId), ); if (rooms.length > 0) { diff --git a/booking-app/components/src/server/calendars.ts b/booking-app/components/src/server/calendars.ts index d552bc43d..fd7813458 100644 --- a/booking-app/components/src/server/calendars.ts +++ b/booking-app/components/src/server/calendars.ts @@ -30,7 +30,7 @@ export const patchCalendarEvent = async ( export const inviteUserToCalendarEvent = async ( calendarEventId: string, guestEmail: string, - roomId: number, + roomId: string, tenant?: string, ) => { const roomCalendarIds = await serverGetRoomCalendarIds(roomId, tenant); @@ -326,9 +326,7 @@ export const updateCalendarEvent = async ( } const roomCalendarIds = await serverGetRoomCalendarIds( - typeof bookingContents.roomId === "string" - ? parseInt(bookingContents.roomId, 10) - : bookingContents.roomId, + String(bookingContents.roomId).split(",")[0].trim(), tenant, ); console.log(`Room Calendar Ids: ${roomCalendarIds}`); diff --git a/booking-app/components/src/server/db.ts b/booking-app/components/src/server/db.ts index f94e3a59d..5f413cd17 100644 --- a/booking-app/components/src/server/db.ts +++ b/booking-app/components/src/server/db.ts @@ -813,7 +813,7 @@ export const updateOperationHours = async ( open: number, close: number, isClosed: boolean, - roomId?: number, + roomId?: string, ) => { const docs = await clientFetchAllDataFromCollection< OperationHours & { id: string } diff --git a/booking-app/components/src/testHelpers/testTenantSchemas.ts b/booking-app/components/src/testHelpers/testTenantSchemas.ts index 5d67c6e97..b735acd8e 100644 --- a/booking-app/components/src/testHelpers/testTenantSchemas.ts +++ b/booking-app/components/src/testHelpers/testTenantSchemas.ts @@ -81,7 +81,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 30, name: "Lecture Hall 202", - roomId: 202, + resourceId: "202", isEquipment: false, calendarId: "mock-calendar-202", training: { required: false, formId: "", infoUrl: "" }, @@ -114,7 +114,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 20, name: "Studio 220", - roomId: 220, + resourceId: "220", isEquipment: false, calendarId: "mock-calendar-220", training: { required: false, formId: "", infoUrl: "" }, @@ -147,7 +147,7 @@ const baseMediaCommonsSchema: SchemaContextType = { { capacity: 15, name: "Training Room 230", - roomId: 230, + resourceId: "230", isEquipment: false, calendarId: "mock-calendar-230", training: { required: true, formId: "https://docs.google.com/forms/d/e/mock/viewform", infoUrl: "" }, diff --git a/booking-app/components/src/types.ts b/booking-app/components/src/types.ts index e5a9e0e1b..ea94bbe74 100644 --- a/booking-app/components/src/types.ts +++ b/booking-app/components/src/types.ts @@ -251,7 +251,7 @@ export type OperationHours = { open: number; close: number; isClosed: boolean; - roomId?: number; + roomId?: string; }; export type PaUser = { @@ -286,7 +286,7 @@ export type BlackoutPeriod = { isActive: boolean; createdAt: Timestamp; updatedAt?: Timestamp; - roomIds?: number[]; // Optional array of room IDs - if empty/undefined, applies to all rooms + roomIds?: string[]; // Optional resource IDs; empty/undefined applies to all resources }; export type PolicySettings = { @@ -310,7 +310,7 @@ export enum Role { } export type RoomSetting = { - roomId: number; + roomId: string; name: string; capacity: string; calendarId: string; diff --git a/booking-app/components/src/utils/blackoutUtils.ts b/booking-app/components/src/utils/blackoutUtils.ts index dd99a3771..9832fc0ee 100644 --- a/booking-app/components/src/utils/blackoutUtils.ts +++ b/booking-app/components/src/utils/blackoutUtils.ts @@ -133,7 +133,7 @@ export function isBookingInBlackoutPeriod( period: BlackoutPeriod, bookingStart: Dayjs, bookingEnd: Dayjs, - selectedRoomIds: number[], + selectedRoomIds: string[], ): boolean { const startDate = dayjs(period.startDate.toDate()); const endDate = dayjs(period.endDate.toDate()); @@ -209,7 +209,7 @@ export function getAffectingBlackoutPeriods( blackoutPeriods: BlackoutPeriod[], bookingStart: Dayjs, bookingEnd: Dayjs, - selectedRoomIds: number[], + selectedRoomIds: string[], ): BlackoutPeriod[] { const activeBlackoutPeriods = blackoutPeriods.filter( (period) => period.isActive, diff --git a/booking-app/lib/bookingRequestLimits.ts b/booking-app/lib/bookingRequestLimits.ts index 20cfe1df9..4d5f30723 100644 --- a/booking-app/lib/bookingRequestLimits.ts +++ b/booking-app/lib/bookingRequestLimits.ts @@ -30,13 +30,14 @@ function nyCalendarParts(now: Date) { /** Start of that calendar date at 00:00 in {@link REQUEST_LIMITS_TIME_ZONE} (UTC instant). */ function nyMidnight(y: number, monthIndex0: number, day: number): Date { - return toDate( - `${y}-${pad2(monthIndex0 + 1)}-${pad2(day)}T00:00:00.000`, - { timeZone: REQUEST_LIMITS_TIME_ZONE }, - ); + return toDate(`${y}-${pad2(monthIndex0 + 1)}-${pad2(day)}T00:00:00.000`, { + timeZone: REQUEST_LIMITS_TIME_ZONE, + }); } -export function parseRoleEnumFromLabel(label: string | undefined): Role | undefined { +export function parseRoleEnumFromLabel( + label: string | undefined, +): Role | undefined { if (!label) return undefined; const normalized = label.trim().toLowerCase(); const entries = Object.values(Role) as string[]; @@ -170,19 +171,29 @@ export function getNewYorkWindowForPeriod( /** @deprecated Use {@link getNewYorkWindowForPeriod} */ export const getLocalWindowForPeriod = getNewYorkWindowForPeriod; -export function parseRoomIdsFromBooking(doc: any): number[] { +function normalizeResourceId(value: unknown): string | null { + if (value == null) return null; + const resourceId = String(value).trim(); + return resourceId || null; +} + +export function parseRoomIdsFromBooking(doc: any): string[] { if (Array.isArray(doc?.roomIds)) { return doc.roomIds - .map((x: any) => Number(x)) - .filter((n: number) => Number.isFinite(n)); + .map((x: unknown) => normalizeResourceId(x)) + .filter( + (resourceId: string | null): resourceId is string => resourceId != null, + ); } const raw = doc?.roomId; - if (typeof raw === "string") { - return raw + if (raw != null) { + return String(raw) .split(",") - .map((s) => Number(String(s).trim())) - .filter((n) => Number.isFinite(n)); + .map((s) => normalizeResourceId(s)) + .filter( + (resourceId: string | null): resourceId is string => resourceId != null, + ); } return []; @@ -285,8 +296,9 @@ function isActiveBookingByLastLog({ latestLogStatusByRequestNumber: Map; }): boolean { const req = Number(booking?.requestNumber); - const last = - Number.isFinite(req) ? latestLogStatusByRequestNumber.get(req) ?? null : null; + const last = Number.isFinite(req) + ? (latestLogStatusByRequestNumber.get(req) ?? null) + : null; // Strict "last-log wins": if we have a last log, use it. if (last) return !isTerminalInactiveStatus(last); @@ -309,17 +321,18 @@ export async function enforceRequestLimits({ bookingRoleField: string; /** Key used in `resource.requestLimits` maps: `student` | `faculty` | `admin` (no origin suffix). */ limitRoleKey: RequestLimitBucketKey; - selectedRoomIds: number[]; + selectedRoomIds: string[]; schema: SchemaContextType | null; }): Promise<{ ok: true } | { ok: false; message: string }> { if (!schema?.resources || schema.resources.length === 0) return { ok: true } as const; - const resourcesByRoomId = new Map(); + const resourcesByRoomId = new Map(); for (const r of schema.resources) { - if (typeof (r as any)?.roomId === "number") { - resourcesByRoomId.set((r as any).roomId, r); - } + const resourceId = + normalizeResourceId((r as any)?.resourceId) ?? + normalizeResourceId((r as any)?.roomId); + if (resourceId) resourcesByRoomId.set(resourceId, r); } const now = new Date(); @@ -345,7 +358,7 @@ export async function enforceRequestLimits({ if (periodsToQuery.length === 0) return { ok: true } as const; const countsByPeriod: Partial< - Record> + Record> > = {}; // Pre-compute windows so we can bound the Firestore query by the earliest @@ -394,7 +407,10 @@ export async function enforceRequestLimits({ const relevant = allByEmail.filter((d: any) => { if (d?.role !== bookingRoleField) return false; - return isActiveBookingByLastLog({ booking: d, latestLogStatusByRequestNumber }); + return isActiveBookingByLastLog({ + booking: d, + latestLogStatusByRequestNumber, + }); }); for (const period of periodsToQuery) { @@ -402,7 +418,7 @@ export async function enforceRequestLimits({ const startMs = start.getTime(); const endMs = end.getTime(); - const roomCounts = new Map(); + const roomCounts = new Map(); for (const doc of relevant) { const ms = getRequestedAtMillis(doc); if (ms == null) continue; @@ -433,7 +449,9 @@ export async function enforceRequestLimits({ // For limit 0, count < 0 is impossible, so booking is always blocked. if (count < limit) continue; - const resourceName = resource.name ? `"${resource.name}"` : `Room ${roomId}`; + const resourceName = resource.name + ? `"${resource.name}"` + : `Room ${roomId}`; return { ok: false, message: `Request limit reached for ${resourceName} (${period}). Limit: ${limit}.`, diff --git a/booking-app/lib/tenant/coerceTenantSchema.ts b/booking-app/lib/tenant/coerceTenantSchema.ts index 3cf8b37a2..84033a4c6 100644 --- a/booking-app/lib/tenant/coerceTenantSchema.ts +++ b/booking-app/lib/tenant/coerceTenantSchema.ts @@ -5,6 +5,66 @@ import type { } from "@/components/src/client/routes/components/schemaTypes"; import { generateDefaultSchema } from "@/components/src/client/routes/components/schemaTypes"; +function normalizeResourceId(value: unknown, field: string): string { + if ( + (typeof value !== "string" && typeof value !== "number") || + (typeof value === "number" && !Number.isFinite(value)) + ) { + throw new Error(`${field} must be a string or finite number`); + } + const resourceId = String(value); + if (!resourceId.trim()) { + throw new Error(`${field} must be non-empty`); + } + if (resourceId.includes(",")) { + throw new Error(`${field} must not contain commas`); + } + return resourceId; +} + +function coerceResource( + rawResource: Resource | Record, + index: number, +): Resource { + const { + roomId, + resourceId, + ...resource + } = rawResource as Record; + const canonicalId = + resourceId === undefined + ? normalizeResourceId(roomId, `resources[${index}].roomId`) + : normalizeResourceId(resourceId, `resources[${index}].resourceId`); + + if ( + roomId !== undefined && + normalizeResourceId(roomId, `resources[${index}].roomId`) !== canonicalId + ) { + throw new Error( + `resources[${index}] has conflicting resourceId and roomId`, + ); + } + + return { + ...resource, + resourceId: canonicalId, + } as Resource; +} + +function coerceResources( + resources: Array>, +): Resource[] { + const seen = new Set(); + return resources.map((resource, index) => { + const coerced = coerceResource(resource, index); + if (seen.has(coerced.resourceId)) { + throw new Error(`resources has duplicate resourceId "${coerced.resourceId}"`); + } + seen.add(coerced.resourceId); + return coerced; + }); +} + /** * Normalizes a tenant schema document from Firestore into SchemaContextType by * merging the stored (canonical nested-shape) document over the tenant @@ -61,7 +121,9 @@ export function coerceTenantSchema( }, }, resources: Array.isArray(raw.resources) - ? (raw.resources as Resource[]) + ? coerceResources( + raw.resources as Array>, + ) : base.resources, attestations: Array.isArray(raw.attestations) ? (raw.attestations as SchemaContextType["attestations"]) diff --git a/booking-app/lib/utils/testTenantSchema.ts b/booking-app/lib/utils/testTenantSchema.ts index 2c6ae2a54..1f80010c8 100644 --- a/booking-app/lib/utils/testTenantSchema.ts +++ b/booking-app/lib/utils/testTenantSchema.ts @@ -83,7 +83,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 30, name: "Lecture Hall 202", - roomId: 202, + resourceId: "202", calendarId: "mock-calendar-202", isWalkIn: false, autoApproval: { shouldAutoApprove: true }, @@ -91,7 +91,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 20, name: "Studio 220", - roomId: 220, + resourceId: "220", calendarId: "mock-calendar-220", isWalkIn: true, autoApproval: { shouldAutoApprove: false }, @@ -99,7 +99,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 25, name: "Seminar 203", - roomId: 203, + resourceId: "203", calendarId: "mock-calendar-203", isWalkIn: false, maxHour: { ...defaultResource.maxHour, faculty: 0.5 }, @@ -108,7 +108,7 @@ function getMcTestSchema(tenant: string): SchemaContextType { resource({ capacity: 15, name: "Workshop 230", - roomId: 230, + resourceId: "230", calendarId: "mock-calendar-230", isWalkIn: false, training: { @@ -186,7 +186,7 @@ function getItpTestSchema(): SchemaContextType { resource({ capacity: 20, name: "Room 408", - roomId: 408, + resourceId: "408", calendarId: "mock-calendar-408", isWalkIn: false, autoApproval: { @@ -198,7 +198,7 @@ function getItpTestSchema(): SchemaContextType { resource({ capacity: 10, name: "Room 410", - roomId: 410, + resourceId: "410", calendarId: "mock-calendar-410", isWalkIn: true, autoApproval: { diff --git a/booking-app/package.json b/booking-app/package.json index a0cf41d62..150fcab24 100644 --- a/booking-app/package.json +++ b/booking-app/package.json @@ -18,7 +18,9 @@ "copy:production:dry-run": "node scripts/copyCollection.js --source-database development --target-database production --source-collection tenantSchema --target-collection tenantSchema --dry-run --report-file dry-run-production-details.json", "mergeUsers": "node scripts/mergeUsersCollections.js", "sync:schemas": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts", - "sync:schemas:dry-run": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts --dry-run" + "sync:schemas:dry-run": "npx ts-node -r tsconfig-paths/register --transpile-only --compiler-options '{\"module\":\"commonjs\",\"jsx\":\"react\"}' scripts/syncTenantSchemas.ts --dry-run", + "migrate:tenant-resource-ids": "node scripts/migrateTenantResourceIds.js", + "migrate:tenant-resource-ids:dry-run": "node scripts/migrateTenantResourceIds.js --dry-run" }, "dependencies": { "@emotion/styled": "^11.13.0", diff --git a/booking-app/scripts/migrateTenantResourceIds.js b/booking-app/scripts/migrateTenantResourceIds.js new file mode 100644 index 000000000..639d03ad5 --- /dev/null +++ b/booking-app/scripts/migrateTenantResourceIds.js @@ -0,0 +1,251 @@ +//@ts-nocheck +require("dotenv").config({ path: ".env.local" }); +const admin = require("firebase-admin"); +const { + TENANT_SCHEMA_COLLECTION, + backupTenantSchemaDocument, +} = require("./tenantSchemaBackup"); + +const BACKUP_TYPE = "resource-id-string"; +const DATABASES = { + development: "default", + staging: "booking-app-staging", + production: "booking-app-prod", +}; + +const hasOwn = (value, key) => + Object.prototype.hasOwnProperty.call(value, key); + +const validateId = (id, path) => { + if (typeof id !== "string") { + throw new Error(`${path} must be a string`); + } + if (id.trim().length === 0) { + throw new Error(`${path} must be non-empty`); + } + if (id.includes(",")) { + throw new Error(`${path} must not contain commas`); + } +}; + +const migrateTenantSchemaData = (schema, tenantId = "unknown") => { + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error(`tenantSchema/${tenantId} must be an object`); + } + if (!hasOwn(schema, "resources")) { + return { schema, changed: false }; + } + if (!Array.isArray(schema.resources)) { + throw new Error(`tenantSchema/${tenantId}.resources must be an array`); + } + + let changed = false; + const seenIds = new Set(); + const resources = schema.resources.map((resource, index) => { + const path = `tenantSchema/${tenantId}.resources[${index}]`; + if (resource === null || typeof resource !== "object" || Array.isArray(resource)) { + throw new Error(`${path} must be an object`); + } + + const hasResourceId = hasOwn(resource, "resourceId"); + const hasRoomId = hasOwn(resource, "roomId"); + let resourceId; + + if (hasResourceId) { + resourceId = resource.resourceId; + validateId(resourceId, `${path}.resourceId`); + } + + if (hasRoomId) { + const roomId = resource.roomId; + if ( + (typeof roomId !== "string" && typeof roomId !== "number") || + (typeof roomId === "number" && !Number.isFinite(roomId)) + ) { + throw new Error(`${path}.roomId must be a string or finite number`); + } + + const legacyResourceId = String(roomId); + validateId(legacyResourceId, `${path}.roomId`); + if (hasResourceId && resourceId !== legacyResourceId) { + throw new Error( + `${path} has conflicting resourceId "${resourceId}" and roomId "${legacyResourceId}"` + ); + } + resourceId = legacyResourceId; + } + + if (!hasResourceId && !hasRoomId) { + throw new Error(`${path} must have resourceId or legacy roomId`); + } + if (seenIds.has(resourceId)) { + throw new Error( + `tenantSchema/${tenantId}.resources has duplicate resourceId "${resourceId}"` + ); + } + seenIds.add(resourceId); + + if (!hasRoomId) { + return resource; + } + + const { roomId: _legacyRoomId, ...withoutRoomId } = resource; + changed = true; + return { ...withoutRoomId, resourceId }; + }); + + return { + schema: changed ? { ...schema, resources } : schema, + changed, + }; +}; + +const getTenantDocuments = async (db, tenant) => { + if (tenant) { + const snapshot = await db.collection(TENANT_SCHEMA_COLLECTION).doc(tenant).get(); + if (!snapshot.exists) { + throw new Error(`No tenant schema found for "${tenant}"`); + } + return [{ id: tenant, data: () => snapshot.data() }]; + } + + const snapshot = await db.collection(TENANT_SCHEMA_COLLECTION).get(); + return snapshot.docs; +}; + +const migrateTenantResourceIds = async ( + db, + { dryRun = false, tenant } = {} +) => { + const documents = await getTenantDocuments(db, tenant); + + // Preflight every selected document so validation failures cannot cause a + // partially applied migration. + const migrations = documents.map((doc) => { + const existingSchema = doc.data(); + return { + tenantId: doc.id, + existingSchema, + ...migrateTenantSchemaData(existingSchema, doc.id), + }; + }); + const changedMigrations = migrations.filter((migration) => migration.changed); + + if (!dryRun) { + for (const migration of changedMigrations) { + await backupTenantSchemaDocument( + db, + migration.tenantId, + migration.existingSchema, + BACKUP_TYPE + ); + await db + .collection(TENANT_SCHEMA_COLLECTION) + .doc(migration.tenantId) + .set(migration.schema, { merge: false }); + } + } + + return { + dryRun, + scanned: migrations.length, + changed: changedMigrations.length, + unchanged: migrations.length - changedMigrations.length, + tenants: changedMigrations.map((migration) => migration.tenantId), + }; +}; + +const parseArgs = (args = process.argv.slice(2)) => { + const options = { dryRun: false, database: "development" }; + for (let index = 0; index < args.length; index++) { + switch (args[index]) { + case "--dry-run": + options.dryRun = true; + break; + case "--database": + options.database = args[++index]; + break; + case "--tenant": + options.tenant = args[++index]; + break; + case "--help": + options.help = true; + break; + default: + throw new Error(`Unknown argument: ${args[index]}`); + } + } + if (!DATABASES[options.database]) { + throw new Error( + `Invalid database "${options.database}". Expected one of: ${Object.keys(DATABASES).join(", ")}` + ); + } + return options; +}; + +const initializeDb = (databaseId) => { + if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"), + }), + }); + } + const db = admin.firestore(); + if (databaseId !== "default") { + db.settings({ databaseId }); + } + return db; +}; + +const main = async () => { + const options = parseArgs(); + if (options.help) { + console.log(`Usage: node scripts/migrateTenantResourceIds.js [options] + +Options: + --dry-run Validate and report without writing + --database development, staging, or production + --tenant Migrate one tenant instead of the whole collection + --help Show this help message`); + return; + } + + const result = await migrateTenantResourceIds( + initializeDb(DATABASES[options.database]), + options + ); + console.log( + `${options.dryRun ? "[DRY RUN] " : ""}Scanned ${result.scanned}; ` + + `${result.changed} ${options.dryRun ? "would change" : "changed"}; ` + + `${result.unchanged} unchanged.` + ); + if (result.tenants.length > 0) { + console.log(`Tenants: ${result.tenants.join(", ")}`); + } +}; + +if (require.main === module) { + main() + .catch((error) => { + console.error(`Migration failed: ${error.message}`); + process.exitCode = 1; + }) + .finally(async () => { + for (const app of admin.apps) { + if (app) { + await app.delete(); + } + } + }); +} + +module.exports = { + BACKUP_TYPE, + DATABASES, + migrateTenantSchemaData, + migrateTenantResourceIds, + parseArgs, +}; diff --git a/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts b/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts index e38536900..300a48c35 100644 --- a/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts +++ b/booking-app/tests/unit/coerce-tenant-schema.unit.test.ts @@ -34,3 +34,68 @@ describe("coerceTenantSchema — timeSensitiveRequestWarning", () => { expect(c.calendarConfig?.timeSensitiveRequestWarning?.hours).toBe(99); }); }); + +describe("coerceTenantSchema — resources", () => { + it.each([ + ["number", 202, "202"], + ["string", "studio-a", "studio-a"], + ])("coerces a legacy %s roomId to resourceId", (_, roomId, resourceId) => { + const resource = { + roomId, + name: "Studio", + capacity: 12, + customField: { keep: true }, + }; + + const coerced = coerceTenantSchema({ resources: [resource] }, "mc"); + + expect(coerced.resources[0]).toEqual({ + resourceId, + name: "Studio", + capacity: 12, + customField: { keep: true }, + }); + expect(coerced.resources[0]).not.toHaveProperty("roomId"); + }); + + it("keeps a canonical resourceId and removes a matching legacy roomId", () => { + const coerced = coerceTenantSchema( + { + resources: [ + { + resourceId: "canonical-id", + roomId: "canonical-id", + name: "Canonical resource", + }, + ], + }, + "mc", + ); + + expect(coerced.resources[0].resourceId).toBe("canonical-id"); + expect(coerced.resources[0]).not.toHaveProperty("roomId"); + }); + + it("rejects conflicting legacy and canonical IDs", () => { + expect(() => + coerceTenantSchema( + { resources: [{ resourceId: "studio-a", roomId: 202 }] }, + "mc", + ), + ).toThrow("conflicting resourceId and roomId"); + }); + + it("rejects duplicate canonical IDs", () => { + expect(() => + coerceTenantSchema( + { + resources: [ + { resourceId: "studio-a" }, + { roomId: "studio-a" }, + ], + }, + "mc", + ), + ).toThrow('duplicate resourceId "studio-a"'); + }); +}); diff --git a/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts b/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts new file mode 100644 index 000000000..c6a05cfe5 --- /dev/null +++ b/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts @@ -0,0 +1,175 @@ +import { createRequire } from "module"; +import { describe, expect, it } from "vitest"; + +type DocumentData = Record; + +class MockFirestoreDb { + private collections = new Map>(); + readonly writes: Array<{ + collection: string; + id: string; + options: Record | undefined; + }> = []; + + constructor(seed: Record>) { + for (const [name, documents] of Object.entries(seed)) { + this.collections.set( + name, + new Map( + documents.map(({ id, data }) => [id, structuredClone(data)]), + ), + ); + } + } + + private getCollection(name: string) { + if (!this.collections.has(name)) { + this.collections.set(name, new Map()); + } + return this.collections.get(name)!; + } + + collection(name: string) { + return { + get: async () => ({ + docs: Array.from(this.getCollection(name), ([id, data]) => ({ + id, + data: () => structuredClone(data), + })), + }), + doc: (id: string) => ({ + get: async () => { + const data = this.getCollection(name).get(id); + return { + exists: data !== undefined, + data: () => structuredClone(data), + }; + }, + set: async (data: DocumentData, options?: Record) => { + this.writes.push({ collection: name, id, options }); + this.getCollection(name).set(id, structuredClone(data)); + }, + }), + }; + } + + read(name: string, id: string) { + return structuredClone(this.getCollection(name).get(id)); + } + + readCollection(name: string) { + return Array.from(this.getCollection(name), ([id, data]) => ({ + id, + data: structuredClone(data), + })); + } +} + +const requireModule = createRequire(import.meta.url); +const { migrateTenantSchemaData, migrateTenantResourceIds } = requireModule( + "../../scripts/migrateTenantResourceIds.js", +) as { + migrateTenantSchemaData: ( + schema: DocumentData, + tenantId?: string, + ) => { schema: DocumentData; changed: boolean }; + migrateTenantResourceIds: ( + db: MockFirestoreDb, + options?: { dryRun?: boolean; tenant?: string }, + ) => Promise; +}; + +describe("scripts/migrateTenantResourceIds", () => { + it("converts legacy numeric/string roomIds, removes roomId, and is idempotent", () => { + const existing = { + tenant: "mc", + resources: [ + { roomId: 101, name: "One" }, + { roomId: "room-two", name: "Two" }, + { resourceId: "canonical", name: "Three" }, + { resourceId: "same", roomId: "same", name: "Four" }, + ], + }; + + const first = migrateTenantSchemaData(existing, "mc"); + expect(first.changed).toBe(true); + expect(first.schema.resources).toEqual([ + { resourceId: "101", name: "One" }, + { resourceId: "room-two", name: "Two" }, + { resourceId: "canonical", name: "Three" }, + { resourceId: "same", name: "Four" }, + ]); + + const second = migrateTenantSchemaData(first.schema, "mc"); + expect(second).toEqual({ schema: first.schema, changed: false }); + }); + + it.each([ + [ + "conflicting IDs", + [{ resourceId: "new", roomId: "old" }], + /conflicting resourceId/, + ], + ["empty IDs", [{ roomId: " " }], /must be non-empty/], + ["comma-containing IDs", [{ resourceId: "one,two" }], /must not contain commas/], + [ + "duplicate canonical IDs", + [{ roomId: 1 }, { resourceId: "1" }], + /duplicate resourceId "1"/, + ], + ])("rejects %s", (_label, resources, message) => { + expect(() => + migrateTenantSchemaData({ resources }, "mc"), + ).toThrow(message); + }); + + it("backs up and replacement-writes only changed schemas", async () => { + const db = new MockFirestoreDb({ + tenantSchema: [ + { id: "mc", data: { resources: [{ roomId: 1, name: "One" }] } }, + { id: "itp", data: { resources: [{ resourceId: "existing" }] } }, + ], + }); + + const result = await migrateTenantResourceIds(db); + + expect(result).toMatchObject({ scanned: 2, changed: 1, unchanged: 1 }); + expect(db.read("tenantSchema", "mc")).toEqual({ + resources: [{ resourceId: "1", name: "One" }], + }); + expect(db.readCollection("tenantSchemaBackup")).toHaveLength(1); + expect(db.readCollection("tenantSchemaBackup")[0].data).toEqual({ + resources: [{ roomId: 1, name: "One" }], + }); + expect( + db.writes.find( + (write) => write.collection === "tenantSchema" && write.id === "mc", + )?.options, + ).toEqual({ merge: false }); + expect( + db.writes.some( + (write) => write.collection === "tenantSchema" && write.id === "itp", + ), + ).toBe(false); + }); + + it("performs no writes in dry-run mode or when preflight validation fails", async () => { + const dryRunDb = new MockFirestoreDb({ + tenantSchema: [{ id: "mc", data: { resources: [{ roomId: 1 }] } }], + }); + const result = await migrateTenantResourceIds(dryRunDb, { dryRun: true }); + expect(result).toMatchObject({ dryRun: true, changed: 1 }); + expect(dryRunDb.writes).toHaveLength(0); + + const invalidDb = new MockFirestoreDb({ + tenantSchema: [ + { id: "mc", data: { resources: [{ roomId: 1 }] } }, + { id: "itp", data: { resources: [{ roomId: "bad,id" }] } }, + ], + }); + await expect(migrateTenantResourceIds(invalidDb)).rejects.toThrow( + /must not contain commas/, + ); + expect(invalidDb.writes).toHaveLength(0); + }); +}); diff --git a/booking-app/tests/unit/request-limits.unit.test.ts b/booking-app/tests/unit/request-limits.unit.test.ts index b4b48f922..67e690d84 100644 --- a/booking-app/tests/unit/request-limits.unit.test.ts +++ b/booking-app/tests/unit/request-limits.unit.test.ts @@ -55,7 +55,10 @@ vi.mock("xstate", () => ({ })); vi.mock("./shared", () => ({ extractTenantFromRequest: () => "mc", - getAffiliationDisplayValues: () => ({ departmentDisplay: "", schoolDisplay: "" }), + getAffiliationDisplayValues: () => ({ + departmentDisplay: "", + schoolDisplay: "", + }), getOtherDisplayFields: () => ({}), })); @@ -82,7 +85,7 @@ describe("Request limits enforcement (POST /api/bookings)", () => { tenant: "mc", resources: [ { - roomId: 1201, + resourceId: "room-1201", name: "Room 1201", requestLimits: { perSemester: { student: 1 }, @@ -95,9 +98,11 @@ describe("Request limits enforcement (POST /api/bookings)", () => { // Active booking in-window for same resource { id: "b1", - roomIds: [1201], + roomIds: ["room-1201"], role: "Student", - requestedAt: { toMillis: () => new Date("2026-04-13T10:00:00Z").getTime() }, + requestedAt: { + toMillis: () => new Date("2026-04-13T10:00:00Z").getTime(), + }, canceledAt: null, declinedAt: null, }, @@ -109,7 +114,9 @@ describe("Request limits enforcement (POST /api/bookings)", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "student@nyu.edu", - selectedRooms: [{ roomId: 1201, calendarId: "cal-1201", name: "Room 1201" }], + selectedRooms: [ + { roomId: "room-1201", calendarId: "cal-1201", name: "Room 1201" }, + ], bookingCalendarInfo: { startStr: "2026-04-13T14:00:00Z", endStr: "2026-04-13T15:00:00Z", @@ -133,13 +140,86 @@ describe("Request limits enforcement (POST /api/bookings)", () => { vi.useRealTimers(); }); + it("matches legacy numeric room IDs after coercing them to strings", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-13T12:00:00Z")); + + mockServerGetDocumentById.mockResolvedValue({ + tenant: "mc", + resources: [ + { + roomId: 1201, + name: "Legacy Room 1201", + requestLimits: { + perDay: { student: 1 }, + }, + }, + ], + }); + + mockServerFetchAllDataFromCollection.mockResolvedValue([ + { + id: "b1", + roomIds: [1201], + role: "Student", + requestedAt: { + toMillis: () => new Date("2026-04-13T10:00:00Z").getTime(), + }, + }, + ]); + + const { POST } = await import("@/app/api/bookings/route"); + const req = new Request("http://localhost:3000/api/bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "student@nyu.edu", + selectedRooms: [ + { roomId: 1201, calendarId: "cal-1201", name: "Legacy Room 1201" }, + ], + bookingCalendarInfo: { + startStr: "2026-04-13T14:00:00Z", + endStr: "2026-04-13T15:00:00Z", + }, + data: { role: "Student", title: "Test", department: "ITP" }, + isAutoApproval: false, + }), + }) as any; + + const res = await POST(req); + + expect(res.status).toBe(429); + expect(await res.json()).toMatchObject({ + error: expect.stringContaining("Legacy Room 1201"), + }); + + vi.useRealTimers(); + }); + + it("parses opaque and legacy numeric booking resource IDs as strings", async () => { + const { parseRoomIdsFromBooking } = + await import("@/lib/bookingRequestLimits"); + + expect( + parseRoomIdsFromBooking({ roomIds: ["room-a", 1201, null] }), + ).toEqual(["room-a", "1201"]); + expect(parseRoomIdsFromBooking({ roomId: "room-a, 1201" })).toEqual([ + "room-a", + "1201", + ]); + }); + it("uses 4-month semester windows in America/New_York (May 1 starts a new window)", async () => { const { getNewYorkWindowForPeriod, REQUEST_LIMITS_TIME_ZONE } = await import("@/lib/bookingRequestLimits"); const { toDate } = await import("date-fns-tz"); const tz = REQUEST_LIMITS_TIME_ZONE; const now = toDate("2026-05-01T12:00:00.000", { timeZone: tz }); - const { start, end } = getNewYorkWindowForPeriod(now, "perSemester", undefined); + const { start, end } = getNewYorkWindowForPeriod( + now, + "perSemester", + undefined, + ); expect(start.getTime()).toBe( toDate("2026-05-01T00:00:00.000", { timeZone: tz }).getTime(), @@ -157,7 +237,7 @@ describe("Request limits enforcement (POST /api/bookings)", () => { tenant: "mc", resources: [ { - roomId: 1201, + resourceId: "room-1201", name: "Room 1201", requestLimits: { // perDay limit forces the window to be the current calendar day, @@ -177,7 +257,7 @@ describe("Request limits enforcement (POST /api/bookings)", () => { body: JSON.stringify({ email: "student@nyu.edu", selectedRooms: [ - { roomId: 1201, calendarId: "cal-1201", name: "Room 1201" }, + { roomId: "room-1201", calendarId: "cal-1201", name: "Room 1201" }, ], bookingCalendarInfo: { startStr: "2026-04-13T14:00:00Z", @@ -190,9 +270,10 @@ describe("Request limits enforcement (POST /api/bookings)", () => { await POST(req); - const bookingQueryCall = mockServerFetchAllDataFromCollection.mock.calls.find( - (call) => call[0] === "bookings", - ); + const bookingQueryCall = + mockServerFetchAllDataFromCollection.mock.calls.find( + (call) => call[0] === "bookings", + ); expect(bookingQueryCall).toBeDefined(); const constraints = bookingQueryCall![1] as Array<{ field: string; @@ -230,4 +311,3 @@ describe("Request limits enforcement (POST /api/bookings)", () => { ); }); }); - diff --git a/booking-app/tests/unit/schema-editor-resource.unit.test.tsx b/booking-app/tests/unit/schema-editor-resource.unit.test.tsx new file mode 100644 index 000000000..038cf3798 --- /dev/null +++ b/booking-app/tests/unit/schema-editor-resource.unit.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { defaultResource } from "../../components/src/client/routes/components/schemaTypes"; +import { ResourceEditor } from "../../components/src/client/routes/super/schemaEditor"; + +describe("SchemaEditor ResourceEditor", () => { + it("edits Resource ID as text without numeric coercion", () => { + const onUpdate = vi.fn(); + + render( + , + ); + + const input = screen.getByLabelText("Resource ID"); + expect(input).not.toHaveAttribute("type", "number"); + + fireEvent.change(input, { target: { value: "studio-b2" } }); + + expect(onUpdate).toHaveBeenCalledWith(0, { + ...defaultResource, + resourceId: "studio-b2", + }); + }); +}); diff --git a/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts b/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts index f21d554a7..7ca1f46b5 100644 --- a/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts +++ b/booking-app/tests/unit/schemaDefaults.addOnly.unit.test.ts @@ -58,6 +58,8 @@ describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { // Never delete extra keys (top-level and within resource items) expect(merged.someExtraTopLevelKey).toEqual({ keepMe: true }); expect(merged.resources[0].extraResourceKey).toBe("keep-me"); + expect(merged.resources[0].resourceId).toBe("999"); + expect(merged.resources[0]).not.toHaveProperty("roomId"); // Still adds missing template defaults (spot-check a known required field) expect(merged.tenant.contextLabels).toBeTruthy(); @@ -115,5 +117,7 @@ describe("scripts/schemaDefaults mergeSchemaDefaults (add-only)", () => { expect(merged.resources[0].training.infoUrl).toBe( "https://info.example/abc", ); + expect(merged.resources[0].resourceId).toBe("1234"); + expect(merged.resources[0]).not.toHaveProperty("roomId"); }); }); From 082e101b4d493e953cb6129bfb1e0e6c3066913c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 04:32:46 +0000 Subject: [PATCH 27/51] ci: replace npm install --force with npm ci to fix ETXTBSY race condition --- .github/workflows/unit-tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 975d1f4e1..9bb683985 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -24,9 +24,7 @@ jobs: - name: Install dependencies run: | cd booking-app - # Clean install to avoid optional dependencies issues - rm -rf node_modules package-lock.json - npm install --force + npm ci - name: Run unit tests env: From 3f098280afe1d8bda91bf553ae1e81de0698f9c0 Mon Sep 17 00:00:00 2001 From: n3xta Date: Mon, 15 Jun 2026 12:46:13 -0400 Subject: [PATCH 28/51] fix(schema): normalize resource IDs in migration --- booking-app/components/src/server/db.ts | 10 ++- .../scripts/migrateTenantResourceIds.js | 33 ++++---- .../migrate-tenant-resource-ids.unit.test.ts | 5 ++ .../unit/update-operation-hours.unit.test.ts | 78 +++++++++++++++++++ 4 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 booking-app/tests/unit/update-operation-hours.unit.test.ts diff --git a/booking-app/components/src/server/db.ts b/booking-app/components/src/server/db.ts index 5f413cd17..ba1e20820 100644 --- a/booking-app/components/src/server/db.ts +++ b/booking-app/components/src/server/db.ts @@ -813,15 +813,17 @@ export const updateOperationHours = async ( open: number, close: number, isClosed: boolean, - roomId?: string, + roomId?: string | number, ) => { const docs = await clientFetchAllDataFromCollection< OperationHours & { id: string } >(TableNames.OPERATION_HOURS); + const normalizedRoomId = + roomId === undefined || roomId === null ? undefined : String(roomId); const match = docs.find((x) => { - if (roomId) { - return x.day === day && x.roomId === roomId; + if (normalizedRoomId) { + return x.day === day && String(x.roomId) === normalizedRoomId; } return x.day === day; }); @@ -835,7 +837,7 @@ export const updateOperationHours = async ( isClosed, }); } else { - const r = roomId ? { roomId } : {}; + const r = normalizedRoomId ? { roomId: normalizedRoomId } : {}; clientSaveDataToFirestore(TableNames.OPERATION_HOURS, { day: day.toString(), open, diff --git a/booking-app/scripts/migrateTenantResourceIds.js b/booking-app/scripts/migrateTenantResourceIds.js index 639d03ad5..67fb0225f 100644 --- a/booking-app/scripts/migrateTenantResourceIds.js +++ b/booking-app/scripts/migrateTenantResourceIds.js @@ -16,16 +16,21 @@ const DATABASES = { const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); -const validateId = (id, path) => { - if (typeof id !== "string") { - throw new Error(`${path} must be a string`); +const normalizeId = (id, path) => { + if ( + (typeof id !== "string" && typeof id !== "number") || + (typeof id === "number" && !Number.isFinite(id)) + ) { + throw new Error(`${path} must be a string or finite number`); } - if (id.trim().length === 0) { + const normalizedId = String(id); + if (normalizedId.trim().length === 0) { throw new Error(`${path} must be non-empty`); } - if (id.includes(",")) { + if (normalizedId.includes(",")) { throw new Error(`${path} must not contain commas`); } + return normalizedId; }; const migrateTenantSchemaData = (schema, tenantId = "unknown") => { @@ -50,23 +55,15 @@ const migrateTenantSchemaData = (schema, tenantId = "unknown") => { const hasResourceId = hasOwn(resource, "resourceId"); const hasRoomId = hasOwn(resource, "roomId"); let resourceId; + let resourceIdChanged = false; if (hasResourceId) { - resourceId = resource.resourceId; - validateId(resourceId, `${path}.resourceId`); + resourceId = normalizeId(resource.resourceId, `${path}.resourceId`); + resourceIdChanged = resource.resourceId !== resourceId; } if (hasRoomId) { - const roomId = resource.roomId; - if ( - (typeof roomId !== "string" && typeof roomId !== "number") || - (typeof roomId === "number" && !Number.isFinite(roomId)) - ) { - throw new Error(`${path}.roomId must be a string or finite number`); - } - - const legacyResourceId = String(roomId); - validateId(legacyResourceId, `${path}.roomId`); + const legacyResourceId = normalizeId(resource.roomId, `${path}.roomId`); if (hasResourceId && resourceId !== legacyResourceId) { throw new Error( `${path} has conflicting resourceId "${resourceId}" and roomId "${legacyResourceId}"` @@ -85,7 +82,7 @@ const migrateTenantSchemaData = (schema, tenantId = "unknown") => { } seenIds.add(resourceId); - if (!hasRoomId) { + if (!hasRoomId && !resourceIdChanged) { return resource; } diff --git a/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts b/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts index c6a05cfe5..92e5f5cdc 100644 --- a/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts +++ b/booking-app/tests/unit/migrate-tenant-resource-ids.unit.test.ts @@ -88,6 +88,8 @@ describe("scripts/migrateTenantResourceIds", () => { { roomId: "room-two", name: "Two" }, { resourceId: "canonical", name: "Three" }, { resourceId: "same", roomId: "same", name: "Four" }, + { resourceId: 202, name: "Five" }, + { resourceId: 303, roomId: 303, name: "Six" }, ], }; @@ -98,6 +100,8 @@ describe("scripts/migrateTenantResourceIds", () => { { resourceId: "room-two", name: "Two" }, { resourceId: "canonical", name: "Three" }, { resourceId: "same", name: "Four" }, + { resourceId: "202", name: "Five" }, + { resourceId: "303", name: "Six" }, ]); const second = migrateTenantSchemaData(first.schema, "mc"); @@ -112,6 +116,7 @@ describe("scripts/migrateTenantResourceIds", () => { ], ["empty IDs", [{ roomId: " " }], /must be non-empty/], ["comma-containing IDs", [{ resourceId: "one,two" }], /must not contain commas/], + ["non-finite resource IDs", [{ resourceId: Number.NaN }], /must be a string or finite number/], [ "duplicate canonical IDs", [{ roomId: 1 }, { resourceId: "1" }], diff --git a/booking-app/tests/unit/update-operation-hours.unit.test.ts b/booking-app/tests/unit/update-operation-hours.unit.test.ts new file mode 100644 index 000000000..c14440a12 --- /dev/null +++ b/booking-app/tests/unit/update-operation-hours.unit.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + clientFetchAllDataFromCollection: vi.fn(), + clientSaveDataToFirestore: vi.fn(), + clientUpdateDataInFirestore: vi.fn(), +})); + +vi.mock("@/lib/firebase/firebase", () => ({ + clientFetchAllDataFromCollection: mocks.clientFetchAllDataFromCollection, + clientGetDataByCalendarEventId: vi.fn(), + clientSaveDataToFirestore: mocks.clientSaveDataToFirestore, + clientUpdateDataInFirestore: mocks.clientUpdateDataInFirestore, + getPaginatedData: vi.fn(), +})); + +vi.mock("@/lib/firebase/client/clientDb", () => ({ + clientUpdateDataByCalendarEventId: vi.fn(), +})); + +vi.mock("@/components/src/server/ui", () => ({ + getBookingToolDeployUrl: vi.fn(), +})); + +import { updateOperationHours } from "@/components/src/server/db"; +import { TableNames } from "@/components/src/policy"; +import { Days } from "@/components/src/types"; + +describe("updateOperationHours", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("matches legacy numeric operation-hours roomId values by normalized string", async () => { + mocks.clientFetchAllDataFromCollection.mockResolvedValue([ + { + id: "existing-id", + day: Days.Monday, + open: 9, + close: 17, + isClosed: false, + roomId: 101, + }, + ]); + + await updateOperationHours(Days.Monday, 10, 18, false, "101"); + + expect(mocks.clientUpdateDataInFirestore).toHaveBeenCalledWith( + TableNames.OPERATION_HOURS, + "existing-id", + { + day: Days.Monday, + open: 10, + close: 18, + isClosed: false, + roomId: 101, + }, + ); + expect(mocks.clientSaveDataToFirestore).not.toHaveBeenCalled(); + }); + + it("stores new operation-hours roomId values as strings", async () => { + mocks.clientFetchAllDataFromCollection.mockResolvedValue([]); + + await updateOperationHours(Days.Tuesday, 8, 16, false, 202); + + expect(mocks.clientSaveDataToFirestore).toHaveBeenCalledWith( + TableNames.OPERATION_HOURS, + { + day: Days.Tuesday, + open: 8, + close: 16, + isClosed: false, + roomId: "202", + }, + ); + }); +}); From 32a31e68e37997aac44d26cb09fcec56528b781e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:20:26 +0000 Subject: [PATCH 29/51] chore(deps): bump the npm_and_yarn group across 2 directories with 3 updates Bumps the npm_and_yarn group with 3 updates in the /booking-app directory: [@grpc/grpc-js](https://github.com/grpc/grpc-node), [esbuild](https://github.com/evanw/esbuild) and [ws](https://github.com/websockets/ws). Bumps the npm_and_yarn group with 1 update in the /booking-app/components/src/test directory: [ws](https://github.com/websockets/ws). Updates `@grpc/grpc-js` from 1.9.15 to 1.9.16 - [Release notes](https://github.com/grpc/grpc-node/releases) - [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.9.15...@grpc/grpc-js@1.9.16) Updates `@grpc/grpc-js` from 1.14.3 to 1.14.4 - [Release notes](https://github.com/grpc/grpc-node/releases) - [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.9.15...@grpc/grpc-js@1.9.16) Removes `esbuild` Updates `ws` from 8.20.1 to 8.21.0 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0) Updates `ws` from 8.20.1 to 8.21.0 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0) --- updated-dependencies: - dependency-name: "@grpc/grpc-js" dependency-version: 1.9.16 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: "@grpc/grpc-js" dependency-version: 1.14.4 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: esbuild dependency-version: dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: ws dependency-version: 8.21.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: ws dependency-version: 8.21.0 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- .../components/src/test/package-lock.json | 6 +- booking-app/package-lock.json | 1874 +++++++---------- booking-app/package.json | 4 +- 3 files changed, 728 insertions(+), 1156 deletions(-) diff --git a/booking-app/components/src/test/package-lock.json b/booking-app/components/src/test/package-lock.json index c4093c1b5..de723d113 100644 --- a/booking-app/components/src/test/package-lock.json +++ b/booking-app/components/src/test/package-lock.json @@ -1293,9 +1293,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/booking-app/package-lock.json b/booking-app/package-lock.json index 703fedfbe..0747a5dd6 100644 --- a/booking-app/package-lock.json +++ b/booking-app/package-lock.json @@ -60,7 +60,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5.1.34", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.0.2", "@xstate/test": "^1.0.0-beta.5", "dotenv": "^16.4.5", "eslint": "^9.0.0", @@ -81,7 +81,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.0", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.6" + "vitest": "^4.1.9" } }, "node_modules/@adobe/css-tools": { @@ -1228,34 +1228,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-react-pure-annotations": { "version": "7.27.1", "license": "MIT", @@ -1762,21 +1734,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -1784,9 +1756,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1860,503 +1832,61 @@ } } }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "license": "MIT" - }, - "node_modules/@emotion/styled": { - "version": "11.14.1", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/is-prop-valid": "^1.3.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2" - }, - "peerDependencies": { - "@emotion/react": "^11.0.0-rc.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@emotion/serialize": { + "version": "1.3.3", "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "license": "MIT" }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@emotion/styled": { + "version": "11.14.1", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "license": "MIT" }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "peerDependencies": { + "react": ">=16.8.0" } }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -4097,9 +3627,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.8", @@ -5953,6 +5483,16 @@ "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -6068,29 +5608,10 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -6099,12 +5620,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -6113,12 +5637,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -6127,26 +5654,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -6155,26 +5671,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -6183,26 +5688,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -6211,54 +5705,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -6267,40 +5739,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -6309,12 +5756,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -6323,12 +5773,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -6337,26 +5790,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -6365,54 +5807,70 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -6421,7 +5879,17 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -6435,6 +5903,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@streamparser/json": { "version": "0.0.6", "license": "MIT" @@ -6788,9 +6263,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -6804,43 +6279,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/caseless": { "version": "0.12.5", "license": "MIT", @@ -8042,58 +7480,66 @@ ] }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -8105,42 +7551,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -8148,33 +7594,37 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@xstate/graph": { "version": "2.0.0-beta.6", "dev": true, @@ -8810,14 +8260,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/cac": { - "version": "6.7.14", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.8", "dev": true, @@ -8910,18 +8352,11 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -8950,16 +8385,6 @@ "node": ">=16" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "license": "MIT", @@ -9420,16 +8845,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -9500,8 +8915,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -9784,7 +9199,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -9838,48 +9255,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, "node_modules/escalade": { "version": "3.2.0", "license": "MIT", @@ -11345,7 +10720,9 @@ } }, "node_modules/google-gax/node_modules/@grpc/grpc-js": { - "version": "1.14.3", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -12388,64 +11765,325 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/jwks-rsa": { - "version": "3.2.2", - "license": "MIT", - "dependencies": { - "@types/jsonwebtoken": "^9.0.4", - "debug": "^4.3.4", - "jose": "^4.15.4", - "limiter": "^1.1.5", - "lru-memoizer": "^2.2.0" - }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jws": { - "version": "4.0.1", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "CC0-1.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/language-tags": { - "version": "1.0.9", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -12581,13 +12219,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lower-case": { "version": "2.0.2", "license": "MIT", @@ -12932,9 +12563,9 @@ } }, "node_modules/newrelic/node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -13328,6 +12959,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "license": "ISC", @@ -13489,19 +13134,11 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -13984,14 +13621,6 @@ "version": "19.2.3", "license": "MIT" }, - "node_modules/react-refresh": { - "version": "0.17.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "license": "BSD-3-Clause", @@ -14235,49 +13864,38 @@ "integrity": "sha512-GCOqTzUsTHF7nrqcgtNGAFotXztLgiePpIDpyWZ7R5I02tmfJWV+/yuJc//Hlsd8G+WzI1t/dc2y/w2imDZdog==", "license": "MIT" }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rrweb-cssom": { @@ -14673,7 +14291,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -14869,26 +14489,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", @@ -15269,16 +14869,23 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -15314,28 +14921,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -15974,18 +15563,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -16001,9 +15589,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -16016,13 +15605,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -16048,27 +15640,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-tsconfig-paths": { "version": "5.1.4", "dev": true, @@ -16087,22 +15658,6 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "dev": true, @@ -16129,65 +15684,79 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -16198,6 +15767,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -16442,9 +16014,9 @@ "optional": true }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/booking-app/package.json b/booking-app/package.json index 5698db7bd..82137ad40 100644 --- a/booking-app/package.json +++ b/booking-app/package.json @@ -75,7 +75,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5.1.34", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.0.2", "@xstate/test": "^1.0.0-beta.5", "dotenv": "^16.4.5", "eslint": "^9.0.0", @@ -96,7 +96,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.0", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.6" + "vitest": "^4.1.9" }, "overrides": { "eslint-config-airbnb-base": { From 25fc87644a726bcdbe4758dad1853ac9674c30fb Mon Sep 17 00:00:00 2001 From: pearmini Date: Sun, 21 Jun 2026 17:39:29 -0400 Subject: [PATCH 30/51] Fix tenant schema resouce id --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- booking-app/docs/SCHEMA_SYNC_GUIDE.md | 13 +++++++++++++ .../tests/unit/api-tenant-schema.unit.test.ts | 4 +++- .../tests/unit/schema-completeness.unit.test.ts | 16 +++++++++++++++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d27e012d0..cc90f25e4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ ## Checklist diff --git a/booking-app/docs/SCHEMA_SYNC_GUIDE.md b/booking-app/docs/SCHEMA_SYNC_GUIDE.md index 08f3c31b9..edd66bf82 100644 --- a/booking-app/docs/SCHEMA_SYNC_GUIDE.md +++ b/booking-app/docs/SCHEMA_SYNC_GUIDE.md @@ -56,6 +56,19 @@ resources: defineObjectArrayWithDefaults(defaultResource), This allows the merge process to automatically apply defaults to each item in the array. +### Resource identifiers (`resourceId`) + +The template defines each resource with a string **`resourceId`**. Legacy Firestore documents may still store numeric **`roomId`** values from before the refactor tracked in [#1506](https://github.com/ITPNYU/booking-app/issues/1506). + +When the sync script reads or merges a tenant schema, it coerces legacy `roomId` values into `resourceId` strings via `coerceTenantSchema`. Writing the merged `resources` array back replaces the stored array, so synced tenants pick up `resourceId` from the template. + +For a dedicated one-time migration (including backup and full document replace), use: + +```bash +npm run migrate:tenant-resource-ids:dry-run +npm run migrate:tenant-resource-ids +``` + ## Related: one-time nested shape migration To **rewrite** stored documents from the legacy flat field layout to the nested canonical shape (and drop stale top-level keys), use the Firestore migration script described in [TENANT_SCHEMA_FIRESTORE_MIGRATION.md](./TENANT_SCHEMA_FIRESTORE_MIGRATION.md). That is separate from this add-only sync. diff --git a/booking-app/tests/unit/api-tenant-schema.unit.test.ts b/booking-app/tests/unit/api-tenant-schema.unit.test.ts index f2a15f4a5..555eab317 100644 --- a/booking-app/tests/unit/api-tenant-schema.unit.test.ts +++ b/booking-app/tests/unit/api-tenant-schema.unit.test.ts @@ -66,7 +66,7 @@ const mockSchema = { tenantId: "mc", tenant: { name: "Media Commons", logo: "", nameForPolicy: "" }, form: { services: { showEquipment: true } }, - resources: [{ name: "Room 1", roomId: 1 }], + resources: [{ name: "Room 1", resourceId: "1" }], }; describe("GET /api/tenantSchema/[tenant]", () => { @@ -83,6 +83,8 @@ describe("GET /api/tenantSchema/[tenant]", () => { expect(status).toBe(200); expect(data.tenant.name).toBe("Media Commons"); expect(data.resources).toHaveLength(1); + expect(data.resources[0].resourceId).toBe("1"); + expect(data.resources[0]).not.toHaveProperty("roomId"); }); it("returns 404 when schema not found", async () => { diff --git a/booking-app/tests/unit/schema-completeness.unit.test.ts b/booking-app/tests/unit/schema-completeness.unit.test.ts index abd436d02..2097ae827 100644 --- a/booking-app/tests/unit/schema-completeness.unit.test.ts +++ b/booking-app/tests/unit/schema-completeness.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { defaultScheme } from "../../components/src/client/routes/components/SchemaProvider"; +import { + defaultResource, + defaultScheme, +} from "../../components/src/client/routes/components/SchemaProvider"; /** * This list must match every key in SchemaContextType (including optional fields). @@ -57,4 +60,15 @@ describe("Schema completeness", () => { `Add them to the list in schema-completeness.unit.test.ts: ${missingFromList.join(", ")}`, ).toEqual([]); }); + + it("defaultResource template uses resourceId string, not legacy roomId", () => { + expect(defaultResource).toHaveProperty("resourceId"); + expect(typeof defaultResource.resourceId).toBe("string"); + expect(defaultResource).not.toHaveProperty("roomId"); + + const itemDefaults = (defaultScheme.resources as { __defaults__?: typeof defaultResource }) + .__defaults__; + expect(itemDefaults?.resourceId).toBe(""); + expect(itemDefaults).not.toHaveProperty("roomId"); + }); }); From 88af89dd00f77cf2bd8581bb3ac29d728d4f3b71 Mon Sep 17 00:00:00 2001 From: pearmini Date: Sun, 21 Jun 2026 18:10:18 -0400 Subject: [PATCH 31/51] Fix reset service status on edit --- booking-app/app/api/bookings/edit/route.ts | 29 +++++++---- .../lib/stateMachines/mcBookingMachine.ts | 5 ++ .../lib/stateMachines/xstatePersistence.ts | 50 +++++++++++++------ ...-resubmission-resets-services.unit.test.ts | 21 +++++--- .../unit/mc-booking-machine.unit.test.ts | 27 ++++++++++ 5 files changed, 99 insertions(+), 33 deletions(-) diff --git a/booking-app/app/api/bookings/edit/route.ts b/booking-app/app/api/bookings/edit/route.ts index c1abc01d1..d94b1d1a8 100644 --- a/booking-app/app/api/bookings/edit/route.ts +++ b/booking-app/app/api/bookings/edit/route.ts @@ -343,16 +343,16 @@ export async function PUT(request: NextRequest) { // treat services as already decided (e.g. declined) and immediately // re-decline the edited request. // - // This is intentionally tenant-agnostic: clearing fields that don't exist - // is harmless, and it future-proofs tenants that add per-service approvals. - if (wasDeclined) { - updatedData.staffServiceApproved = null; - updatedData.equipmentServiceApproved = null; - updatedData.cateringServiceApproved = null; - updatedData.cleaningServiceApproved = null; - updatedData.securityServiceApproved = null; - updatedData.setupServiceApproved = null; - } + // Firestore fields are deleted (not nulled) after the booking update so + // restoreXStateFromFirestore does not rehydrate stale declined decisions. + const SERVICE_APPROVAL_FIELDS = [ + "staffServiceApproved", + "equipmentServiceApproved", + "cateringServiceApproved", + "cleaningServiceApproved", + "securityServiceApproved", + "setupServiceApproved", + ]; // If booking was declined, clear the declinedAt timestamp to ensure status shows as REQUESTED if (existingContents.declinedAt) { @@ -382,6 +382,15 @@ export async function PUT(request: NextRequest) { tenant, ); + if (wasDeclined) { + await serverDeleteFieldsByCalendarEventId( + TableNames.BOOKING, + newCalendarEventId, + SERVICE_APPROVAL_FIELDS, + tenant, + ); + } + // If booking was declined, trigger XState edit transition on the NEW calendarEventId // This moves the booking from DECLINED to REQUESTED state if (usesXState && wasDeclined) { diff --git a/booking-app/lib/stateMachines/mcBookingMachine.ts b/booking-app/lib/stateMachines/mcBookingMachine.ts index 4677d03c0..6dc3c0873 100644 --- a/booking-app/lib/stateMachines/mcBookingMachine.ts +++ b/booking-app/lib/stateMachines/mcBookingMachine.ts @@ -393,6 +393,9 @@ export const mcBookingMachine = setup({ // Service approval/decline actions generated from config ...createServiceActions(SERVICE_CONFIGS), // Auto-approve all requested services for pregame bookings + resetServiceDecisionsOnEdit: assign({ + servicesApproved: () => ({}), + }), approveAllPregameServices: assign({ servicesApproved: ({ context }) => { const approved: any = {}; @@ -746,6 +749,7 @@ export const mcBookingMachine = setup({ }, edit: { target: "Requested", + actions: "resetServiceDecisionsOnEdit", }, approve: { target: "Pre-approved", @@ -825,6 +829,7 @@ export const mcBookingMachine = setup({ }, edit: { target: "Requested", + actions: "resetServiceDecisionsOnEdit", }, }, after: { diff --git a/booking-app/lib/stateMachines/xstatePersistence.ts b/booking-app/lib/stateMachines/xstatePersistence.ts index 525138d0b..804fa111e 100644 --- a/booking-app/lib/stateMachines/xstatePersistence.ts +++ b/booking-app/lib/stateMachines/xstatePersistence.ts @@ -14,6 +14,37 @@ import { itpBookingMachine } from "./itpBookingMachine"; import { mcBookingMachine } from "./mcBookingMachine"; import type { PersistedXStateData } from "./xstateTypes"; +const SERVICE_APPROVAL_FIELD_MAP = { + staff: "staffServiceApproved", + equipment: "equipmentServiceApproved", + catering: "cateringServiceApproved", + cleaning: "cleaningServiceApproved", + security: "securityServiceApproved", + setup: "setupServiceApproved", +} as const; + +/** + * Build XState servicesApproved context from Firestore booking fields. + * Only explicit boolean decisions are included so cleared/null fields do not + * retain a prior declined state after edit/resubmission. + */ +export function getServicesApprovedFromBookingData( + bookingData: any, +): Record { + const servicesApproved: Record = {}; + + for (const [serviceKey, fieldName] of Object.entries( + SERVICE_APPROVAL_FIELD_MAP, + )) { + const value = bookingData?.[fieldName]; + if (typeof value === "boolean") { + servicesApproved[serviceKey] = value; + } + } + + return servicesApproved; +} + /** * Map booking status to XState state */ @@ -116,14 +147,7 @@ export async function createXStateDataFromBookingStatus( email: bookingData.email, isVip: bookingData.isVip || false, servicesRequested, - servicesApproved: { - staff: bookingData.staffServiceApproved, - equipment: bookingData.equipmentServiceApproved, - catering: bookingData.cateringServiceApproved, - cleaning: bookingData.cleaningServiceApproved, - security: bookingData.securityServiceApproved, - setup: bookingData.setupServiceApproved, - }, + servicesApproved: getServicesApprovedFromBookingData(bookingData), // Flag to indicate this XState was created from existing booking without prior xstateData _restoredFromStatus: true, } @@ -451,14 +475,8 @@ export async function restoreXStateFromFirestore( // Update MC-specific services data if (isMediaCommons(tenant)) { const currentServicesRequested = getMediaCommonsServices(bookingData); - const currentServicesApproved = { - staff: (bookingData as any).staffServiceApproved, - equipment: (bookingData as any).equipmentServiceApproved, - catering: (bookingData as any).cateringServiceApproved, - cleaning: (bookingData as any).cleaningServiceApproved, - security: (bookingData as any).securityServiceApproved, - setup: (bookingData as any).setupServiceApproved, - }; + const currentServicesApproved = + getServicesApprovedFromBookingData(bookingData); updatedSnapshot.context = { ...updatedSnapshot.context, diff --git a/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts b/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts index 38a1eff88..922d20088 100644 --- a/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts +++ b/booking-app/tests/unit/booking-edit-resubmission-resets-services.unit.test.ts @@ -155,14 +155,21 @@ describe("Edit resubmission resets declined services", () => { const updatedData = updateArgs[2]; expect(updatedData.calendarEventId).toBe("new-cal-456"); + expect(updatedData.staffServiceApproved).toBeUndefined(); - // Critical: reset service decisions - expect(updatedData.staffServiceApproved).toBeNull(); - expect(updatedData.equipmentServiceApproved).toBeNull(); - expect(updatedData.cateringServiceApproved).toBeNull(); - expect(updatedData.cleaningServiceApproved).toBeNull(); - expect(updatedData.securityServiceApproved).toBeNull(); - expect(updatedData.setupServiceApproved).toBeNull(); + expect(mockServerDeleteFieldsByCalendarEventId).toHaveBeenCalledWith( + "bookings", + "new-cal-456", + [ + "staffServiceApproved", + "equipmentServiceApproved", + "cateringServiceApproved", + "cleaningServiceApproved", + "securityServiceApproved", + "setupServiceApproved", + ], + "mc", + ); // Still runs XState edit transition for declined bookings expect(mockCallXStateTransitionAPI).toHaveBeenCalledWith( diff --git a/booking-app/tests/unit/mc-booking-machine.unit.test.ts b/booking-app/tests/unit/mc-booking-machine.unit.test.ts index 4a3103971..dd0825e06 100644 --- a/booking-app/tests/unit/mc-booking-machine.unit.test.ts +++ b/booking-app/tests/unit/mc-booking-machine.unit.test.ts @@ -474,6 +474,33 @@ describe("mcBookingMachine", () => { await waitForCondition(actor, (snapshot) => snapshot.matches("Declined")); }); + it("clears declined service decisions after edit and re-approves into Services Request", async () => { + const actor = createTestActor({ + selectedRooms: [{ roomId: 202 }], + servicesRequested: { staff: true }, + }); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Pre-approved")).toBe(true); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Services Request")).toBe(true); + + actor.send({ type: "declineStaff" }); + await waitForCondition(actor, (snapshot) => snapshot.matches("Declined")); + expect(actor.getSnapshot().context.servicesApproved?.staff).toBe(false); + + actor.send({ type: "edit" }); + expect(actor.getSnapshot().matches("Requested")).toBe(true); + expect(actor.getSnapshot().context.servicesApproved).toEqual({}); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Pre-approved")).toBe(true); + + actor.send({ type: "approve" }); + expect(actor.getSnapshot().matches("Services Request")).toBe(true); + }); + it("emits the official guideline state sequence for manual service approvals", async () => { const actor = createTestActor({ selectedRooms: [{ roomId: 202 }], // No autoApproval = disabled From 36b1874219f6103042b9c9b43b1e3b079a5cdb46 Mon Sep 17 00:00:00 2001 From: pearmini Date: Sun, 21 Jun 2026 21:30:03 -0400 Subject: [PATCH 32/51] Fix booking end time modification --- .../routes/admin/hooks/useExistingBooking.tsx | 5 +-- .../booking/components/BookingSelection.tsx | 8 ++--- .../components/CalendarVerticalResource.tsx | 14 ++++++--- .../components/src/client/utils/date.tsx | 6 +++- .../tests/unit/date-utils.unit.test.tsx | 31 +++++++++++++++++++ 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx index 954d66a6f..7ff329dfa 100644 --- a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx +++ b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx @@ -1,4 +1,5 @@ import { Department, Inputs, Role } from "@/components/src/types"; +import { toBookingCalendarStr } from "@/components/src/client/utils/date"; import { useContext } from "react"; import { BookingContext } from "../../booking/bookingProvider"; @@ -33,8 +34,8 @@ export default function useExistingBooking() { const start = booking.startDate.toDate(); const end = booking.endDate.toDate(); - const startStr = start.toISOString(); - const endStr = end.toISOString(); + const startStr = toBookingCalendarStr(start); + const endStr = toBookingCalendarStr(end); setBookingCalendarInfo({ start, end, diff --git a/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx b/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx index 3408a6908..a78e53963 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingSelection.tsx @@ -48,13 +48,13 @@ export default function BookingSelection() { Date: -

{new Date(bookingCalendarInfo.startStr).toLocaleDateString()}

+

{bookingCalendarInfo.start.toLocaleDateString()}

Time: -

{`${formatTimeAmPm( - new Date(bookingCalendarInfo.startStr), - )} - ${formatTimeAmPm(new Date(bookingCalendarInfo.endStr))}`}

+

{`${formatTimeAmPm(bookingCalendarInfo.start)} - ${formatTimeAmPm( + bookingCalendarInfo.end, + )}`}

diff --git a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx index bf4469a1c..48e99fb79 100644 --- a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx +++ b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx @@ -29,7 +29,7 @@ import { getBlackoutTimeRangeForDate } from "../../../../utils/blackoutUtils"; import { DatabaseContext } from "../../components/Provider"; import { BookingContext } from "../bookingProvider"; import { useBookingDateRestrictions } from "../hooks/useBookingDateRestrictions"; -import { TIMEZONE } from "../../../utils/date"; +import { TIMEZONE, toBookingCalendarStr } from "../../../utils/date"; import { DEFAULT_START_HOUR } from "../utils/getStartHour"; import { DEFAULT_SLOT_UNIT } from "../utils/getSlotUnit"; import { buildBlockPastTimes } from "../utils/buildBlockPastTimes"; @@ -316,11 +316,17 @@ export default function CalendarVerticalResource({ // if change event duration via dragging edges or drag event block to move const handleEventEdit = (info: EventResizeDoneArg | EventDropArg) => { - // Always allow modification of end time, even for past events + if (info.event.start == null || info.event.end == null) { + return; + } + + // Always allow modification of end time, even for past events. + // Normalize to Eastern local strings so resize/drag stays consistent with + // FullCalendar's timeZone and avoids UTC ISO strings corrupting start time. setBookingCalendarInfo({ - startStr: info.event.startStr, + startStr: toBookingCalendarStr(info.event.start), start: info.event.start, - endStr: info.event.endStr, + endStr: toBookingCalendarStr(info.event.end), end: info.event.end, allDay: false, jsEvent: info.jsEvent, diff --git a/booking-app/components/src/client/utils/date.tsx b/booking-app/components/src/client/utils/date.tsx index 0c5f9dfdc..9bc2726cc 100644 --- a/booking-app/components/src/client/utils/date.tsx +++ b/booking-app/components/src/client/utils/date.tsx @@ -1,11 +1,15 @@ import { Timestamp } from "firebase/firestore"; import { format } from "date-fns"; -import { toZonedTime } from "date-fns-tz"; +import { formatInTimeZone, toZonedTime } from "date-fns-tz"; import { DEFAULT_SLOT_UNIT } from "../routes/booking/utils/getSlotUnit"; // All times in the booking app are displayed in Eastern Time export const TIMEZONE = "America/New_York"; +/** FullCalendar `startStr` / `endStr` format: Eastern local time without offset. */ +export const toBookingCalendarStr = (date: Date): string => + formatInTimeZone(date, TIMEZONE, "yyyy-MM-dd'T'HH:mm:ss"); + export const formatDate = ( oldDate: | Date diff --git a/booking-app/tests/unit/date-utils.unit.test.tsx b/booking-app/tests/unit/date-utils.unit.test.tsx index 88dfd113a..e61ae09f2 100644 --- a/booking-app/tests/unit/date-utils.unit.test.tsx +++ b/booking-app/tests/unit/date-utils.unit.test.tsx @@ -1,6 +1,37 @@ import { describe, expect, it } from "vitest"; +import { + formatTimeAmPm, + toBookingCalendarStr, +} from "@/components/src/client/utils/date"; describe("Date Utils", () => { + describe("toBookingCalendarStr", () => { + it("formats 9:00 AM Eastern as local calendar string without UTC suffix", () => { + // 9:00 AM EDT on 2026-04-07 = 13:00 UTC + const start = new Date("2026-04-07T13:00:00.000Z"); + expect(toBookingCalendarStr(start)).toBe("2026-04-07T09:00:00"); + }); + + it("formats 9:00 PM Eastern as local calendar string", () => { + // 9:00 PM EDT on 2026-04-07 = 01:00 UTC next day + const end = new Date("2026-04-08T01:00:00.000Z"); + expect(toBookingCalendarStr(end)).toBe("2026-04-07T21:00:00"); + }); + + it("does not produce UTC ISO strings that shift start time on resize", () => { + const start = new Date("2026-04-07T13:00:00.000Z"); + const end = new Date("2026-04-08T01:00:00.000Z"); + + const startStr = toBookingCalendarStr(start); + const endStr = toBookingCalendarStr(end); + + expect(startStr).not.toContain("Z"); + expect(endStr).not.toContain("Z"); + expect(formatTimeAmPm(start)).toBe("9:00 AM"); + expect(formatTimeAmPm(end)).toBe("9:00 PM"); + }); + }); + describe("formatBookingTime", () => { const formatBookingTime = (dateStr: string) => { const date = new Date(dateStr); From 5d8d329b941d20baf78caed1813e61cf704a4a18 Mon Sep 17 00:00:00 2001 From: n3xta Date: Sun, 21 Jun 2026 23:15:49 -0400 Subject: [PATCH 33/51] fix(resource-approvers): address review feedback --- booking-app/app/api/approve/route.ts | 14 +- booking-app/components/src/server/admin.ts | 126 ++++++++++++------ booking-app/lib/firebase/firebase.ts | 67 ++++++++-- booking-app/lib/firebase/server/adminDb.ts | 56 +++++++- .../tests/unit/api-approve.unit.test.ts | 21 +-- .../resource-approvers-client.unit.test.ts | 21 ++- .../resource-approvers-server.unit.test.ts | 38 +++++- 7 files changed, 259 insertions(+), 84 deletions(-) diff --git a/booking-app/app/api/approve/route.ts b/booking-app/app/api/approve/route.ts index 8e3f3dc4f..3982f326d 100644 --- a/booking-app/app/api/approve/route.ts +++ b/booking-app/app/api/approve/route.ts @@ -15,7 +15,7 @@ import { requireSession } from "@/lib/api/requireSession"; import { serverGetDataByCalendarEventId, serverGetFinalApproverEmail, - serverListResourceApprovers, + serverListResourceApproversByEmail, } from "@/lib/firebase/server/adminDb"; import { executeXStateTransition } from "@/lib/stateMachines/xstateUtilsV5"; import { NextRequest, NextResponse } from "next/server"; @@ -77,14 +77,12 @@ async function authorizeApproval( const resourceIds = parseBookingResourceIds(booking.roomId); if (resourceIds.length === 0) return false; - const assignments = await serverListResourceApprovers(tenant); + const assignments = await serverListResourceApproversByEmail( + normalizedEmail, + tenant, + ); const assignedResourceIds = new Set( - assignments - .filter( - (assignment) => - assignment.email.trim().toLowerCase() === normalizedEmail, - ) - .map((assignment) => assignment.resourceId), + assignments.map((assignment) => assignment.resourceId), ); return resourceIds.every((resourceId) => assignedResourceIds.has(resourceId)); } diff --git a/booking-app/components/src/server/admin.ts b/booking-app/components/src/server/admin.ts index 86eacd38f..8aed095db 100644 --- a/booking-app/components/src/server/admin.ts +++ b/booking-app/components/src/server/admin.ts @@ -53,6 +53,37 @@ const resolveBookingResourceApproverEmails = async ( return fallback ? [fallback] : []; }; +const logEmailSendFailure = (context: string, error: unknown) => { + console.error(`[${context}] Failed to send email:`, error); +}; + +const sendEmailFanout = async ( + context: string, + senders: Array<() => Promise>, +): Promise>> => { + const results = await Promise.allSettled(senders.map((send) => send())); + results.forEach((result) => { + if (result.status === "rejected") { + logEmailSendFailure(context, result.reason); + } else if (!result.value.ok) { + logEmailSendFailure( + context, + `Email API returned ${result.value.status} ${result.value.statusText}`, + ); + } + }); + return results; +}; + +const wasAnyEmailSent = ( + results: Array>, +): boolean => + results.some((result) => result.status === "fulfilled" && result.value.ok); + +const sendEmailInBackground = (context: string, promise: Promise) => { + void promise.catch((error) => logEmailSendFailure(context, error)); +}; + const getBookingHistory = async ( booking: Booking, tenant?: string, @@ -340,8 +371,9 @@ export const serverFirstApproveOnly = async ( if (recipients.length === 0) { return; } - const results = await Promise.all( - recipients.map((recipient) => + const results = await sendEmailFanout( + "first approval", + recipients.map((recipient) => () => fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { method: "POST", headers: { @@ -367,7 +399,7 @@ export const serverFirstApproveOnly = async ( `✅ FIRST APPROVAL COMPLETED [${tenant?.toUpperCase() || "UNKNOWN"}]:`, { calendarEventId: id, - emailSent: results.some((result) => result.ok), + emailSent: wasAnyEmailSent(results), status: BookingStatusLabel.PRE_APPROVED, }, ); @@ -518,8 +550,9 @@ const firstApprove = async (id: string, email: string, tenant?: string) => { return; } - await Promise.all( - recipients.map((recipient) => + await sendEmailFanout( + "booking modification first approval", + recipients.map((recipient) => () => fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { method: "POST", headers: { @@ -618,7 +651,7 @@ export const serverSendBookingDetailEmail = async ({ tenant, schemaName: emailConfig.schemaName, }; - const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { + return fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { method: "POST", headers: { "Content-Type": "application/json", @@ -640,8 +673,9 @@ export const serverSendConfirmationEmail = async ({ if (emails.length === 0) { return; } - await Promise.all( - emails.map((email) => + await sendEmailFanout( + "approval confirmation", + emails.map((email) => () => serverSendBookingDetailEmail({ calendarEventId, targetEmail: email, @@ -679,35 +713,44 @@ export const serverApproveEvent = async (id: string, tenant?: string) => { const otherHeaderMessage = `This is a confirmation email.

${emailConfig.emailNotifications.approvedUser}`; // for client - serverSendBookingDetailEmail({ - calendarEventId: id, - targetEmail: guestEmail, - headerMessage: userHeaderMessage, - status: BookingStatusLabel.APPROVED, - tenant, - }); + sendEmailInBackground( + "approval requester notification", + serverSendBookingDetailEmail({ + calendarEventId: id, + targetEmail: guestEmail, + headerMessage: userHeaderMessage, + status: BookingStatusLabel.APPROVED, + tenant, + }), + ); // for second approver - serverSendConfirmationEmail({ - calendarEventId: id, - status: BookingStatusLabel.APPROVED, - headerMessage: otherHeaderMessage, - guestEmail, - tenant, - roomId: bookingRoomId, - }); + sendEmailInBackground( + "approval resource approver confirmation", + serverSendConfirmationEmail({ + calendarEventId: id, + status: BookingStatusLabel.APPROVED, + headerMessage: otherHeaderMessage, + guestEmail, + tenant, + roomId: bookingRoomId, + }), + ); // for Samantha const approvedCcEmail = await getApprovalCcEmail(process.env.NEXT_PUBLIC_BRANCH_NAME, tenant); if (approvedCcEmail) { - serverSendBookingDetailEmail({ - calendarEventId: id, - targetEmail: approvedCcEmail, - headerMessage: otherHeaderMessage, - status: BookingStatusLabel.APPROVED, - replyTo: guestEmail, - tenant, - }); + sendEmailInBackground( + "approval cc notification", + serverSendBookingDetailEmail({ + calendarEventId: id, + targetEmail: approvedCcEmail, + headerMessage: otherHeaderMessage, + status: BookingStatusLabel.APPROVED, + replyTo: guestEmail, + tenant, + }), + ); } // for sponsor, if we have one @@ -718,15 +761,18 @@ export const serverApproveEvent = async (id: string, tenant?: string) => { ? contents.sponsorEmail : `${contents.sponsorEmail}@nyu.edu`; - serverSendBookingDetailEmail({ - calendarEventId: id, - targetEmail: sponsorEmailAddress, - headerMessage: - `A reservation that you are the Sponsor of has been approved.

${emailConfig.emailNotifications.approvedUser}`, - status: BookingStatusLabel.APPROVED, - replyTo: guestEmail, - tenant, - }); + sendEmailInBackground( + "approval sponsor notification", + serverSendBookingDetailEmail({ + calendarEventId: id, + targetEmail: sponsorEmailAddress, + headerMessage: + `A reservation that you are the Sponsor of has been approved.

${emailConfig.emailNotifications.approvedUser}`, + status: BookingStatusLabel.APPROVED, + replyTo: guestEmail, + tenant, + }), + ); } // for secondary contact, if we have one diff --git a/booking-app/lib/firebase/firebase.ts b/booking-app/lib/firebase/firebase.ts index 93b4c3dac..de9a37e9e 100644 --- a/booking-app/lib/firebase/firebase.ts +++ b/booking-app/lib/firebase/firebase.ts @@ -56,6 +56,14 @@ export type ResourceApproverData = { createdAt: Timestamp; }; +const FIRESTORE_IN_QUERY_LIMIT = 30; + +const normalizeEmail = (email: string): string => email.trim().toLowerCase(); + +const normalizeResourceIds = (resourceIds: string[]): string[] => [ + ...new Set(resourceIds.map((resourceId) => resourceId.trim()).filter(Boolean)), +]; + type ResourceApproverSetRequest = { op: "set"; collection: TableNames.RESOURCE_APPROVERS; @@ -243,12 +251,34 @@ export const clientListResourceApprovers = async ( tenant, ); +const clientListResourceApproversByResourceIds = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = normalizeResourceIds(resourceIds); + if (uniqueResourceIds.length === 0) return []; + + if (uniqueResourceIds.length <= FIRESTORE_IN_QUERY_LIMIT) { + return clientFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [{ field: "resourceId", op: "in", value: uniqueResourceIds }], + tenant, + ); + } + + const requestedResourceIds = new Set(uniqueResourceIds); + const approvers = await clientListResourceApprovers(tenant); + return approvers.filter((approver) => + requestedResourceIds.has(approver.resourceId), + ); +}; + export const clientAddResourceApprover = async ( resourceId: string, email: string, tenant?: string, ): Promise => { - const normalizedEmail = email.trim().toLowerCase(); + const normalizedEmail = normalizeEmail(email); await postJson("/api/firestore/mutate", { op: "set", collection: TableNames.RESOURCE_APPROVERS, @@ -267,11 +297,12 @@ export const clientRemoveResourceApprover = async ( email: string, tenant?: string, ): Promise => { + const normalizedEmail = normalizeEmail(email); await postJson("/api/firestore/mutate", { op: "delete", collection: TableNames.RESOURCE_APPROVERS, tenant: resolveTenantArg(tenant), - docId: getResourceApproverDocumentId(resourceId, email), + docId: getResourceApproverDocumentId(resourceId, normalizedEmail), }); }; @@ -279,21 +310,31 @@ export const clientResolveResourceApproverEmails = async ( resourceIds: string[], tenant?: string, ): Promise => { - const uniqueResourceIds = [...new Set(resourceIds)]; + const uniqueResourceIds = normalizeResourceIds(resourceIds); if (uniqueResourceIds.length === 0) return []; - const approvers = await clientListResourceApprovers(tenant); + const approvers = await clientListResourceApproversByResourceIds( + uniqueResourceIds, + tenant, + ); const requestedResourceIds = new Set(uniqueResourceIds); - const coveredResourceIds = new Set(); - const recipients = new Set(); + const resourceIdsByEmail = new Map>(); for (const approver of approvers) { if (!requestedResourceIds.has(approver.resourceId)) continue; - coveredResourceIds.add(approver.resourceId); - recipients.add(approver.email.trim().toLowerCase()); + const email = normalizeEmail(approver.email); + const approverResourceIds = resourceIdsByEmail.get(email) ?? new Set(); + approverResourceIds.add(approver.resourceId); + resourceIdsByEmail.set(email, approverResourceIds); } - if (coveredResourceIds.size < uniqueResourceIds.length) { + const recipients = [...resourceIdsByEmail.entries()] + .filter(([, approverResourceIds]) => + uniqueResourceIds.every((resourceId) => approverResourceIds.has(resourceId)), + ) + .map(([email]) => email); + + if (recipients.length === 0) { const { docs } = await postJson< ListRequest, { docs: Array<{ email?: string }> } @@ -303,11 +344,13 @@ export const clientResolveResourceApproverEmails = async ( where: [{ field: "level", op: "==", value: ApproverLevel.FINAL }], limit: 1, }); - const finalApproverEmail = docs[0]?.email?.trim().toLowerCase(); - if (finalApproverEmail) recipients.add(finalApproverEmail); + const finalApproverEmail = docs[0]?.email + ? normalizeEmail(docs[0].email) + : undefined; + if (finalApproverEmail) recipients.push(finalApproverEmail); } - return [...recipients]; + return recipients; }; export const clientFetchAllDataFromCollectionWithLimitAndOffset = async ( diff --git a/booking-app/lib/firebase/server/adminDb.ts b/booking-app/lib/firebase/server/adminDb.ts index 472d92dee..c7b26a98e 100644 --- a/booking-app/lib/firebase/server/adminDb.ts +++ b/booking-app/lib/firebase/server/adminDb.ts @@ -39,6 +39,14 @@ export type ResourceApproverData = { createdAt: admin.firestore.Timestamp; }; +const FIRESTORE_IN_QUERY_LIMIT = 30; + +const normalizeEmail = (email: string): string => email.trim().toLowerCase(); + +const normalizeResourceIds = (resourceIds: string[]): string[] => [ + ...new Set(resourceIds.map((resourceId) => resourceId.trim()).filter(Boolean)), +]; + export const serverListResourceApprovers = async ( tenant?: string, ): Promise => @@ -48,12 +56,44 @@ export const serverListResourceApprovers = async ( tenant, ); +export const serverListResourceApproversByResourceIds = async ( + resourceIds: string[], + tenant?: string, +): Promise => { + const uniqueResourceIds = normalizeResourceIds(resourceIds); + if (uniqueResourceIds.length === 0) return []; + + if (uniqueResourceIds.length <= FIRESTORE_IN_QUERY_LIMIT) { + return serverFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [{ field: "resourceId", operator: "in", value: uniqueResourceIds }], + tenant, + ); + } + + const requestedResourceIds = new Set(uniqueResourceIds); + const approvers = await serverListResourceApprovers(tenant); + return approvers.filter((approver) => + requestedResourceIds.has(approver.resourceId), + ); +}; + +export const serverListResourceApproversByEmail = async ( + email: string, + tenant?: string, +): Promise => + serverFetchAllDataFromCollection( + TableNames.RESOURCE_APPROVERS, + [{ field: "email", operator: "==", value: normalizeEmail(email) }], + tenant, + ); + export const serverAddResourceApprover = async ( resourceId: string, email: string, tenant?: string, ): Promise => { - const normalizedEmail = email.trim().toLowerCase(); + const normalizedEmail = normalizeEmail(email); const tenantCollection = getServerTenantCollection( TableNames.RESOURCE_APPROVERS, tenant, @@ -73,11 +113,12 @@ export const serverRemoveResourceApprover = async ( email: string, tenant?: string, ): Promise => { + const normalizedEmail = normalizeEmail(email); const tenantCollection = getServerTenantCollection( TableNames.RESOURCE_APPROVERS, tenant, ); - const docId = getResourceApproverDocumentId(resourceId, email); + const docId = getResourceApproverDocumentId(resourceId, normalizedEmail); await traceDatabase("delete", `Firestore/${tenantCollection}`, () => db.collection(tenantCollection).doc(docId).delete(), ); @@ -87,18 +128,19 @@ export const serverResolveResourceApproverEmails = async ( resourceIds: string[], tenant?: string, ): Promise => { - const uniqueResourceIds = [ - ...new Set(resourceIds.map((resourceId) => resourceId.trim()).filter(Boolean)), - ]; + const uniqueResourceIds = normalizeResourceIds(resourceIds); if (uniqueResourceIds.length === 0) return []; - const approvers = await serverListResourceApprovers(tenant); + const approvers = await serverListResourceApproversByResourceIds( + uniqueResourceIds, + tenant, + ); const requestedResourceIds = new Set(uniqueResourceIds); const resourceIdsByEmail = new Map>(); for (const approver of approvers) { if (!requestedResourceIds.has(approver.resourceId)) continue; - const email = approver.email.trim().toLowerCase(); + const email = normalizeEmail(approver.email); const approverResourceIds = resourceIdsByEmail.get(email) ?? new Set(); approverResourceIds.add(approver.resourceId); resourceIdsByEmail.set(email, approverResourceIds); diff --git a/booking-app/tests/unit/api-approve.unit.test.ts b/booking-app/tests/unit/api-approve.unit.test.ts index f0b1b75b7..0b3aebdb8 100644 --- a/booking-app/tests/unit/api-approve.unit.test.ts +++ b/booking-app/tests/unit/api-approve.unit.test.ts @@ -20,7 +20,7 @@ vi.mock("@/lib/firebase/server/adminDb", () => ({ serverGetDataByCalendarEventId: vi.fn(), serverFetchAllDataFromCollection: vi.fn(), serverGetFinalApproverEmail: vi.fn(), - serverListResourceApprovers: vi.fn(), + serverListResourceApproversByEmail: vi.fn(), })); vi.mock("@/lib/api/requireSession", () => ({ @@ -56,7 +56,7 @@ import { serverFetchAllDataFromCollection, serverGetFinalApproverEmail, serverGetDataByCalendarEventId, - serverListResourceApprovers, + serverListResourceApproversByEmail, } from "@/lib/firebase/server/adminDb"; import { resolveCallerRole } from "@/lib/api/authz"; import { requireSession } from "@/lib/api/requireSession"; @@ -90,7 +90,9 @@ const mockServerFetchAllDataFromCollection = vi.mocked( serverFetchAllDataFromCollection, ); const mockServerGetFinalApproverEmail = vi.mocked(serverGetFinalApproverEmail); -const mockServerListResourceApprovers = vi.mocked(serverListResourceApprovers); +const mockServerListResourceApproversByEmail = vi.mocked( + serverListResourceApproversByEmail, +); const mockRequireSession = vi.mocked(requireSession); const mockResolveCallerRole = vi.mocked(resolveCallerRole); @@ -115,7 +117,7 @@ describe("POST /api/approve", () => { } as any); mockResolveCallerRole.mockResolvedValue(PagePermission.ADMIN); mockServerGetFinalApproverEmail.mockResolvedValue(null); - mockServerListResourceApprovers.mockResolvedValue([]); + mockServerListResourceApproversByEmail.mockResolvedValue([]); mockIsMediaCommons.mockReturnValue(false); mockGetMediaCommonsServices.mockReturnValue({ staff: false, @@ -234,7 +236,7 @@ describe("POST /api/approve", () => { firstApprovedAt: "2026-06-14T12:00:00Z", roomId: "room-1, room-2", } as any); - mockServerListResourceApprovers.mockResolvedValue([ + mockServerListResourceApproversByEmail.mockResolvedValue([ { resourceId: "room-1", email: sessionEmail }, { resourceId: "room-2", email: sessionEmail.toUpperCase() }, ] as any); @@ -244,7 +246,10 @@ describe("POST /api/approve", () => { createRequest({ id: bookingId, email: bodyEmail }) as any, ); - expect(mockServerListResourceApprovers).toHaveBeenCalledWith(DEFAULT_TENANT); + expect(mockServerListResourceApproversByEmail).toHaveBeenCalledWith( + sessionEmail, + DEFAULT_TENANT, + ); expect(mockFinalApprove).toHaveBeenCalledWith( bookingId, sessionEmail, @@ -263,7 +268,7 @@ describe("POST /api/approve", () => { firstApprovedAt: "2026-06-14T12:00:00Z", roomId: "room-1, room-2", } as any); - mockServerListResourceApprovers.mockResolvedValue(assignments as any); + mockServerListResourceApproversByEmail.mockResolvedValue(assignments as any); const response = await POST( createRequest({ id: bookingId, email: bodyEmail }) as any, @@ -295,7 +300,7 @@ describe("POST /api/approve", () => { sessionEmail, DEFAULT_TENANT, ); - expect(mockServerListResourceApprovers).not.toHaveBeenCalled(); + expect(mockServerListResourceApproversByEmail).not.toHaveBeenCalled(); }); it.each([PagePermission.ADMIN, PagePermission.SUPER_ADMIN])( diff --git a/booking-app/tests/unit/resource-approvers-client.unit.test.ts b/booking-app/tests/unit/resource-approvers-client.unit.test.ts index 0dcc28362..911d481e1 100644 --- a/booking-app/tests/unit/resource-approvers-client.unit.test.ts +++ b/booking-app/tests/unit/resource-approvers-client.unit.test.ts @@ -43,7 +43,7 @@ describe("resource approver client helpers", () => { fetchMock.mockResolvedValue(response({ ok: true })); await clientAddResourceApprover("room/a", " Person@NYU.EDU ", "mc"); - await clientRemoveResourceApprover("room/a", "person@nyu.edu", "mc"); + await clientRemoveResourceApprover("room/a", " Person@NYU.EDU ", "mc"); expect(bodyAt(0)).toMatchObject({ op: "set", @@ -63,7 +63,7 @@ describe("resource approver client helpers", () => { }); }); - it("falls back only when a requested resource is uncovered and deduplicates", async () => { + it("falls back when no approver covers every requested resource", async () => { fetchMock .mockResolvedValueOnce( response({ @@ -78,8 +78,13 @@ describe("resource approver client helpers", () => { await expect( clientResolveResourceApproverEmails(["a", "b", "b"], "mc"), - ).resolves.toEqual(["same@nyu.edu", "final@nyu.edu"]); + ).resolves.toEqual(["final@nyu.edu"]); expect(fetchMock).toHaveBeenCalledTimes(2); + expect(bodyAt(0)).toMatchObject({ + collection: "usersResourceApprovers", + tenant: "mc", + where: [{ field: "resourceId", op: "in", value: ["a", "b"] }], + }); expect(bodyAt(1)).toMatchObject({ collection: "usersApprovers", tenant: "mc", @@ -91,7 +96,8 @@ describe("resource approver client helpers", () => { fetchMock.mockResolvedValue( response({ docs: [ - { resourceId: "a", email: "one@nyu.edu" }, + { resourceId: "a", email: "ONE@nyu.edu" }, + { resourceId: "b", email: "one@nyu.edu" }, { resourceId: "b", email: "two@nyu.edu" }, ], }), @@ -99,8 +105,13 @@ describe("resource approver client helpers", () => { await expect( clientResolveResourceApproverEmails(["a", "b"], "mc"), - ).resolves.toEqual(["one@nyu.edu", "two@nyu.edu"]); + ).resolves.toEqual(["one@nyu.edu"]); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(bodyAt(0)).toMatchObject({ + collection: "usersResourceApprovers", + tenant: "mc", + where: [{ field: "resourceId", op: "in", value: ["a", "b"] }], + }); }); it("throws instead of silently falling back after a resource query failure", async () => { diff --git a/booking-app/tests/unit/resource-approvers-server.unit.test.ts b/booking-app/tests/unit/resource-approvers-server.unit.test.ts index 40578329a..057c4b5f2 100644 --- a/booking-app/tests/unit/resource-approvers-server.unit.test.ts +++ b/booking-app/tests/unit/resource-approvers-server.unit.test.ts @@ -1,25 +1,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; type Doc = { id: string; data: Record }; +type QueryFilter = { field: string; op: string; value: unknown }; const collections = new Map(); const getFailures = new Map(); const setMock = vi.fn(); const deleteMock = vi.fn(); +const queryGetMock = vi.fn(); -function queryFor(name: string, filters: Array<[string, unknown]> = []) { +function queryFor(name: string, filters: QueryFilter[] = []) { return { where(field: string, _op: string, value: unknown) { - return queryFor(name, [...filters, [field, value]]); + return queryFor(name, [...filters, { field, op: _op, value }]); }, limit() { return this; }, async get() { + queryGetMock(name, filters); const failure = getFailures.get(name); if (failure) throw failure; const docs = (collections.get(name) ?? []).filter(({ data }) => - filters.every(([field, value]) => data[field] === value), + filters.every(({ field, op, value }) => { + if (op === "in" && Array.isArray(value)) { + return value.includes(data[field]); + } + return data[field] === value; + }), ); return { empty: docs.length === 0, @@ -60,6 +68,7 @@ describe("resource approver server helpers", () => { getFailures.clear(); setMock.mockReset(); deleteMock.mockReset(); + queryGetMock.mockReset(); }); it("uses tenant collection, normalized email, and deterministic IDs", async () => { @@ -69,7 +78,7 @@ describe("resource approver server helpers", () => { await import("@/lib/firebase/server/adminDb"); await serverAddResourceApprover("room/a", " Person@NYU.EDU ", "mc"); - await serverRemoveResourceApprover("room/a", "person@nyu.edu", "mc"); + await serverRemoveResourceApprover("room/a", " Person@NYU.EDU ", "mc"); expect(setMock).toHaveBeenCalledWith( "mc-usersResourceApprovers", @@ -115,6 +124,27 @@ describe("resource approver server helpers", () => { await expect( serverResolveResourceApproverEmails(["a", "b"], "mc"), ).resolves.toEqual(["one@nyu.edu"]); + expect(queryGetMock).toHaveBeenCalledWith("mc-usersResourceApprovers", [ + { field: "resourceId", op: "in", value: ["a", "b"] }, + ]); + }); + + it("lists resource approvers by normalized email", async () => { + collections.set("mc-usersResourceApprovers", [ + { id: "1", data: { resourceId: "a", email: "one@nyu.edu" } }, + { id: "2", data: { resourceId: "b", email: "two@nyu.edu" } }, + ]); + const { serverListResourceApproversByEmail } = + await import("@/lib/firebase/server/adminDb"); + + await expect( + serverListResourceApproversByEmail(" ONE@NYU.EDU ", "mc"), + ).resolves.toEqual([ + { id: "1", resourceId: "a", email: "one@nyu.edu" }, + ]); + expect(queryGetMock).toHaveBeenCalledWith("mc-usersResourceApprovers", [ + { field: "email", op: "==", value: "one@nyu.edu" }, + ]); }); it("falls back when resources are only covered by different approvers", async () => { From 8249352e70dbd53aa0eeef76fe306321143f87dd Mon Sep 17 00:00:00 2001 From: n3xta Date: Mon, 22 Jun 2026 00:09:37 -0400 Subject: [PATCH 34/51] fix(resource-approvers): restore table UI --- .../admin/components/ResourceSpecific.tsx | 262 +++++++----------- booking-app/components/src/policy.ts | 2 +- booking-app/lib/firebase/firebase.ts | 11 +- booking-app/lib/firebase/server/adminDb.ts | 17 +- booking-app/tests/unit/policy.unit.test.ts | 13 +- .../resource-approvers-client.unit.test.ts | 4 +- .../resource-approvers-server.unit.test.ts | 4 +- ...source-specific-approvers-ui.unit.test.tsx | 13 +- 8 files changed, 149 insertions(+), 177 deletions(-) diff --git a/booking-app/components/src/client/routes/admin/components/ResourceSpecific.tsx b/booking-app/components/src/client/routes/admin/components/ResourceSpecific.tsx index f874080f4..c25cc450e 100644 --- a/booking-app/components/src/client/routes/admin/components/ResourceSpecific.tsx +++ b/booking-app/components/src/client/routes/admin/components/ResourceSpecific.tsx @@ -1,19 +1,7 @@ -import { AddCircleOutline, Delete } from "@mui/icons-material"; -import { - Alert, - Box, - Button, - CircularProgress, - IconButton, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, - Typography, -} from "@mui/material"; +import { AddCircleOutline } from "@mui/icons-material"; +import { Box, IconButton, TextField, Typography } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2/Grid2"; +import { Timestamp } from "firebase/firestore"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -22,141 +10,124 @@ import { clientRemoveResourceApprover, } from "@/lib/firebase/firebase"; import { useTenantSchema } from "../../components/SchemaProvider"; +import ListTable from "../../components/ListTable"; +import { formatDate } from "../../../utils/date"; +import { TableNames } from "../../../../policy"; type ResourceApprover = { id: string; resourceId: string; email: string; + createdAt?: Timestamp; }; const normalizeEmail = (email: string) => email.trim().toLowerCase(); type ResourceApproverSectionProps = { - name: string; resourceId: string; approvers: ResourceApprover[]; - onAdd: (resourceId: string, email: string) => Promise; - onRemove: (approver: ResourceApprover) => Promise; + rowsRefresh: () => Promise; }; const ResourceApproverSection = ({ - name, resourceId, approvers, - onAdd, - onRemove, + rowsRefresh, }: ResourceApproverSectionProps) => { - const [email, setEmail] = useState(""); - const [adding, setAdding] = useState(false); - const [removingId, setRemovingId] = useState(null); - const [error, setError] = useState(null); + const { tenantId } = useTenantSchema(); + const [valueToAdd, setValueToAdd] = useState(""); const addApprover = async () => { - const normalizedEmail = normalizeEmail(email); + const normalizedEmail = normalizeEmail(valueToAdd); if (!normalizedEmail) { return; } if (approvers.some((approver) => approver.email === normalizedEmail)) { - setError("This user is already a resource approver."); + alert("This user is already a resource approver."); return; } - setAdding(true); - setError(null); try { - await onAdd(resourceId, normalizedEmail); - setEmail(""); + await clientAddResourceApprover(resourceId, normalizedEmail, tenantId); + setValueToAdd(""); + await rowsRefresh(); } catch (addError) { console.error("Failed to add resource approver:", addError); - setError("Failed to add resource approver."); - } finally { - setAdding(false); + alert("Failed to add resource approver."); } }; - const removeApprover = async (approver: ResourceApprover) => { - setRemovingId(approver.id); - setError(null); - try { - await onRemove(approver); - } catch (removeError) { - console.error("Failed to remove resource approver:", removeError); - setError("Failed to remove resource approver."); - } finally { - setRemovingId(null); - } - }; + const rows = useMemo( + () => + approvers.map(({ id, email, createdAt }) => ({ + id, + email, + createdAt, + })), + [approvers], + ); - return ( - - - {name} - - - setEmail(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void addApprover(); - } - }} - placeholder="Add email" - size="small" - value={email} - /> + const removeApprover = useCallback( + async (row: { [key: string]: string }) => { + await clientRemoveResourceApprover(resourceId, row.email, tenantId); + }, + [resourceId, tenantId], + ); + + const topRow = ( + + + Resource Approvers + + + + setValueToAdd(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void addApprover(); + } + }} + value={valueToAdd} + placeholder="Add email" + size="small" + /> + void addApprover()} + color="primary" + sx={{ padding: 0 }} + disabled={!valueToAdd.trim()} + aria-label={`Add resource approver for ${resourceId}`} > - {adding ? : } + - - {error && ( - setError(null)} sx={{ mb: 1 }}> - {error} - - )} - - - - Email - Action - - - - {approvers.length === 0 ? ( - - No resource approvers. - - ) : ( - approvers.map((approver) => ( - - {approver.email} - - void removeApprover(approver)} - > - {removingId === approver.id ? ( - - ) : ( - - )} - - - - )) - )} - -
-
+ + + ); + + return ( + ); }; @@ -175,6 +146,7 @@ export const ResourceSpecific = () => { data.map((approver) => ({ ...approver, email: normalizeEmail(approver.email), + resourceId: approver.resourceId.trim(), })), ); } catch (error) { @@ -202,51 +174,19 @@ export const ResourceSpecific = () => { return result; }, [approvers]); - const addApprover = useCallback( - async (resourceId: string, email: string) => { - await clientAddResourceApprover(resourceId, email, tenantId); - await loadApprovers(); - }, - [loadApprovers, tenantId], - ); - - const removeApprover = useCallback( - async (approver: ResourceApprover) => { - await clientRemoveResourceApprover( - approver.resourceId, - approver.email, - tenantId, - ); - setApprovers((current) => - current.filter((currentApprover) => currentApprover.id !== approver.id), - ); - }, - [tenantId], - ); - if (loading) { return ( - - - - Loading resource approvers... - - + + Loading resource approvers... + ); } if (loadError) { return ( - void loadApprovers()}> - Retry - - } - > - {loadError} - + + {loadError} Refresh the page to retry. + ); } @@ -259,17 +199,19 @@ export const ResourceSpecific = () => { } return ( - + {resources.map((resource) => ( - + + + {resource.resourceId} {resource.name} + + + ))} - + ); }; diff --git a/booking-app/components/src/policy.ts b/booking-app/components/src/policy.ts index 531bdbc6e..c094a85cc 100644 --- a/booking-app/components/src/policy.ts +++ b/booking-app/components/src/policy.ts @@ -106,7 +106,7 @@ export const getResourceApproverDocumentId = ( resourceId: string, email: string, ): string => { - const encodedResourceId = encodeURIComponent(resourceId); + const encodedResourceId = encodeURIComponent(resourceId.trim()); const encodedEmail = encodeURIComponent(email.trim().toLowerCase()); return `${encodedResourceId.length}-${encodedResourceId}${encodedEmail}`; }; diff --git a/booking-app/lib/firebase/firebase.ts b/booking-app/lib/firebase/firebase.ts index de9a37e9e..5c5c12609 100644 --- a/booking-app/lib/firebase/firebase.ts +++ b/booking-app/lib/firebase/firebase.ts @@ -59,9 +59,10 @@ export type ResourceApproverData = { const FIRESTORE_IN_QUERY_LIMIT = 30; const normalizeEmail = (email: string): string => email.trim().toLowerCase(); +const normalizeResourceId = (resourceId: string): string => resourceId.trim(); const normalizeResourceIds = (resourceIds: string[]): string[] => [ - ...new Set(resourceIds.map((resourceId) => resourceId.trim()).filter(Boolean)), + ...new Set(resourceIds.map(normalizeResourceId).filter(Boolean)), ]; type ResourceApproverSetRequest = { @@ -278,15 +279,16 @@ export const clientAddResourceApprover = async ( email: string, tenant?: string, ): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); const normalizedEmail = normalizeEmail(email); await postJson("/api/firestore/mutate", { op: "set", collection: TableNames.RESOURCE_APPROVERS, tenant: resolveTenantArg(tenant), - docId: getResourceApproverDocumentId(resourceId, normalizedEmail), + docId: getResourceApproverDocumentId(normalizedResourceId, normalizedEmail), data: { email: normalizedEmail, - resourceId, + resourceId: normalizedResourceId, createdAt: { __ts: Date.now() }, }, }); @@ -297,12 +299,13 @@ export const clientRemoveResourceApprover = async ( email: string, tenant?: string, ): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); const normalizedEmail = normalizeEmail(email); await postJson("/api/firestore/mutate", { op: "delete", collection: TableNames.RESOURCE_APPROVERS, tenant: resolveTenantArg(tenant), - docId: getResourceApproverDocumentId(resourceId, normalizedEmail), + docId: getResourceApproverDocumentId(normalizedResourceId, normalizedEmail), }); }; diff --git a/booking-app/lib/firebase/server/adminDb.ts b/booking-app/lib/firebase/server/adminDb.ts index c7b26a98e..25386bb87 100644 --- a/booking-app/lib/firebase/server/adminDb.ts +++ b/booking-app/lib/firebase/server/adminDb.ts @@ -42,9 +42,10 @@ export type ResourceApproverData = { const FIRESTORE_IN_QUERY_LIMIT = 30; const normalizeEmail = (email: string): string => email.trim().toLowerCase(); +const normalizeResourceId = (resourceId: string): string => resourceId.trim(); const normalizeResourceIds = (resourceIds: string[]): string[] => [ - ...new Set(resourceIds.map((resourceId) => resourceId.trim()).filter(Boolean)), + ...new Set(resourceIds.map(normalizeResourceId).filter(Boolean)), ]; export const serverListResourceApprovers = async ( @@ -93,16 +94,20 @@ export const serverAddResourceApprover = async ( email: string, tenant?: string, ): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); const normalizedEmail = normalizeEmail(email); const tenantCollection = getServerTenantCollection( TableNames.RESOURCE_APPROVERS, tenant, ); - const docId = getResourceApproverDocumentId(resourceId, normalizedEmail); + const docId = getResourceApproverDocumentId( + normalizedResourceId, + normalizedEmail, + ); await traceDatabase("set", `Firestore/${tenantCollection}`, () => db.collection(tenantCollection).doc(docId).set({ email: normalizedEmail, - resourceId, + resourceId: normalizedResourceId, createdAt: admin.firestore.Timestamp.now(), }), ); @@ -113,12 +118,16 @@ export const serverRemoveResourceApprover = async ( email: string, tenant?: string, ): Promise => { + const normalizedResourceId = normalizeResourceId(resourceId); const normalizedEmail = normalizeEmail(email); const tenantCollection = getServerTenantCollection( TableNames.RESOURCE_APPROVERS, tenant, ); - const docId = getResourceApproverDocumentId(resourceId, normalizedEmail); + const docId = getResourceApproverDocumentId( + normalizedResourceId, + normalizedEmail, + ); await traceDatabase("delete", `Firestore/${tenantCollection}`, () => db.collection(tenantCollection).doc(docId).delete(), ); diff --git a/booking-app/tests/unit/policy.unit.test.ts b/booking-app/tests/unit/policy.unit.test.ts index bfb08f803..7855afac1 100644 --- a/booking-app/tests/unit/policy.unit.test.ts +++ b/booking-app/tests/unit/policy.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { CALENDAR_HIDE_STATUS } from "@/components/src/policy"; +import { + CALENDAR_HIDE_STATUS, + getResourceApproverDocumentId, +} from "@/components/src/policy"; import { BookingStatusLabel } from "@/components/src/types"; describe("CALENDAR_HIDE_STATUS", () => { @@ -13,3 +16,11 @@ describe("CALENDAR_HIDE_STATUS", () => { expect(CALENDAR_HIDE_STATUS).toContain(BookingStatusLabel.CHECKED_OUT); }); }); + +describe("getResourceApproverDocumentId", () => { + it("normalizes resource ID whitespace and email case", () => { + expect( + getResourceApproverDocumentId(" room/a ", " Person@NYU.EDU "), + ).toBe(getResourceApproverDocumentId("room/a", "person@nyu.edu")); + }); +}); diff --git a/booking-app/tests/unit/resource-approvers-client.unit.test.ts b/booking-app/tests/unit/resource-approvers-client.unit.test.ts index 911d481e1..1c70b6036 100644 --- a/booking-app/tests/unit/resource-approvers-client.unit.test.ts +++ b/booking-app/tests/unit/resource-approvers-client.unit.test.ts @@ -42,8 +42,8 @@ describe("resource approver client helpers", () => { it("normalizes email and uses the same deterministic ID for add/remove", async () => { fetchMock.mockResolvedValue(response({ ok: true })); - await clientAddResourceApprover("room/a", " Person@NYU.EDU ", "mc"); - await clientRemoveResourceApprover("room/a", " Person@NYU.EDU ", "mc"); + await clientAddResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); + await clientRemoveResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); expect(bodyAt(0)).toMatchObject({ op: "set", diff --git a/booking-app/tests/unit/resource-approvers-server.unit.test.ts b/booking-app/tests/unit/resource-approvers-server.unit.test.ts index 057c4b5f2..dc037e7b6 100644 --- a/booking-app/tests/unit/resource-approvers-server.unit.test.ts +++ b/booking-app/tests/unit/resource-approvers-server.unit.test.ts @@ -77,8 +77,8 @@ describe("resource approver server helpers", () => { const { serverAddResourceApprover, serverRemoveResourceApprover } = await import("@/lib/firebase/server/adminDb"); - await serverAddResourceApprover("room/a", " Person@NYU.EDU ", "mc"); - await serverRemoveResourceApprover("room/a", " Person@NYU.EDU ", "mc"); + await serverAddResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); + await serverRemoveResourceApprover(" room/a ", " Person@NYU.EDU ", "mc"); expect(setMock).toHaveBeenCalledWith( "mc-usersResourceApprovers", diff --git a/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx b/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx index e485e36c0..9f5c024f5 100644 --- a/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx +++ b/booking-app/tests/unit/resource-specific-approvers-ui.unit.test.tsx @@ -46,12 +46,19 @@ describe("ResourceSpecific", () => { , ); - const input = await screen.findByLabelText( - "Approver email for Audio Studio", + expect( + await screen.findByText("studio/a:floor-2 Audio Studio"), + ).toBeInTheDocument(); + expect(screen.getByText("Resource Approvers")).toBeInTheDocument(); + + const input = screen.getByLabelText( + "Resource approver email for studio/a:floor-2", ); await user.type(input, " Person@NYU.EDU "); await user.click( - screen.getByRole("button", { name: "Add approver for Audio Studio" }), + screen.getByRole("button", { + name: "Add resource approver for studio/a:floor-2", + }), ); await waitFor(() => From b1a95c63952f9b46d91ceb53f7badc127634f884 Mon Sep 17 00:00:00 2001 From: n3xta Date: Mon, 22 Jun 2026 00:30:34 -0400 Subject: [PATCH 35/51] fix(resource-approvers): tolerate default mui theme in tables --- booking-app/components/src/client/routes/components/Table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/booking-app/components/src/client/routes/components/Table.tsx b/booking-app/components/src/client/routes/components/Table.tsx index eea569af7..6ad986d87 100644 --- a/booking-app/components/src/client/routes/components/Table.tsx +++ b/booking-app/components/src/client/routes/components/Table.tsx @@ -17,7 +17,7 @@ const Clip = styled(Box)` `; const TableCustom = styled(MuiTable)(({ theme }) => ({ - border: `1px solid ${theme.palette.custom.border}`, + border: `1px solid ${theme.palette.custom?.border ?? theme.palette.divider}`, borderCollapse: "separate", borderRadius: "0px 0px 4px 4px", // overflowX: "scroll", @@ -32,7 +32,7 @@ const TableCustom = styled(MuiTable)(({ theme }) => ({ })); const ShadedHeader = styled(TableHead)(({ theme }) => ({ - backgroundColor: theme.palette.custom.gray, + backgroundColor: theme.palette.custom?.gray ?? theme.palette.action.hover, })); export const TableTopRow = styled(MuiTable)` From dc93f430f92f0845d88b699b5945afbc3d26d999 Mon Sep 17 00:00:00 2001 From: Noah Pivnick Date: Mon, 22 Jun 2026 14:08:50 -0400 Subject: [PATCH 36/51] Delete crud files --- .DS_Store | Bin 0 -> 6148 bytes .clasp.json | 5 - .gitignore | 67 - SECURITY.md | 21 - booking-app/.firebaserc | 9 - booking-app/clientInfo | 1 - booking-app/components/src/client/README.md | 31 - booking-app/docker-compose.yml | 20 - booking-app/firebase-debug.log | 1623 ------------------- booking-app/firebase.json | 9 - booking-app/init-db.sql | 4 - booking-app/tailwind.config.ts | 20 - 12 files changed, 1810 deletions(-) create mode 100644 .DS_Store delete mode 100644 .clasp.json delete mode 100644 .gitignore delete mode 100644 SECURITY.md delete mode 100644 booking-app/.firebaserc delete mode 100644 booking-app/clientInfo delete mode 100644 booking-app/components/src/client/README.md delete mode 100644 booking-app/docker-compose.yml delete mode 100644 booking-app/firebase-debug.log delete mode 100644 booking-app/firebase.json delete mode 100644 booking-app/init-db.sql delete mode 100644 booking-app/tailwind.config.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3023984b5482923fda6cea0917673e1a5800b81f GIT binary patch literal 6148 zcmeHKyG{c!5S)b+k$WwHGn1E5nn#c&ELDv?4}|{q;ro~yyFG;c*1#>{d>T~ zFje3-mus*8kMuwK|0zi;DIf)|N&#DJciRoGRJC<>Ij^;izNdT6H{Ff%pm2zCOpJ2O gg_q;INXoqCbH49|V`9)54?0ml1Fnlq3jDVMKMx!ge*gdg literal 0 HcmV?d00001 diff --git a/.clasp.json b/.clasp.json deleted file mode 100644 index b61136bf4..000000000 --- a/.clasp.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "scriptId": "1ohzyuJgn89_Ovj1-DT5JrN-T8uuRAyxdCl3OECDuXtx4BrX4CsyHi61C", - "rootDir": "./dist", - "parentId": ["12owkz3UxzEjyj8vNnsQXq_o0exBquvg4A6D_MMoE3g8"] -} diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9426e4e39..000000000 --- a/.gitignore +++ /dev/null @@ -1,67 +0,0 @@ -# Created by https://www.toptal.com/developers/gitignore/api/macos,windows -# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows - -### macOS ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### macOS Patch ### -# iCloud generated files -*.icloud - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -.firebase -# End of https://www.toptal.com/developers/gitignore/api/macos,windows - - -app.yaml diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 034e84803..000000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/booking-app/.firebaserc b/booking-app/.firebaserc deleted file mode 100644 index fda5c666e..000000000 --- a/booking-app/.firebaserc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "projects": { - "default": "flowing-mantis-389917", - "production": "flowing-mantis-389917" - }, - "targets": {}, - "etags": {}, - "dataconnectEmulatorConfig": {} -} \ No newline at end of file diff --git a/booking-app/clientInfo b/booking-app/clientInfo deleted file mode 100644 index 368be10fc..000000000 --- a/booking-app/clientInfo +++ /dev/null @@ -1 +0,0 @@ -o4Qd0YDOKtCR95EUyQ2Rbp5UbNUa:XkfbKenefg7ntnRR1D3r3nTo_lQa diff --git a/booking-app/components/src/client/README.md b/booking-app/components/src/client/README.md deleted file mode 100644 index 52ba76f5e..000000000 --- a/booking-app/components/src/client/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Client (our React code) - -This directory is where we store the source code for our client-side React apps. - -We have multiple directories in here because our app creates menu items that open multiple dialog windows. Each dialog opens a separate app, so each directory here represents its own distinct React app. Our webpack configuration will generate a separate bundle for each React app. - -## Requirements - -Each React app will need: -- an entrypoint, usually a file named `index.js`, that loads the app -- an HTML file that acts as a template, in which the bundled React app is loaded - -You'll need to declare the following in [webpack.config.js](../../webpack.config.js): -- **name**: just a name to print in the webpack console, e.g. 'CLIENT - Dialog Demo' -- **entry**: the path to the entry point for the app, e.g. './src/client/dialog-demo/index.js' -- **filename**: the name of the html file that is generated. The server code will reference this filename to load the app into a dialog window. E.g. 'dialog-demo' -- **template**: the path to the HTML template for the app, e.g. './src/client/dialog-demo/index.html' - - -### Adding or removing an entrypoint -Your app or use case may only require a single dialog or sidebar, or you may want to add more than are included in the sample app. - -To edit the entrypoints, you will need to: - -1. Create or remove the entrypoint directories in the client source code. For instance, you can remove `./src/client/sidebar-about-page` altogether, or copy it and modify the source code. See above [requirements](#requirements). - -2. Modify the server-side code to load the correct menu items and expose the correct public functions: - - [ui file](../server/ui.js) - - [index file](../server/index.js) - -3. Modify the `clientEntrypoints` config in the [webpack config file](../../webpack.config.js). diff --git a/booking-app/docker-compose.yml b/booking-app/docker-compose.yml deleted file mode 100644 index 9176b7686..000000000 --- a/booking-app/docker-compose.yml +++ /dev/null @@ -1,20 +0,0 @@ -# docker-compose.yml -version: "3.8" - -services: - db: - image: mysql:8.0 - restart: always - environment: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_DATABASE: mydatabase - MYSQL_USER: myuser - MYSQL_PASSWORD: mypassword - ports: - - "13306:3306" - volumes: - - db-data:/var/lib/mysql - - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - -volumes: - db-data: diff --git a/booking-app/firebase-debug.log b/booking-app/firebase-debug.log deleted file mode 100644 index da26ac234..000000000 --- a/booking-app/firebase-debug.log +++ /dev/null @@ -1,1623 +0,0 @@ -[debug] [2024-07-08T21:18:52.854Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:18:52.855Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:18:52.855Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:18:52.856Z] Platform: darwin -[debug] [2024-07-08T21:18:52.856Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:18:52.860Z] Time: Mon Jul 08 2024 17:18:52 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:18:52.860Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:18:52.890Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:18:52.890Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:18:52.890Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:18:52.891Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:18:52.891Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:18:52.891Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:18:53.441Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:18:53.442Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:18:53.444Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:18:54.139Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:18:54.140Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:18:55.002Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:18:56.625Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:18:57.260Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:18:57.261Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/admin/components/BookingActions.tsx:53:35 -Type error: Argument of type 'undefined' is not assignable to parameter of type 'BookingStatusLabel'. - - 51 | .catch(() => { - 52 | onError(); -> 53 | setOptimisticStatus(undefined); - | ^ - 54 | }) - 55 | .finally(() => reload().then(() => setOptimisticStatus(undefined))); - 56 | } catch (ex) { - -[debug] [2024-07-08T21:19:11.487Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:20:14.178Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:20:14.179Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:20:14.180Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:20:14.180Z] Platform: darwin -[debug] [2024-07-08T21:20:14.180Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:20:14.180Z] Time: Mon Jul 08 2024 17:20:14 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:20:14.180Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:20:14.210Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:20:14.210Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:20:14.210Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:20:14.211Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:20:14.211Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:20:14.211Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:20:14.412Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:20:14.412Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:20:14.414Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:20:14.781Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:20:14.782Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:20:15.563Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:20:17.180Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:20:17.551Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:20:17.551Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/admin/components/BookingActions.tsx:89:19 -Type error: Type 'Promise<{ result: string; }>' is not assignable to type 'Promise'. - - 87 | {ActionButton( - 88 | "Cancel", -> 89 | () => serverFunctions.cancel(calendarEventId), - | ^ - 90 | BookingStatusLabel.CANCELED, - 91 | true - 92 | )} - -[debug] [2024-07-08T21:20:27.663Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:22:26.986Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:22:26.987Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:22:26.987Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:22:26.987Z] Platform: darwin -[debug] [2024-07-08T21:22:26.988Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:22:26.992Z] Time: Mon Jul 08 2024 17:22:26 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:22:26.992Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:22:27.021Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:22:27.021Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:22:27.021Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:22:27.022Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:22:27.022Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:22:27.022Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:22:27.249Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:22:27.250Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:22:27.251Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:22:27.533Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:22:27.533Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:22:28.393Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:22:30.060Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:22:30.219Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:22:30.219Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/admin/components/Liaisons.tsx:11:27 -Type error: Binding element 'liaisonEmails' implicitly has an 'any' type. - - 9 | import { serverFunctions } from '../../../utils/serverFunctions'; - 10 | -> 11 | const AddLiaisonForm = ({ liaisonEmails, reloadLiaisonEmails }) => { - | ^ - 12 | const [email, setEmail] = useState(''); - 13 | const [department, setDepartment] = useState(''); - 14 | const [loading, setLoading] = useState(false); - -[debug] [2024-07-08T21:22:39.586Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:23:17.645Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:23:17.646Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:23:17.646Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:23:17.646Z] Platform: darwin -[debug] [2024-07-08T21:23:17.646Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:23:17.650Z] Time: Mon Jul 08 2024 17:23:17 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:23:17.650Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:23:17.681Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:23:17.681Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:23:17.682Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:23:17.682Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:23:17.682Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:23:17.683Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:23:17.904Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:23:17.904Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:23:17.905Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:23:18.177Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:23:18.177Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:23:18.994Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:23:20.594Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:23:20.896Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:23:20.897Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/bookingProvider.tsx:70:30 -Type error: Property 'map' does not exist on type '{ result: string; }'. - - 68 | isTrained = await serverFunctions - 69 | .getOldSafetyTrainingEmails() -> 70 | .then((rows) => rows.map((row) => row[0]).includes(userEmail)); - | ^ - 71 | } - 72 | console.log("isTrained from googlesheets", isTrained); - 73 | setIsSafetyTrained(isTrained); - -[debug] [2024-07-08T21:23:31.091Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:24:43.186Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:24:43.187Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:24:43.187Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:24:43.187Z] Platform: darwin -[debug] [2024-07-08T21:24:43.187Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:24:43.192Z] Time: Mon Jul 08 2024 17:24:43 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:24:43.193Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:24:43.230Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:24:43.231Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:24:43.231Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:24:43.232Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:24:43.232Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:24:43.232Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:24:43.454Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:24:43.455Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:24:43.456Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:24:43.973Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:24:43.974Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:24:44.818Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:24:46.967Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:24:47.267Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:24:47.268Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/bookingProvider.tsx:41:35 -Type error: Binding element 'children' implicitly has an 'any' type. - - 39 | }); - 40 | -> 41 | export function BookingProvider({ children }) { - | ^ - 42 | const { bannedUsers, safetyTrainedUsers, userEmail } = - 43 | useContext(DatabaseContext); - 44 | - -[debug] [2024-07-08T21:24:57.354Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:27:55.548Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:27:55.549Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:27:55.549Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:27:55.550Z] Platform: darwin -[debug] [2024-07-08T21:27:55.550Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:27:55.553Z] Time: Mon Jul 08 2024 17:27:55 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:27:55.553Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:27:55.583Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:27:55.583Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:27:55.583Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:27:55.584Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:27:55.584Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:27:55.584Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:27:56.204Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:27:56.205Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:27:56.206Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:27:56.882Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:27:56.883Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:27:57.732Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:27:59.356Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:27:59.998Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:27:59.999Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/components/CalendarDatePicker.tsx:4:38 -Type error: Binding element 'handleChange' implicitly has an 'any' type. - - 2 | import Datepicker from 'tailwind-datepicker-react'; - 3 | -> 4 | export const CalendarDatePicker = ({ handleChange }) => { - | ^ - 5 | const options = { - 6 | title: 'Date', - 7 | autoHide: true, - -[debug] [2024-07-08T21:28:09.675Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:28:29.556Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:28:29.557Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:28:29.557Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:28:29.557Z] Platform: darwin -[debug] [2024-07-08T21:28:29.557Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:28:29.560Z] Time: Mon Jul 08 2024 17:28:29 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:28:29.560Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:28:29.591Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:28:29.591Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:28:29.591Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:28:29.592Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:28:29.592Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:28:29.592Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:28:29.796Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:28:29.796Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:28:29.797Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:28:30.023Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:28:30.024Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:28:30.840Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:28:32.470Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:28:32.774Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:28:32.774Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] ⨯ ESLint: Failed to load config "airbnb-base" to extend from. Referenced from: /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/components/src/client/.eslintrc.json - -[info] Failed to compile. - - -[info] ./components/src/client/routes/booking/components/Calendars.tsx:26:22 -Type error: Parameter 'info' implicitly has an 'any' type. - - 24 | const [bookingTimeEvent, setBookingTimeEvent] = useState(); - 25 | -> 26 | const isOverlap = (info) => { - | ^ - 27 | return selectedRooms.some((room, i) => { - 28 | const calendarApi = room.calendarRef.current.getApi(); - 29 | - -[debug] [2024-07-08T21:28:42.660Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:43:26.080Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:43:26.081Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:43:26.081Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:43:26.081Z] Platform: darwin -[debug] [2024-07-08T21:43:26.081Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:43:26.085Z] Time: Mon Jul 08 2024 17:43:26 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:43:26.085Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:43:26.114Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:43:26.114Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:43:26.114Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:43:26.115Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:43:26.115Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:43:26.115Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:43:26.715Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:43:26.716Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:43:26.718Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:43:27.428Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:43:27.429Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:43:28.294Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:43:30.007Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:43:30.757Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:43:30.758Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] Disabled SWC as replacement for Babel because of custom Babel configuration ".babelrc" https://nextjs.org/docs/messages/swc-disabled - -[info] Using external babel configuration from /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.babelrc - -[info] ⚠ It looks like there is a custom Babel configuration that can be removed. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/aiplatform/v1.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/aiplatform/v1beta1.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/compute/v1.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/compute/alpha.js as it exceeds the max of 500KB. - -[info] [BABEL] Note: The code generator has deoptimised the styling of /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/googleapis/build/src/apis/compute/beta.js as it exceeds the max of 500KB. - -[info] Failed to compile. - - -[info] ./app/layout.tsx:3:1 -Syntax error: "next/font" requires SWC although Babel is being used due to a custom babel config being present. -Read more: https://nextjs.org/docs/messages/babel-font-loader-conflict - - -[info] - -[info] > Build failed because of webpack errors - -[debug] [2024-07-08T21:43:59.930Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/server/middleware-manifest.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-08T21:48:05.360Z] ---------------------------------------------------------------------- -[debug] [2024-07-08T21:48:05.361Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase deploy -[debug] [2024-07-08T21:48:05.361Z] CLI Version: 13.13.0 -[debug] [2024-07-08T21:48:05.361Z] Platform: darwin -[debug] [2024-07-08T21:48:05.361Z] Node Version: v18.17.0 -[debug] [2024-07-08T21:48:05.364Z] Time: Mon Jul 08 2024 17:48:05 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-08T21:48:05.364Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-08T21:48:05.396Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-08T21:48:05.397Z] > authorizing via signed-in user (rh3555@nyu.edu) -[debug] [2024-07-08T21:48:05.397Z] [iam] checking project flowing-mantis-389917 for permissions ["firebase.projects.get","firebasehosting.sites.update"] -[debug] [2024-07-08T21:48:05.398Z] >>> [apiv2][query] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions [none] -[debug] [2024-07-08T21:48:05.398Z] >>> [apiv2][(partial)header] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions x-goog-quota-user=projects/flowing-mantis-389917 -[debug] [2024-07-08T21:48:05.398Z] >>> [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:48:05.607Z] <<< [apiv2][status] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions 200 -[debug] [2024-07-08T21:48:05.607Z] <<< [apiv2][body] POST https://cloudresourcemanager.googleapis.com/v1/projects/flowing-mantis-389917:testIamPermissions {"permissions":["firebase.projects.get","firebasehosting.sites.update"]} -[debug] [2024-07-08T21:48:05.609Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:48:05.958Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-08T21:48:05.959Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_5d2cff49-f9b1-4ad8-a083-9037b60e7e48"} -[debug] [2024-07-08T21:48:06.782Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-08T21:48:08.449Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 [none] -[debug] [2024-07-08T21:48:08.752Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 200 -[debug] [2024-07-08T21:48:08.753Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites/flowing-mantis-389917 {"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"} -[info] ▲ Next.js 14.2.4 - -[info] - Environments: .env.local - - -[info] Creating an optimized production build ... - -[info] ✓ Compiled successfully - -[info] Linting and checking validity of types ... - -[info] Failed to compile. - -./components/src/client/routes/booking/components/Calendars.tsx:26:22 -Type error: Parameter 'info' implicitly has an 'any' type. - - 24 | const [bookingTimeEvent, setBookingTimeEvent] = useState(); - 25 | -> 26 | const isOverlap = (info) => { - | ^ - 27 | return selectedRooms.some((room, i) => { - 28 | const calendarApi = room.calendarRef.current.getApi(); - 29 | - -[debug] [2024-07-08T21:48:28.421Z] Error: ENOENT: no such file or directory, open '/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.next/export-marker.json' -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T14:59:05.210Z] ---------------------------------------------------------------------- -[debug] [2024-07-11T14:59:05.210Z] Command: /Users/takagiriho/.nvm/versions/node/v18.17.0/bin/node /usr/local/bin/firebase emulators:start -[debug] [2024-07-11T14:59:05.211Z] CLI Version: 13.13.0 -[debug] [2024-07-11T14:59:05.211Z] Platform: darwin -[debug] [2024-07-11T14:59:05.211Z] Node Version: v18.17.0 -[debug] [2024-07-11T14:59:05.211Z] Time: Thu Jul 11 2024 10:59:05 GMT-0400 (Eastern Daylight Time) -[debug] [2024-07-11T14:59:05.211Z] ---------------------------------------------------------------------- -[debug] -[debug] [2024-07-11T14:59:05.213Z] >>> [apiv2][query] GET https://firebase-public.firebaseio.com/cli.json [none] -[debug] [2024-07-11T14:59:05.263Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"] -[debug] [2024-07-11T14:59:05.263Z] > authorizing via signed-in user (rh3555@nyu.edu) -[info] i emulators: Starting emulators: hosting {"metadata":{"emulator":{"name":"hub"},"message":"Starting emulators: hosting"}} -[debug] [2024-07-11T14:59:05.718Z] [logging] Logging Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[warn] ⚠ hosting: Hosting Emulator unable to start on port 5000, starting on 5002 instead. {"metadata":{"emulator":{"name":"hosting"},"message":"Hosting Emulator unable to start on port 5000, starting on 5002 instead."}} -[debug] [2024-07-11T14:59:05.718Z] [hosting] Hosting Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[debug] [2024-07-11T14:59:05.718Z] assigned listening specs for emulators {"user":{"hub":[{"address":"127.0.0.1","family":"IPv4","port":4400},{"address":"::1","family":"IPv6","port":4400}],"logging":[{"address":"127.0.0.1","family":"IPv4","port":4500}],"hosting":[{"address":"127.0.0.1","family":"IPv4","port":5002}]},"metadata":{"message":"assigned listening specs for emulators"}} -[debug] [2024-07-11T14:59:05.721Z] [hub] writing locator at /var/folders/0t/pc3g628n3ylg5pnvtgy3y8h00000gn/T/hub-flowing-mantis-389917.json -[debug] [2024-07-11T14:59:05.725Z] > refreshing access token with scopes: [] -[debug] [2024-07-11T14:59:05.725Z] >>> [apiv2][query] POST https://www.googleapis.com/oauth2/v3/token [none] -[debug] [2024-07-11T14:59:05.726Z] >>> [apiv2][body] POST https://www.googleapis.com/oauth2/v3/token [omitted] -[debug] [2024-07-11T14:59:05.817Z] <<< [apiv2][status] GET https://firebase-public.firebaseio.com/cli.json 200 -[debug] [2024-07-11T14:59:05.818Z] <<< [apiv2][body] GET https://firebase-public.firebaseio.com/cli.json {"cloudBuildErrorAfter":1594252800000,"cloudBuildWarnAfter":1590019200000,"defaultNode10After":1594252800000,"minVersion":"3.0.5","node8DeploysDisabledAfter":1613390400000,"node8RuntimeDisabledAfter":1615809600000,"node8WarnAfter":1600128000000} -[debug] [2024-07-11T14:59:06.235Z] <<< [apiv2][status] POST https://www.googleapis.com/oauth2/v3/token 200 -[debug] [2024-07-11T14:59:06.235Z] <<< [apiv2][body] POST https://www.googleapis.com/oauth2/v3/token [omitted] -[debug] [2024-07-11T14:59:06.248Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 [none] -[debug] [2024-07-11T14:59:06.906Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 200 -[debug] [2024-07-11T14:59:06.907Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917 {"projectId":"flowing-mantis-389917","projectNumber":"289027705740","displayName":"sharedSpaceBooking","name":"projects/flowing-mantis-389917","resources":{"hostingSite":"flowing-mantis-389917","locationId":"us-central"},"state":"ACTIVE","etag":"1_eef0285d-0deb-4624-91cc-ffd3664399c8"} -[debug] [2024-07-11T14:59:07.796Z] defaultcredentials: writing to file /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json -[debug] [2024-07-11T14:59:07.798Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites pageToken=&pageSize=10 -[debug] [2024-07-11T14:59:08.549Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites 200 -[debug] [2024-07-11T14:59:08.550Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites {"sites":[{"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"}]} -[debug] [2024-07-11T14:59:08.551Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/-/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config [none] -[debug] [2024-07-11T14:59:09.153Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/-/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config 200 -[debug] [2024-07-11T14:59:09.153Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/-/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config {"projectId":"flowing-mantis-389917","appId":"1:289027705740:web:31820e33df9be4d65d24a9","storageBucket":"flowing-mantis-389917.appspot.com","locationId":"us-central","apiKey":"AIzaSyDdFBW-MHSbBCHg-6TLBgJUmp1hacx7Vr8","authDomain":"flowing-mantis-389917.firebaseapp.com","messagingSenderId":"289027705740","measurementId":"G-B2TH2J5RFR"} -[info] - Thank you for trying our early preview of Next.js support on Firebase Hosting. - During the preview, support is best-effort and breaking changes can be expected. Proceed with caution. - The integration is known to work with Next.js version 12 - 14.0. You may encounter errors. - - Documentation: https://firebase.google.com/docs/hosting/frameworks/nextjs - File a bug: https://github.com/firebase/firebase-tools/issues/new?template=bug_report.md - Submit a feature request: https://github.com/firebase/firebase-tools/issues/new?template=feature_request.md - - We'd love to learn from you. Express your interest in helping us shape the future of Firebase Hosting: https://goo.gle/41enW5X - -[debug] [2024-07-11T14:59:21.854Z] [web frameworks] effective firebase.json: { - "hosting": [ - { - "source": ".", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], - "frameworksBackend": { - "region": "us-central1" - }, - "site": "flowing-mantis-389917", - "rewrites": [ - { - "source": "**", - "function": { - "functionId": "ssrflowingmantis389917", - "region": "us-central1", - "pinTag": true - } - } - ], - "redirects": [], - "headers": [ - { - "source": "**/*.[jt]s", - "headers": [ - { - "key": "Set-Cookie", - "value": "__FIREBASE_DEFAULTS__=eyJjb25maWciOnsicHJvamVjdElkIjoiZmxvd2luZy1tYW50aXMtMzg5OTE3IiwiYXBwSWQiOiIxOjI4OTAyNzcwNTc0MDp3ZWI6MzE4MjBlMzNkZjliZTRkNjVkMjRhOSIsInN0b3JhZ2VCdWNrZXQiOiJmbG93aW5nLW1hbnRpcy0zODk5MTcuYXBwc3BvdC5jb20iLCJsb2NhdGlvbklkIjoidXMtY2VudHJhbCIsImFwaUtleSI6IkFJemFTeURkRkJXLU1IU2JCQ0hnLTZUTEJnSlVtcDFoYWN4N1ZyOCIsImF1dGhEb21haW4iOiJmbG93aW5nLW1hbnRpcy0zODk5MTcuZmlyZWJhc2VhcHAuY29tIiwibWVzc2FnaW5nU2VuZGVySWQiOiIyODkwMjc3MDU3NDAiLCJtZWFzdXJlbWVudElkIjoiRy1CMlRIMko1UkZSIn0sIl9hdXRoVG9rZW5TeW5jVVJMIjoiL19fc2Vzc2lvbiJ9; SameSite=Strict; Expires=2026-06-06T01:39:21.854Z; Path=/;" - } - ] - } - ], - "cleanUrls": true, - "public": ".firebase/flowing-mantis-389917/hosting", - "webFramework": "next_ssr" - } - ], - "functions": [ - { - "source": ".firebase/flowing-mantis-389917/functions", - "codebase": "firebase-frameworks-flowing-mantis-389917" - } - ] -} -[debug] [2024-07-11T14:59:22.130Z] [functions] Functions Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[debug] [2024-07-11T14:59:22.130Z] [eventarc] Eventarc Emulator only supports listening on one address (127.0.0.1). Not listening on ::1 -[debug] [2024-07-11T14:59:22.130Z] late-assigned ports for functions and eventarc emulators {"user":{"hub":[{"address":"127.0.0.1","family":"IPv4","port":4400},{"address":"::1","family":"IPv6","port":4400}],"logging":[{"address":"127.0.0.1","family":"IPv4","port":4500}],"hosting":[{"address":"127.0.0.1","family":"IPv4","port":5002}],"functions":[{"address":"127.0.0.1","family":"IPv4","port":5001}],"eventarc":[{"address":"127.0.0.1","family":"IPv4","port":9299}]},"metadata":{"message":"late-assigned ports for functions and eventarc emulators"}} -[warn] ⚠ functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, firestore, database, pubsub, storage, dataconnect {"metadata":{"emulator":{"name":"functions"},"message":"The following emulators are not running, calls to these services from the Functions emulator will affect production: \u001b[1mauth, firestore, database, pubsub, storage, dataconnect\u001b[22m"}} -[warn] ⚠ functions: Your GOOGLE_APPLICATION_CREDENTIALS environment variable points to /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json. Non-emulated services will access production using these credentials. Be careful! {"metadata":{"emulator":{"name":"functions"},"message":"Your GOOGLE_APPLICATION_CREDENTIALS environment variable points to /Users/takagiriho/.config/firebase/rh3555_nyu_edu_application_default_credentials.json. Non-emulated services will access production using these credentials. Be careful!"}} -[debug] [2024-07-11T14:59:22.134Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/adminSdkConfig [none] -[debug] [2024-07-11T14:59:22.504Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/adminSdkConfig 200 -[debug] [2024-07-11T14:59:22.505Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/adminSdkConfig {"projectId":"flowing-mantis-389917","storageBucket":"flowing-mantis-389917.appspot.com","locationId":"us-central"} -[debug] [2024-07-11T14:59:22.530Z] >>> [apiv2][query] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites -[debug] [2024-07-11T14:59:22.909Z] <<< [apiv2][status] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites 200 -[debug] [2024-07-11T14:59:22.909Z] <<< [apiv2][body] GET https://firebasehosting.googleapis.com/v1beta1/projects/flowing-mantis-389917/sites {"sites":[{"name":"projects/flowing-mantis-389917/sites/flowing-mantis-389917","defaultUrl":"https://flowing-mantis-389917.web.app","appId":"1:289027705740:web:31820e33df9be4d65d24a9","type":"DEFAULT_SITE"}]} -[debug] [2024-07-11T14:59:22.910Z] >>> [apiv2][query] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config [none] -[debug] [2024-07-11T14:59:23.412Z] <<< [apiv2][status] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config 200 -[debug] [2024-07-11T14:59:23.413Z] <<< [apiv2][body] GET https://firebase.googleapis.com/v1beta1/projects/flowing-mantis-389917/webApps/1:289027705740:web:31820e33df9be4d65d24a9/config {"projectId":"flowing-mantis-389917","appId":"1:289027705740:web:31820e33df9be4d65d24a9","storageBucket":"flowing-mantis-389917.appspot.com","locationId":"us-central","apiKey":"AIzaSyDdFBW-MHSbBCHg-6TLBgJUmp1hacx7Vr8","authDomain":"flowing-mantis-389917.firebaseapp.com","messagingSenderId":"289027705740","measurementId":"G-B2TH2J5RFR"} -[info] i hosting[flowing-mantis-389917]: Serving hosting files from: .firebase/flowing-mantis-389917/hosting {"metadata":{"emulator":{"name":"hosting"},"message":"Serving hosting files from: \u001b[1m.firebase/flowing-mantis-389917/hosting\u001b[22m"}} -[info] ✔ hosting[flowing-mantis-389917]: Local server: http://127.0.0.1:5002 {"metadata":{"emulator":{"name":"hosting"},"message":"Local server: \u001b[4m\u001b[1mhttp://127.0.0.1:5002\u001b[22m\u001b[24m"}} -[info] i functions: Watching "/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.firebase/flowing-mantis-389917/functions" for Cloud Functions... {"metadata":{"emulator":{"name":"functions"},"message":"Watching \"/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.firebase/flowing-mantis-389917/functions\" for Cloud Functions..."}} -[debug] [2024-07-11T14:59:23.539Z] Validating nodejs source -[warn] ⚠ functions: package.json indicates an outdated version of firebase-functions. Please upgrade using npm install --save firebase-functions@latest in your functions directory. -[warn] ⚠ functions: Please note that there will be breaking changes when you upgrade. -[debug] [2024-07-11T14:59:23.837Z] > [functions] package.json contents: { - "main": "server.js", - "dependencies": { - "firebase-frameworks": "^0.11.0", - "firebase-functions": "^4.5.0", - "firebase-admin": "^11.11.1" - }, - "engines": { - "node": "18" - } -} -[debug] [2024-07-11T14:59:23.837Z] Building nodejs source -[debug] [2024-07-11T14:59:23.837Z] Failed to find version of module node: reached end of search path /Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/.firebase/flowing-mantis-389917/functions/node_modules -[info] ✔ functions: Using node@18 from host. -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_PROJECT_ID: Error: Key FIREBASE_PROJECT_ID starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_PRIVATE_KEY_ID: Error: Key FIREBASE_PRIVATE_KEY_ID starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_PRIVATE_KEY: Error: Key FIREBASE_PRIVATE_KEY starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_CLIENT_EMAIL: Error: Key FIREBASE_CLIENT_EMAIL starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_CLIENT_ID: Error: Key FIREBASE_CLIENT_ID starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_AUTH_URI: Error: Key FIREBASE_AUTH_URI starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_TOKEN_URI: Error: Key FIREBASE_TOKEN_URI starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_AUTH_PROVIDER_X509_CERT_URL: Error: Key FIREBASE_AUTH_PROVIDER_X509_CERT_URL starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[debug] [2024-07-11T14:59:23.839Z] Failed to validate key FIREBASE_CLIENT_X509_CERT_URL: Error: Key FIREBASE_CLIENT_X509_CERT_URL starts with a reserved prefix (X_GOOGLE_ FIREBASE_ EXT_) -[error] ⬢ functions: Failed to load function definition from source: FirebaseError: Failed to load environment variables from .env.local. {"metadata":{"emulator":{"name":"functions"},"message":"Failed to load function definition from source: FirebaseError: Failed to load environment variables from .env.local."}} -[info] -┌─────────────────────────────────────────────────────────────┐ -│ ✔ All emulators ready! It is now safe to connect your app. │ -└─────────────────────────────────────────────────────────────┘ - -┌───────────┬────────────────┐ -│ Emulator │ Host:Port │ -├───────────┼────────────────┤ -│ Functions │ 127.0.0.1:5001 │ -├───────────┼────────────────┤ -│ Hosting │ 127.0.0.1:5002 │ -└───────────┴────────────────┘ - Emulator Hub running at 127.0.0.1:4400 - Other reserved ports: 4500 - -Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files. - -[debug] [2024-07-11T15:06:00.878Z] Received signal SIGHUP 1 -[info] -[info] i emulators: Received SIGHUP for the first time. Starting a clean shutdown. -[info] i emulators: Please wait for a clean shutdown or send the SIGHUP signal again to stop right now. -[debug] [2024-07-11T15:06:00.888Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger. [as info] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:81:14) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/emulator/commandUtils.js:171:29) - at process.emit (node:events:514:28) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.900Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.903Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.905Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.908Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.912Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.914Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.916Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.917Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.919Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.921Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.922Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.923Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.925Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.925Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.926Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.928Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.928Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.929Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.930Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[info] i emulators: Shutting down emulators. {"metadata":{"emulator":{"name":"hub"},"message":"Shutting down emulators."}} -[info] i functions: Stopping Functions Emulator {"metadata":{"emulator":{"name":"functions"},"message":"Stopping Functions Emulator"}} -[info] i hosting: Stopping Hosting Emulator {"metadata":{"emulator":{"name":"hosting"},"message":"Stopping Hosting Emulator"}} -[info] i eventarc: Stopping Eventarc Emulator {"metadata":{"emulator":{"name":"eventarc"},"message":"Stopping Eventarc Emulator"}} -[info] i hub: Stopping emulator hub {"metadata":{"emulator":{"name":"hub"},"message":"Stopping emulator hub"}} -[debug] [2024-07-11T15:06:00.943Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at Readable.read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:451:26) - at flow (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:858:34) - at File.pipeOnDrainFunctionResult (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:690:7) - at File.emit (node:events:514:28) - at File.emit (node:domain:489:12) - at onwriteDrain (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:453:12) - at afterWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:441:18) - at onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:436:7) - at WritableState.onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:160:5) - at PassThrough. (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/file.js:227:9) - at Object.onceWrapper (node:events:628:28) - at PassThrough.emit (node:events:514:28) - at PassThrough.emit (node:domain:489:12) - at onwriteDrain (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:453:12) - at afterWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:441:18) - at onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:436:7) - at WritableState.onwrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:160:5) - at PassThrough.afterTransform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:86:3) - at PassThrough._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_passthrough.js:36:3) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Readable.read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:428:10) - at maybeReadMore_ (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:550:12) - at process.processTicksAndRejections (node:internal/process/task_queues:82:21) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.943Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.945Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.945Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at Console.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/transports/console.js:79:23) - at Console._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/modern.js:103:17) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston-transport/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.ondata (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:629:20) - at DerivedLogger.emit (node:events:526:35) - at DerivedLogger.emit (node:domain:489:12) - at addChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:279:12) - at readableAddChunk (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:262:11) - at Readable.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_readable.js:228:10) - at Transform.push (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:132:32) - at DerivedLogger._transform (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:313:12) - at Transform._read (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:166:10) - at Transform._write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_transform.js:155:83) - at doWrite (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:390:139) - at writeOrBuffer (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:381:5) - at Writable.write (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/node_modules/readable-stream/lib/_stream_writable.js:302:11) - at DerivedLogger.log (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/logger.js:231:12) - at DerivedLogger.newLogFunc [as log] (/usr/local/lib/node_modules/firebase-tools/lib/logger.js:15:16) - at DerivedLogger. [as error] (/usr/local/lib/node_modules/firebase-tools/node_modules/winston/lib/winston/create-logger.js:87:14) - at logError (/usr/local/lib/node_modules/firebase-tools/lib/logError.js:22:25) - at errorOut (/usr/local/lib/node_modules/firebase-tools/lib/errorOut.js:17:29) - at process. (/usr/local/lib/node_modules/firebase-tools/lib/bin/firebase.js:124:29) - at process.emit (node:events:526:35) - at process._fatalException (node:internal/process/execution:149:25) -[error] -[error] Error: An unexpected error has occurred. -[debug] [2024-07-11T15:06:00.947Z] Error: write EIO - at afterWriteDispatched (node:internal/stream_base_commons:160:15) - at writeGeneric (node:internal/stream_base_commons:151:3) - at Socket._writeGeneric (node:net:931:11) - at Socket._write (node:net:943:8) - at writeOrBuffer (node:internal/streams/writable:392:12) - at _write (node:internal/streams/writable:333:10) - at Writable.write (node:internal/streams/writable:337:10) - at console.value (node:internal/console/constructor:305:16) - at console.warn (node:internal/console/constructor:385:26) - at prefixedLog (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/build/output/log.js:80:31) - at Object.error (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/build/output/log.js:90:5) - at Object.logErrorWithOriginalStack (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.js:751:22) - at process.processTicksAndRejections (node:internal/process/task_queues:95:5) - at async DevBundlerService.logErrorWithOriginalStack (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/server/lib/dev-bundler-service.js:21:20) - at async DevServer.logErrorWithOriginalStack (/Users/takagiriho/ghq/github.com/ITPNYU/Booking-Tool/booking-app/node_modules/next/dist/server/dev/next-dev-server.js:393:9) diff --git a/booking-app/firebase.json b/booking-app/firebase.json deleted file mode 100644 index 9dd26e465..000000000 --- a/booking-app/firebase.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "hosting": { - "source": ".", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], - "frameworksBackend": { - "region": "us-central1" - } - } -} diff --git a/booking-app/init-db.sql b/booking-app/init-db.sql deleted file mode 100644 index 0290dd40d..000000000 --- a/booking-app/init-db.sql +++ /dev/null @@ -1,4 +0,0 @@ --- init-db.sql -CREATE DATABASE IF NOT EXISTS mydatabase_shadow; -GRANT ALL PRIVILEGES ON mydatabase_shadow.* TO 'myuser'@'%'; -FLUSH PRIVILEGES; diff --git a/booking-app/tailwind.config.ts b/booking-app/tailwind.config.ts deleted file mode 100644 index 7e4bd91a0..000000000 --- a/booking-app/tailwind.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Config } from "tailwindcss"; - -const config: Config = { - content: [ - "./pages/**/*.{js,ts,jsx,tsx,mdx}", - "./components/**/*.{js,ts,jsx,tsx,mdx}", - "./app/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - extend: { - backgroundImage: { - "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", - "gradient-conic": - "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", - }, - }, - }, - plugins: [], -}; -export default config; From 327f7f4a9b04c922c70e336223827823ad5e0812 Mon Sep 17 00:00:00 2001 From: n3xta Date: Mon, 22 Jun 2026 15:43:18 -0400 Subject: [PATCH 37/51] fix(resource-approvers): avoid theme palette assumptions --- .../components/src/client/routes/components/Table.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/booking-app/components/src/client/routes/components/Table.tsx b/booking-app/components/src/client/routes/components/Table.tsx index 6ad986d87..09a8cd22e 100644 --- a/booking-app/components/src/client/routes/components/Table.tsx +++ b/booking-app/components/src/client/routes/components/Table.tsx @@ -16,8 +16,11 @@ const Clip = styled(Box)` overflow-x: auto; `; +const DEFAULT_TABLE_BORDER = "#e3e3e3"; +const DEFAULT_TABLE_HEADER_BACKGROUND = "rgba(33, 33, 33, 0.08)"; + const TableCustom = styled(MuiTable)(({ theme }) => ({ - border: `1px solid ${theme.palette.custom?.border ?? theme.palette.divider}`, + border: `1px solid ${theme.palette.custom?.border ?? DEFAULT_TABLE_BORDER}`, borderCollapse: "separate", borderRadius: "0px 0px 4px 4px", // overflowX: "scroll", @@ -32,7 +35,8 @@ const TableCustom = styled(MuiTable)(({ theme }) => ({ })); const ShadedHeader = styled(TableHead)(({ theme }) => ({ - backgroundColor: theme.palette.custom?.gray ?? theme.palette.action.hover, + backgroundColor: + theme.palette.custom?.gray ?? DEFAULT_TABLE_HEADER_BACKGROUND, })); export const TableTopRow = styled(MuiTable)` From 66d5c8bc4f7bf05b3a18a694721f6863939870dd Mon Sep 17 00:00:00 2001 From: pearmini Date: Sun, 28 Jun 2026 09:34:07 -0400 Subject: [PATCH 38/51] Fix unit test ResizeObserver mock for Vitest 4 CI. Use constructable class mocks so MUI Tabs can call `new ResizeObserver()` without failing in CI. Co-authored-by: Cursor --- booking-app/tests/setup.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/booking-app/tests/setup.ts b/booking-app/tests/setup.ts index 9bb993a61..3e4f6e38c 100644 --- a/booking-app/tests/setup.ts +++ b/booking-app/tests/setup.ts @@ -103,16 +103,22 @@ Object.defineProperty(window, "matchMedia", { })), }); -// Mock IntersectionObserver -global.IntersectionObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +// Mock IntersectionObserver — must be a constructable class for components that use `new IntersectionObserver()` +class IntersectionObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + constructor(_callback: IntersectionObserverCallback) {} +} +global.IntersectionObserver = + IntersectionObserverMock as unknown as typeof IntersectionObserver; -// Mock ResizeObserver -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +// Mock ResizeObserver — must be a constructable class (MUI Tabs uses `new ResizeObserver()`) +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + constructor(_callback: ResizeObserverCallback) {} +} +global.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; From 4f503679fc3f0db7a308a5fb14bc21879fa88a1a Mon Sep 17 00:00:00 2001 From: n3xta Date: Sun, 28 Jun 2026 13:11:51 -0400 Subject: [PATCH 39/51] feat: add booking maintenance mode --- booking-app/app/api/bookings/route.ts | 32 ++- booking-app/app/api/permissions/route.ts | 40 ++- .../app/api/tenant-maintenance-mode/route.ts | 105 ++++++++ .../components/MaintenanceModeSettings.tsx | 101 ++++++++ .../routes/admin/components/Settings.tsx | 3 + .../routes/booking/formPages/LandingPage.tsx | 74 +++--- .../src/client/routes/components/Provider.tsx | 39 ++- booking-app/components/src/types.ts | 8 +- booking-app/lib/maintenanceModeServer.ts | 31 +++ booking-app/lib/utils/maintenanceMode.ts | 36 +++ ...api-bookings-maintenance-mode.unit.test.ts | 115 +++++++++ .../api-tenant-maintenance-mode.unit.test.ts | 228 ++++++++++++++++++ .../tests/unit/maintenanceMode.unit.test.ts | 62 +++++ 13 files changed, 819 insertions(+), 55 deletions(-) create mode 100644 booking-app/app/api/tenant-maintenance-mode/route.ts create mode 100644 booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx create mode 100644 booking-app/lib/maintenanceModeServer.ts create mode 100644 booking-app/lib/utils/maintenanceMode.ts create mode 100644 booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts create mode 100644 booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts create mode 100644 booking-app/tests/unit/maintenanceMode.unit.test.ts diff --git a/booking-app/app/api/bookings/route.ts b/booking-app/app/api/bookings/route.ts index e34de94eb..263cc7fd6 100644 --- a/booking-app/app/api/bookings/route.ts +++ b/booking-app/app/api/bookings/route.ts @@ -22,7 +22,10 @@ import { FormContextLevel, RoomSetting, } from "@/components/src/types"; -import { getSecondaryContactName } from "@/components/src/utils/formatters"; +import { + formatOrigin, + getSecondaryContactName, +} from "@/components/src/utils/formatters"; import { logServerBookingChange, serverGetNextSequentialId, @@ -37,13 +40,18 @@ import { sendHTMLEmail } from "@/app/lib/sendHTMLEmail"; import { DEFAULT_TENANT } from "@/components/src/constants/tenants"; import { CALENDAR_HIDE_STATUS, TableNames } from "@/components/src/policy"; -import { formatOrigin } from "@/components/src/utils/formatters"; import { getMediaCommonsServices, isMediaCommons, } from "@/components/src/utils/tenantUtils"; +import { + enforceRequestLimits, + getRequestLimitRoleKey, +} from "@/lib/bookingRequestLimits"; import { getCalendarClient } from "@/lib/googleClient"; +import { getMaintenanceModeSettings } from "@/lib/maintenanceModeServer"; import { applyEnvironmentCalendarIds } from "@/lib/utils/calendarEnvironment"; +import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; import { Timestamp } from "firebase-admin/firestore"; import { DateSelectArg } from "fullcalendar"; import { @@ -51,11 +59,6 @@ import { getAffiliationDisplayValues, getOtherDisplayFields, } from "./shared"; -import { - enforceRequestLimits, - getRequestLimitRoleKey, -} from "@/lib/bookingRequestLimits"; -import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; // Common function to create XState data structure export function createXStateData( @@ -233,7 +236,7 @@ async function createBookingCalendarEvent( throw Error(`calendarId not found for room ${room.roomId}`); } - const selectedRoomIds = selectedRooms.map((r) => r.roomId); + const selectedRoomIds = selectedRooms.map(r => r.roomId); const otherRoomEmails = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); @@ -526,6 +529,14 @@ export async function POST(request: NextRequest) { // Extract tenant from URL const tenant = extractTenantFromRequest(request); + const maintenanceMode = await getMaintenanceModeSettings(tenant); + if (maintenanceMode.enabled) { + return NextResponse.json( + { error: maintenanceMode.message, maintenanceMode: true }, + { status: 503 }, + ); + } + // Get tenant-specific flags const { isITP, isMediaCommons, usesXState } = getTenantFlags(tenant); @@ -980,7 +991,10 @@ export async function POST(request: NextRequest) { tenant ) { try { - await notifyServiceApproversForRequestedServices(calendarEventId, tenant); + await notifyServiceApproversForRequestedServices( + calendarEventId, + tenant, + ); } catch (notificationError) { console.error( `🚨 SERVICE APPROVER NOTIFICATION FAILED [${tenant?.toUpperCase()}]:`, diff --git a/booking-app/app/api/permissions/route.ts b/booking-app/app/api/permissions/route.ts index 5279a546b..4f3e5422a 100644 --- a/booking-app/app/api/permissions/route.ts +++ b/booking-app/app/api/permissions/route.ts @@ -8,6 +8,11 @@ import { } from "@/components/src/policy"; import { isValidTenant } from "@/components/src/constants/tenants"; import { PagePermission, SiteBannerSettings } from "@/components/src/types"; +import { + DEFAULT_MAINTENANCE_MODE_SETTINGS, + MAINTENANCE_MODE_SETTINGS_DOC_ID, + parseMaintenanceModeFromDoc, +} from "@/lib/utils/maintenanceMode"; import { DEFAULT_SITE_BANNER_COLOR_HEX, SITE_BANNER_SETTINGS_DOC_ID, @@ -78,13 +83,26 @@ export async function GET(req: NextRequest) { .doc(SITE_BANNER_SETTINGS_DOC_ID) .get() : Promise.resolve(null); - const [usersRightsSnap, superAdminSnap, approversSnap, settingsSnap] = - await Promise.all([ - usersRightsRef.get(), - superAdminRef.get(), - approversRef.get(), - settingsDocPromise, - ]); + const maintenanceDocPromise = + tenant && isValidTenant(tenant) + ? db + .collection(getTenantCollectionName(TableNames.SETTINGS, tenant)) + .doc(MAINTENANCE_MODE_SETTINGS_DOC_ID) + .get() + : Promise.resolve(null); + const [ + usersRightsSnap, + superAdminSnap, + approversSnap, + settingsSnap, + maintenanceSnap, + ] = await Promise.all([ + usersRightsRef.get(), + superAdminRef.get(), + approversRef.get(), + settingsDocPromise, + maintenanceDocPromise, + ]); const userRightsRecords = usersRightsSnap.docs.map(d => ({ id: d.id, @@ -170,6 +188,13 @@ export async function GET(req: NextRequest) { colorHex: DEFAULT_SITE_BANNER_COLOR_HEX, }; + const maintenanceMode = + maintenanceSnap && maintenanceSnap.exists + ? parseMaintenanceModeFromDoc( + maintenanceSnap.data() as Record, + ) + : DEFAULT_MAINTENANCE_MODE_SETTINGS; + return NextResponse.json({ pagePermission, adminUsers, @@ -178,6 +203,7 @@ export async function GET(req: NextRequest) { equipmentUsers, superAdminUsers, policySettings: { finalApproverEmail }, + maintenanceMode, siteBanner, }); } catch (error) { diff --git a/booking-app/app/api/tenant-maintenance-mode/route.ts b/booking-app/app/api/tenant-maintenance-mode/route.ts new file mode 100644 index 000000000..69e9016c3 --- /dev/null +++ b/booking-app/app/api/tenant-maintenance-mode/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isValidTenant } from "@/components/src/constants/tenants"; +import { TableNames, getTenantCollectionName } from "@/components/src/policy"; +import { PagePermission } from "@/components/src/types"; +import { resolveCallerRole } from "@/lib/api/authz"; +import { requireSession } from "@/lib/api/requireSession"; +import admin from "@/lib/firebase/server/firebaseAdmin"; +import { + DEFAULT_MAINTENANCE_MODE_MESSAGE, + MAINTENANCE_MODE_MESSAGE_MAX_LEN, + MAINTENANCE_MODE_SETTINGS_DOC_ID, +} from "@/lib/utils/maintenanceMode"; + +type MaintenanceModeBody = { + tenant?: string; + maintenanceMode?: unknown; +}; + +function isPlainRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +export async function PUT(req: NextRequest) { + const session = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: MaintenanceModeBody; + try { + body = (await req.json()) as MaintenanceModeBody; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const tenant = body.tenant?.trim(); + if (!tenant || !isValidTenant(tenant)) { + return NextResponse.json({ error: "Invalid tenant" }, { status: 400 }); + } + + const role = await resolveCallerRole(session, tenant); + if (role !== PagePermission.ADMIN && role !== PagePermission.SUPER_ADMIN) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const raw = body.maintenanceMode; + if (raw === undefined) { + return NextResponse.json( + { error: "maintenanceMode required" }, + { status: 400 }, + ); + } + if (!isPlainRecord(raw)) { + return NextResponse.json( + { error: "maintenanceMode must be a plain object" }, + { status: 400 }, + ); + } + if (!("enabled" in raw) || typeof raw.enabled !== "boolean") { + return NextResponse.json( + { error: "maintenanceMode.enabled must be a boolean" }, + { status: 400 }, + ); + } + if (!("message" in raw) || typeof raw.message !== "string") { + return NextResponse.json( + { error: "maintenanceMode.message must be a string" }, + { status: 400 }, + ); + } + + const message = + raw.message.trim().slice(0, MAINTENANCE_MODE_MESSAGE_MAX_LEN) || + DEFAULT_MAINTENANCE_MODE_MESSAGE; + + const db = admin.firestore(); + const ref = db + .collection(getTenantCollectionName(TableNames.SETTINGS, tenant)) + .doc(MAINTENANCE_MODE_SETTINGS_DOC_ID); + try { + await ref.set( + { + maintenanceMode: { enabled: raw.enabled, message }, + maintenanceModeUpdatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true }, + ); + } catch (error) { + console.error( + "[/api/tenant-maintenance-mode][PUT] Firestore write failed:", + error, + ); + return NextResponse.json( + { error: "Failed to save maintenance mode" }, + { status: 500 }, + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx b/booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx new file mode 100644 index 000000000..6a0a086b9 --- /dev/null +++ b/booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { + Box, + Button, + FormControlLabel, + Switch, + TextField, + Typography, +} from "@mui/material"; +import { useParams } from "next/navigation"; +import { useContext, useEffect, useState } from "react"; +import { DEFAULT_MAINTENANCE_MODE_MESSAGE } from "@/lib/utils/maintenanceMode"; +import { DatabaseContext } from "../../components/Provider"; + +export default function MaintenanceModeSettings() { + const { maintenanceMode, reloadAdminUsers } = useContext(DatabaseContext); + const params = useParams<{ tenant: string }>(); + const tenant = typeof params?.tenant === "string" ? params.tenant : undefined; + + const [enabled, setEnabled] = useState(maintenanceMode.enabled); + const [message, setMessage] = useState(maintenanceMode.message); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setEnabled(maintenanceMode.enabled); + setMessage(maintenanceMode.message || DEFAULT_MAINTENANCE_MODE_MESSAGE); + }, [maintenanceMode.enabled, maintenanceMode.message]); + + const handleSave = async () => { + if (!tenant) return; + setSaving(true); + setError(null); + try { + const res = await fetch("/api/tenant-maintenance-mode", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tenant, + maintenanceMode: { enabled, message }, + }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(body.error ?? `Save failed (${res.status})`); + } + await reloadAdminUsers(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }; + + return ( + + Maintenance mode + + Stop the public booking form from accepting new reservation requests. + + + setEnabled(value)} + color="primary" + /> + } + label="Maintenance mode enabled" + /> + + setMessage(event.target.value)} + multiline + minRows={4} + fullWidth + sx={{ mt: 2 }} + inputProps={{ maxLength: 4000 }} + disabled={saving} + /> + + + + {error && ( + + {error} + + )} + + + ); +} diff --git a/booking-app/components/src/client/routes/admin/components/Settings.tsx b/booking-app/components/src/client/routes/admin/components/Settings.tsx index dfb635433..8f0845a8d 100644 --- a/booking-app/components/src/client/routes/admin/components/Settings.tsx +++ b/booking-app/components/src/client/routes/admin/components/Settings.tsx @@ -8,6 +8,7 @@ import { BannedUsers } from "./Ban"; import BookingTypes from "./BookingTypes"; import { Departments } from "./Departments"; import ExportDatabase from "./ExportDatabase"; +import MaintenanceModeSettings from "./MaintenanceModeSettings"; import { PAUsers } from "./PAUsers"; import PolicySettings from "./PolicySettings"; import SafetyTrainedUsers from "./SafetyTraining"; @@ -25,6 +26,7 @@ const tabs = [ { label: "Ban", id: "ban" }, { label: "Booking Types", id: "bookingTypes" }, { label: "Policy Settings", id: "policy" }, + { label: "Maintenance mode", id: "maintenanceMode" }, { label: "Export", id: "export" }, { label: "Sync Calendars", id: "syncCalendars" }, { label: "Site banner", id: "siteBanner" }, @@ -61,6 +63,7 @@ export default function Settings() { {tab === "departments" && } {tab === "bookingTypes" && } {tab === "policy" && } + {tab === "maintenanceMode" && } {tab === "export" && } {tab === "syncCalendars" && } {tab === "siteBanner" && } diff --git a/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx b/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx index c57390342..a6e5f4b2a 100644 --- a/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx @@ -1,10 +1,11 @@ -import { Box, CircularProgress, Typography } from "@mui/material"; +import { Alert, Box, CircularProgress, Typography } from "@mui/material"; import { useTenantSchema } from "@/components/src/client/routes/components/SchemaProvider"; import Button from "@mui/material/Button"; import { styled } from "@mui/system"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useContext, useState } from "react"; +import { DatabaseContext } from "../../components/Provider"; const Center = styled(Box)` display: flex; @@ -32,6 +33,7 @@ export default function LandingPage() { const router = useRouter(); const { tenant } = useParams(); const schema = useTenantSchema(); + const { maintenanceMode, permissionsLoading } = useContext(DatabaseContext); const [isLoading, setIsLoading] = useState(false); const handleAccept = () => { @@ -46,34 +48,46 @@ export default function LandingPage() { padding={{ xs: 3 }} > {schema.tenant.nameForPolicy} Reservation Form -

Thank you for your interest in booking with the {schema.tenant.name}

- - - Please read our policy for using the {schema.tenant.nameForPolicy} - -
- - +

+ Thank you for your interest in booking with the {schema.tenant.name} +

+ {!permissionsLoading && maintenanceMode.enabled ? ( + + + + {maintenanceMode.message} + + + + ) : ( + + + Please read our policy for using the {schema.tenant.nameForPolicy} + +
+ + + )} ); } diff --git a/booking-app/components/src/client/routes/components/Provider.tsx b/booking-app/components/src/client/routes/components/Provider.tsx index 69b760fde..e7f60265a 100644 --- a/booking-app/components/src/client/routes/components/Provider.tsx +++ b/booking-app/components/src/client/routes/components/Provider.tsx @@ -19,6 +19,7 @@ import { clientFetchAllDataFromCollection, reviveTimestamps, } from "@/lib/firebase/firebase"; +import { DEFAULT_MAINTENANCE_MODE_SETTINGS } from "@/lib/utils/maintenanceMode"; import { DEFAULT_SITE_BANNER_COLOR_HEX } from "@/lib/utils/siteBannerHex"; import { AdminUser, @@ -29,6 +30,7 @@ import { BookingType, DepartmentType, Filters, + MaintenanceModeSettings, OperationHours, PaUser, PagePermission, @@ -64,6 +66,7 @@ export interface DatabaseContextType { paUsers: PaUser[]; superAdminUsers: AdminUser[]; policySettings: PolicySettings; + maintenanceMode: MaintenanceModeSettings; siteBanner: SiteBannerSettings; roomSettings: RoomSetting[]; safetyTrainedUsers: SafetyTraining[]; @@ -89,7 +92,7 @@ export interface DatabaseContextType { fetchAllBookings: (clicked: boolean) => Promise; updateBookingInList: ( calendarEventId: string, - updatedFields: Partial + updatedFields: Partial, ) => void; setFilters: (x: Filters) => void; setLoadMoreEnabled: (x: boolean) => void; @@ -114,6 +117,7 @@ export const DatabaseContext = createContext({ paUsers: [], superAdminUsers: [], policySettings: { finalApproverEmail: "" }, + maintenanceMode: DEFAULT_MAINTENANCE_MODE_SETTINGS, siteBanner: { enabled: false, message: "", @@ -168,6 +172,8 @@ export const DatabaseProvider = ({ const [policySettings, setPolicySettings] = useState({ finalApproverEmail: "", }); + const [maintenanceMode, setMaintenanceMode] = + useState(DEFAULT_MAINTENANCE_MODE_SETTINGS); const [siteBanner, setSiteBanner] = useState({ enabled: false, message: "", @@ -239,8 +245,14 @@ export const DatabaseProvider = ({ if (paEmails.includes(userEmail)) return PagePermission.PA; return PagePermission.BOOKING; - }, [userEmail, adminUsers, liaisonUsers, paUsers, equipmentUsers, superAdminUsers]); - + }, [ + userEmail, + adminUsers, + liaisonUsers, + paUsers, + equipmentUsers, + superAdminUsers, + ]); // Defer non-permission data fetches to pages that actually need them. // The landing page only needs permissions; booking/admin data loads on navigation. @@ -249,7 +261,17 @@ export const DatabaseProvider = ({ if (!pathname) return false; const segments = pathname.split("/").filter(Boolean); // e.g. /mc/book, /mc/edit, /mc/admin, /mc/walk-in, /mc/vip, /mc/services, /mc/pa, /mc/liaison, /mc/modification - const dataRoutes = ["book", "edit", "admin", "walk-in", "vip", "services", "pa", "liaison", "modification"]; + const dataRoutes = [ + "book", + "edit", + "admin", + "walk-in", + "vip", + "services", + "pa", + "liaison", + "modification", + ]; return segments.some((s) => dataRoutes.includes(s)); }, [pathname]); @@ -438,6 +460,7 @@ export const DatabaseProvider = ({ equipmentUsers: Approver[]; superAdminUsers: AdminUser[]; policySettings: PolicySettings; + maintenanceMode?: MaintenanceModeSettings; siteBanner?: SiteBannerSettings; }; setAdminUsers(data.adminUsers ?? []); @@ -445,8 +468,9 @@ export const DatabaseProvider = ({ setLiaisonUsers(data.liaisonUsers ?? []); setEquipmentUsers(data.equipmentUsers ?? []); setSuperAdminUsers(data.superAdminUsers ?? []); - setPolicySettings( - data.policySettings ?? { finalApproverEmail: "" }, + setPolicySettings(data.policySettings ?? { finalApproverEmail: "" }); + setMaintenanceMode( + data.maintenanceMode ?? DEFAULT_MAINTENANCE_MODE_SETTINGS, ); setSiteBanner( data.siteBanner ?? { @@ -552,7 +576,6 @@ export const DatabaseProvider = ({ }); } }); - } } else { // No rooms provided, fetch all (no resource filter) @@ -574,7 +597,6 @@ export const DatabaseProvider = ({ }); } }); - } } catch (error: any) { console.error( @@ -752,6 +774,7 @@ export const DatabaseProvider = ({ superAdminUsers, pagePermission, policySettings, + maintenanceMode, siteBanner, roomSettings, safetyTrainedUsers, diff --git a/booking-app/components/src/types.ts b/booking-app/components/src/types.ts index ea94bbe74..dfa170e21 100644 --- a/booking-app/components/src/types.ts +++ b/booking-app/components/src/types.ts @@ -197,7 +197,7 @@ export type Inputs = { staffing?: string; cleaning?: string; // origin of the booking - origin?: BookingOrigin + origin?: BookingOrigin; }; export type DepartmentType = { @@ -301,6 +301,12 @@ export type SiteBannerSettings = { colorHex: string; }; +/** Tenant-wide switch that disables new public booking requests. */ +export type MaintenanceModeSettings = { + enabled: boolean; + message: string; +}; + export enum Role { STUDENT = "Student", RESIDENT_FELLOW = "Resident/Fellow", diff --git a/booking-app/lib/maintenanceModeServer.ts b/booking-app/lib/maintenanceModeServer.ts new file mode 100644 index 000000000..48f4e8b9e --- /dev/null +++ b/booking-app/lib/maintenanceModeServer.ts @@ -0,0 +1,31 @@ +import { TableNames, getTenantCollectionName } from "@/components/src/policy"; +import admin from "@/lib/firebase/server/firebaseAdmin"; +import { + DEFAULT_MAINTENANCE_MODE_SETTINGS, + MAINTENANCE_MODE_SETTINGS_DOC_ID, + MaintenanceModeSettings, + parseMaintenanceModeFromDoc, +} from "@/lib/utils/maintenanceMode"; + +export async function getMaintenanceModeSettings( + tenant?: string, +): Promise { + if (!tenant) { + return DEFAULT_MAINTENANCE_MODE_SETTINGS; + } + + try { + const snap = await admin + .firestore() + .collection(getTenantCollectionName(TableNames.SETTINGS, tenant)) + .doc(MAINTENANCE_MODE_SETTINGS_DOC_ID) + .get(); + + return snap.exists + ? parseMaintenanceModeFromDoc(snap.data() as Record) + : DEFAULT_MAINTENANCE_MODE_SETTINGS; + } catch (error) { + console.error("[maintenanceMode] Failed to read settings:", error); + return DEFAULT_MAINTENANCE_MODE_SETTINGS; + } +} diff --git a/booking-app/lib/utils/maintenanceMode.ts b/booking-app/lib/utils/maintenanceMode.ts new file mode 100644 index 000000000..46e52c181 --- /dev/null +++ b/booking-app/lib/utils/maintenanceMode.ts @@ -0,0 +1,36 @@ +export const MAINTENANCE_MODE_SETTINGS_DOC_ID = "maintenanceMode"; + +export const MAINTENANCE_MODE_MESSAGE_MAX_LEN = 4000; + +export const DEFAULT_MAINTENANCE_MODE_MESSAGE = + "The booking tool is temporarily not accepting new requests. Please check back later."; + +export type MaintenanceModeSettings = { + enabled: boolean; + message: string; +}; + +export const DEFAULT_MAINTENANCE_MODE_SETTINGS: MaintenanceModeSettings = { + enabled: false, + message: DEFAULT_MAINTENANCE_MODE_MESSAGE, +}; + +export function parseMaintenanceModeFromDoc( + data: Record | undefined, +): MaintenanceModeSettings { + const raw = data?.maintenanceMode; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return DEFAULT_MAINTENANCE_MODE_SETTINGS; + } + + const record = raw as Record; + const message = + typeof record.message === "string" + ? record.message.slice(0, MAINTENANCE_MODE_MESSAGE_MAX_LEN) + : DEFAULT_MAINTENANCE_MODE_MESSAGE; + + return { + enabled: record.enabled === true, + message: message.trim() || DEFAULT_MAINTENANCE_MODE_MESSAGE, + }; +} diff --git a/booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts b/booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts new file mode 100644 index 000000000..8c605fa57 --- /dev/null +++ b/booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts @@ -0,0 +1,115 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + mockGetMaintenanceModeSettings: vi.fn(), + mockInsertEvent: vi.fn(), +})); + +vi.mock("@/lib/maintenanceModeServer", () => ({ + getMaintenanceModeSettings: (...args: unknown[]) => + mocks.mockGetMaintenanceModeSettings(...args), +})); + +vi.mock("@/components/src/client/utils/serverDate", () => ({ + toFirebaseTimestampFromString: vi.fn(), +})); + +vi.mock("@/components/src/server/admin", () => ({ + firstApproverEmails: vi.fn(), + serverApproveInstantBooking: vi.fn(), + serverSendBookingDetailEmail: vi.fn(), + serverUpdateDataByCalendarEventId: vi.fn(), +})); + +vi.mock("@/components/src/server/serviceApproverNotifications", () => ({ + isServicesRequestState: vi.fn(), + notifyServiceApproversForRequestedServices: vi.fn(), +})); + +vi.mock("@/components/src/server/calendars", () => ({ + bookingContentsToDescription: vi.fn(), + insertEvent: (...args: unknown[]) => mocks.mockInsertEvent(...args), +})); + +vi.mock("@/components/src/server/emails", () => ({ + getTenantEmailConfig: vi.fn(), +})); + +vi.mock("@/lib/firebase/server/adminDb", () => ({ + logServerBookingChange: vi.fn(), + serverGetNextSequentialId: vi.fn(), + serverSaveDataToFirestore: vi.fn(), + serverGetDocumentById: vi.fn(), +})); + +vi.mock("@/lib/stateMachines/itpBookingMachine", () => ({ + itpBookingMachine: { id: "itp" }, +})); + +vi.mock("@/lib/stateMachines/mcBookingMachine", () => ({ + mcBookingMachine: { id: "mc" }, +})); + +vi.mock("xstate", () => ({ + createActor: vi.fn(), +})); + +vi.mock("@/app/lib/sendHTMLEmail", () => ({ + sendHTMLEmail: vi.fn(), +})); + +vi.mock("@/lib/googleClient", () => ({ + getCalendarClient: vi.fn(), +})); + +vi.mock("@/lib/utils/calendarEnvironment", () => ({ + applyEnvironmentCalendarIds: vi.fn((resources) => resources), +})); + +vi.mock("@/lib/bookingRequestLimits", () => ({ + enforceRequestLimits: vi.fn(), + getRequestLimitRoleKey: vi.fn(), +})); + +import { POST } from "@/app/api/bookings/route"; + +const createPostRequest = () => + new NextRequest("http://localhost:3000/api/bookings", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/json", + "x-tenant": "mc", + }), + body: JSON.stringify({ + email: "requester@nyu.edu", + selectedRooms: [], + bookingCalendarInfo: null, + data: {}, + isAutoApproval: false, + }), + }); + +describe("POST /api/bookings maintenance mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 503 before creating a booking when maintenance mode is enabled", async () => { + mocks.mockGetMaintenanceModeSettings.mockResolvedValue({ + enabled: true, + message: "Requests are paused.", + }); + + const response = await POST(createPostRequest()); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body).toEqual({ + error: "Requests are paused.", + maintenanceMode: true, + }); + expect(mocks.mockGetMaintenanceModeSettings).toHaveBeenCalledWith("mc"); + expect(mocks.mockInsertEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts b/booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts new file mode 100644 index 000000000..60ea0d56a --- /dev/null +++ b/booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts @@ -0,0 +1,228 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PagePermission } from "@/components/src/types"; +import { + DEFAULT_MAINTENANCE_MODE_MESSAGE, + MAINTENANCE_MODE_SETTINGS_DOC_ID, +} from "@/lib/utils/maintenanceMode"; + +const maintenanceMode = (overrides: Record = {}) => ({ + enabled: false, + message: "Requests are paused.", + ...overrides, +}); + +const mocks = vi.hoisted(() => { + const mockSet = vi.fn(); + const mockDoc = vi.fn(() => ({ + set: (...args: unknown[]) => mockSet(...args), + })); + const mockCollection = vi.fn(() => ({ + doc: (...args: unknown[]) => mockDoc(...args), + })); + const mockFirestoreFn = Object.assign( + () => ({ + collection: (...args: unknown[]) => mockCollection(...args), + }), + { + FieldValue: { + serverTimestamp: vi.fn(() => ({ __serverTimestamp: true })), + }, + }, + ); + return { + mockRequireSession: vi.fn(), + mockResolveCallerRole: vi.fn(), + mockCollection, + mockDoc, + mockSet, + mockFirestoreFn, + }; +}); + +vi.mock("@/lib/api/requireSession", () => ({ + requireSession: () => mocks.mockRequireSession(), +})); + +vi.mock("@/lib/api/authz", () => ({ + resolveCallerRole: (...args: unknown[]) => + mocks.mockResolveCallerRole(...args), +})); + +vi.mock("@/lib/firebase/server/firebaseAdmin", () => ({ + default: { + firestore: mocks.mockFirestoreFn, + }, +})); + +import { PUT } from "@/app/api/tenant-maintenance-mode/route"; + +const createPutRequest = (body: object) => + new NextRequest("http://localhost:3000/api/tenant-maintenance-mode", { + method: "PUT", + headers: new Headers({ "Content-Type": "application/json" }), + body: JSON.stringify(body), + }); + +const parseJson = async (response: Response) => ({ + data: await response.json(), + status: response.status, +}); + +describe("PUT /api/tenant-maintenance-mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockRequireSession.mockResolvedValue({ + email: "admin@nyu.edu", + netId: "admin", + }); + mocks.mockResolveCallerRole.mockResolvedValue(PagePermission.ADMIN); + mocks.mockSet.mockResolvedValue(undefined); + }); + + it("returns 401 when session is missing", async () => { + mocks.mockRequireSession.mockResolvedValue(null); + + const res = await PUT( + createPutRequest({ + tenant: "mc", + maintenanceMode: maintenanceMode({ enabled: true }), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(401); + expect(data.error).toBe("Unauthorized"); + expect(mocks.mockSet).not.toHaveBeenCalled(); + }); + + it("returns 400 for invalid tenant", async () => { + const res = await PUT( + createPutRequest({ + tenant: "not-a-real-tenant", + maintenanceMode: maintenanceMode(), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(400); + expect(data.error).toBe("Invalid tenant"); + expect(mocks.mockSet).not.toHaveBeenCalled(); + }); + + it("returns 403 when caller is not admin or super-admin", async () => { + mocks.mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); + + const res = await PUT( + createPutRequest({ + tenant: "mc", + maintenanceMode: maintenanceMode({ enabled: true }), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(403); + expect(data.error).toBe("Forbidden"); + expect(mocks.mockSet).not.toHaveBeenCalled(); + }); + + it("returns 400 when maintenanceMode is missing", async () => { + const res = await PUT(createPutRequest({ tenant: "mc" })); + const { data, status } = await parseJson(res); + + expect(status).toBe(400); + expect(data.error).toBe("maintenanceMode required"); + }); + + it("returns 400 when enabled is not a boolean", async () => { + const res = await PUT( + createPutRequest({ + tenant: "mc", + maintenanceMode: maintenanceMode({ enabled: "false" }), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(400); + expect(data.error).toBe("maintenanceMode.enabled must be a boolean"); + }); + + it("returns 400 when message is not a string", async () => { + const res = await PUT( + createPutRequest({ + tenant: "mc", + maintenanceMode: maintenanceMode({ message: null }), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(400); + expect(data.error).toBe("maintenanceMode.message must be a string"); + }); + + it("uses the default message when saving a blank message", async () => { + const res = await PUT( + createPutRequest({ + tenant: "mc", + maintenanceMode: maintenanceMode({ enabled: true, message: " " }), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mocks.mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + maintenanceMode: { + enabled: true, + message: DEFAULT_MAINTENANCE_MODE_MESSAGE, + }, + }), + { merge: true }, + ); + }); + + it("returns 200 and writes Firestore for valid admin payload", async () => { + const res = await PUT( + createPutRequest({ + tenant: "mc", + maintenanceMode: maintenanceMode({ + enabled: true, + message: "Requests are paused for maintenance.", + }), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mocks.mockCollection).toHaveBeenCalledWith("mc-settings"); + expect(mocks.mockDoc).toHaveBeenCalledWith( + MAINTENANCE_MODE_SETTINGS_DOC_ID, + ); + expect(mocks.mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + maintenanceMode: { + enabled: true, + message: "Requests are paused for maintenance.", + }, + }), + { merge: true }, + ); + }); + + it("returns 500 with JSON when Firestore set throws", async () => { + mocks.mockSet.mockRejectedValueOnce(new Error("network")); + + const res = await PUT( + createPutRequest({ + tenant: "mc", + maintenanceMode: maintenanceMode(), + }), + ); + const { data, status } = await parseJson(res); + + expect(status).toBe(500); + expect(data.error).toBe("Failed to save maintenance mode"); + }); +}); diff --git a/booking-app/tests/unit/maintenanceMode.unit.test.ts b/booking-app/tests/unit/maintenanceMode.unit.test.ts new file mode 100644 index 000000000..eadec1a36 --- /dev/null +++ b/booking-app/tests/unit/maintenanceMode.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MAINTENANCE_MODE_MESSAGE, + DEFAULT_MAINTENANCE_MODE_SETTINGS, + MAINTENANCE_MODE_MESSAGE_MAX_LEN, + parseMaintenanceModeFromDoc, +} from "@/lib/utils/maintenanceMode"; + +describe("parseMaintenanceModeFromDoc", () => { + it("returns disabled defaults when the document has no maintenance mode", () => { + expect(parseMaintenanceModeFromDoc(undefined)).toEqual( + DEFAULT_MAINTENANCE_MODE_SETTINGS, + ); + expect(parseMaintenanceModeFromDoc({})).toEqual( + DEFAULT_MAINTENANCE_MODE_SETTINGS, + ); + }); + + it("parses enabled state and message", () => { + expect( + parseMaintenanceModeFromDoc({ + maintenanceMode: { + enabled: true, + message: "Requests are paused.", + }, + }), + ).toEqual({ + enabled: true, + message: "Requests are paused.", + }); + }); + + it("falls back to the default message for blank or invalid messages", () => { + expect( + parseMaintenanceModeFromDoc({ + maintenanceMode: { enabled: true, message: " " }, + }), + ).toEqual({ + enabled: true, + message: DEFAULT_MAINTENANCE_MODE_MESSAGE, + }); + + expect( + parseMaintenanceModeFromDoc({ + maintenanceMode: { enabled: true, message: null }, + }), + ).toEqual({ + enabled: true, + message: DEFAULT_MAINTENANCE_MODE_MESSAGE, + }); + }); + + it("caps stored messages to the maximum length", () => { + const longMessage = "x".repeat(MAINTENANCE_MODE_MESSAGE_MAX_LEN + 1); + + expect( + parseMaintenanceModeFromDoc({ + maintenanceMode: { enabled: true, message: longMessage }, + }).message, + ).toHaveLength(MAINTENANCE_MODE_MESSAGE_MAX_LEN); + }); +}); From 4632427a4c6d92797a31e8a7de258c4bfbcf3a54 Mon Sep 17 00:00:00 2001 From: n3xta Date: Sun, 28 Jun 2026 13:18:01 -0400 Subject: [PATCH 40/51] Revert "feat: add booking maintenance mode" This reverts commit 4f503679fc3f0db7a308a5fb14bc21879fa88a1a. --- booking-app/app/api/bookings/route.ts | 32 +-- booking-app/app/api/permissions/route.ts | 40 +-- .../app/api/tenant-maintenance-mode/route.ts | 105 -------- .../components/MaintenanceModeSettings.tsx | 101 -------- .../routes/admin/components/Settings.tsx | 3 - .../routes/booking/formPages/LandingPage.tsx | 74 +++--- .../src/client/routes/components/Provider.tsx | 39 +-- booking-app/components/src/types.ts | 8 +- booking-app/lib/maintenanceModeServer.ts | 31 --- booking-app/lib/utils/maintenanceMode.ts | 36 --- ...api-bookings-maintenance-mode.unit.test.ts | 115 --------- .../api-tenant-maintenance-mode.unit.test.ts | 228 ------------------ .../tests/unit/maintenanceMode.unit.test.ts | 62 ----- 13 files changed, 55 insertions(+), 819 deletions(-) delete mode 100644 booking-app/app/api/tenant-maintenance-mode/route.ts delete mode 100644 booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx delete mode 100644 booking-app/lib/maintenanceModeServer.ts delete mode 100644 booking-app/lib/utils/maintenanceMode.ts delete mode 100644 booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts delete mode 100644 booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts delete mode 100644 booking-app/tests/unit/maintenanceMode.unit.test.ts diff --git a/booking-app/app/api/bookings/route.ts b/booking-app/app/api/bookings/route.ts index 263cc7fd6..e34de94eb 100644 --- a/booking-app/app/api/bookings/route.ts +++ b/booking-app/app/api/bookings/route.ts @@ -22,10 +22,7 @@ import { FormContextLevel, RoomSetting, } from "@/components/src/types"; -import { - formatOrigin, - getSecondaryContactName, -} from "@/components/src/utils/formatters"; +import { getSecondaryContactName } from "@/components/src/utils/formatters"; import { logServerBookingChange, serverGetNextSequentialId, @@ -40,18 +37,13 @@ import { sendHTMLEmail } from "@/app/lib/sendHTMLEmail"; import { DEFAULT_TENANT } from "@/components/src/constants/tenants"; import { CALENDAR_HIDE_STATUS, TableNames } from "@/components/src/policy"; +import { formatOrigin } from "@/components/src/utils/formatters"; import { getMediaCommonsServices, isMediaCommons, } from "@/components/src/utils/tenantUtils"; -import { - enforceRequestLimits, - getRequestLimitRoleKey, -} from "@/lib/bookingRequestLimits"; import { getCalendarClient } from "@/lib/googleClient"; -import { getMaintenanceModeSettings } from "@/lib/maintenanceModeServer"; import { applyEnvironmentCalendarIds } from "@/lib/utils/calendarEnvironment"; -import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; import { Timestamp } from "firebase-admin/firestore"; import { DateSelectArg } from "fullcalendar"; import { @@ -59,6 +51,11 @@ import { getAffiliationDisplayValues, getOtherDisplayFields, } from "./shared"; +import { + enforceRequestLimits, + getRequestLimitRoleKey, +} from "@/lib/bookingRequestLimits"; +import type { SchemaContextType } from "@/components/src/client/routes/components/SchemaProvider"; // Common function to create XState data structure export function createXStateData( @@ -236,7 +233,7 @@ async function createBookingCalendarEvent( throw Error(`calendarId not found for room ${room.roomId}`); } - const selectedRoomIds = selectedRooms.map(r => r.roomId); + const selectedRoomIds = selectedRooms.map((r) => r.roomId); const otherRoomEmails = otherRooms.map( (r: { calendarId: string }) => r.calendarId, ); @@ -529,14 +526,6 @@ export async function POST(request: NextRequest) { // Extract tenant from URL const tenant = extractTenantFromRequest(request); - const maintenanceMode = await getMaintenanceModeSettings(tenant); - if (maintenanceMode.enabled) { - return NextResponse.json( - { error: maintenanceMode.message, maintenanceMode: true }, - { status: 503 }, - ); - } - // Get tenant-specific flags const { isITP, isMediaCommons, usesXState } = getTenantFlags(tenant); @@ -991,10 +980,7 @@ export async function POST(request: NextRequest) { tenant ) { try { - await notifyServiceApproversForRequestedServices( - calendarEventId, - tenant, - ); + await notifyServiceApproversForRequestedServices(calendarEventId, tenant); } catch (notificationError) { console.error( `🚨 SERVICE APPROVER NOTIFICATION FAILED [${tenant?.toUpperCase()}]:`, diff --git a/booking-app/app/api/permissions/route.ts b/booking-app/app/api/permissions/route.ts index 4f3e5422a..5279a546b 100644 --- a/booking-app/app/api/permissions/route.ts +++ b/booking-app/app/api/permissions/route.ts @@ -8,11 +8,6 @@ import { } from "@/components/src/policy"; import { isValidTenant } from "@/components/src/constants/tenants"; import { PagePermission, SiteBannerSettings } from "@/components/src/types"; -import { - DEFAULT_MAINTENANCE_MODE_SETTINGS, - MAINTENANCE_MODE_SETTINGS_DOC_ID, - parseMaintenanceModeFromDoc, -} from "@/lib/utils/maintenanceMode"; import { DEFAULT_SITE_BANNER_COLOR_HEX, SITE_BANNER_SETTINGS_DOC_ID, @@ -83,26 +78,13 @@ export async function GET(req: NextRequest) { .doc(SITE_BANNER_SETTINGS_DOC_ID) .get() : Promise.resolve(null); - const maintenanceDocPromise = - tenant && isValidTenant(tenant) - ? db - .collection(getTenantCollectionName(TableNames.SETTINGS, tenant)) - .doc(MAINTENANCE_MODE_SETTINGS_DOC_ID) - .get() - : Promise.resolve(null); - const [ - usersRightsSnap, - superAdminSnap, - approversSnap, - settingsSnap, - maintenanceSnap, - ] = await Promise.all([ - usersRightsRef.get(), - superAdminRef.get(), - approversRef.get(), - settingsDocPromise, - maintenanceDocPromise, - ]); + const [usersRightsSnap, superAdminSnap, approversSnap, settingsSnap] = + await Promise.all([ + usersRightsRef.get(), + superAdminRef.get(), + approversRef.get(), + settingsDocPromise, + ]); const userRightsRecords = usersRightsSnap.docs.map(d => ({ id: d.id, @@ -188,13 +170,6 @@ export async function GET(req: NextRequest) { colorHex: DEFAULT_SITE_BANNER_COLOR_HEX, }; - const maintenanceMode = - maintenanceSnap && maintenanceSnap.exists - ? parseMaintenanceModeFromDoc( - maintenanceSnap.data() as Record, - ) - : DEFAULT_MAINTENANCE_MODE_SETTINGS; - return NextResponse.json({ pagePermission, adminUsers, @@ -203,7 +178,6 @@ export async function GET(req: NextRequest) { equipmentUsers, superAdminUsers, policySettings: { finalApproverEmail }, - maintenanceMode, siteBanner, }); } catch (error) { diff --git a/booking-app/app/api/tenant-maintenance-mode/route.ts b/booking-app/app/api/tenant-maintenance-mode/route.ts deleted file mode 100644 index 69e9016c3..000000000 --- a/booking-app/app/api/tenant-maintenance-mode/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { isValidTenant } from "@/components/src/constants/tenants"; -import { TableNames, getTenantCollectionName } from "@/components/src/policy"; -import { PagePermission } from "@/components/src/types"; -import { resolveCallerRole } from "@/lib/api/authz"; -import { requireSession } from "@/lib/api/requireSession"; -import admin from "@/lib/firebase/server/firebaseAdmin"; -import { - DEFAULT_MAINTENANCE_MODE_MESSAGE, - MAINTENANCE_MODE_MESSAGE_MAX_LEN, - MAINTENANCE_MODE_SETTINGS_DOC_ID, -} from "@/lib/utils/maintenanceMode"; - -type MaintenanceModeBody = { - tenant?: string; - maintenanceMode?: unknown; -}; - -function isPlainRecord(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype - ); -} - -export async function PUT(req: NextRequest) { - const session = await requireSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: MaintenanceModeBody; - try { - body = (await req.json()) as MaintenanceModeBody; - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - const tenant = body.tenant?.trim(); - if (!tenant || !isValidTenant(tenant)) { - return NextResponse.json({ error: "Invalid tenant" }, { status: 400 }); - } - - const role = await resolveCallerRole(session, tenant); - if (role !== PagePermission.ADMIN && role !== PagePermission.SUPER_ADMIN) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const raw = body.maintenanceMode; - if (raw === undefined) { - return NextResponse.json( - { error: "maintenanceMode required" }, - { status: 400 }, - ); - } - if (!isPlainRecord(raw)) { - return NextResponse.json( - { error: "maintenanceMode must be a plain object" }, - { status: 400 }, - ); - } - if (!("enabled" in raw) || typeof raw.enabled !== "boolean") { - return NextResponse.json( - { error: "maintenanceMode.enabled must be a boolean" }, - { status: 400 }, - ); - } - if (!("message" in raw) || typeof raw.message !== "string") { - return NextResponse.json( - { error: "maintenanceMode.message must be a string" }, - { status: 400 }, - ); - } - - const message = - raw.message.trim().slice(0, MAINTENANCE_MODE_MESSAGE_MAX_LEN) || - DEFAULT_MAINTENANCE_MODE_MESSAGE; - - const db = admin.firestore(); - const ref = db - .collection(getTenantCollectionName(TableNames.SETTINGS, tenant)) - .doc(MAINTENANCE_MODE_SETTINGS_DOC_ID); - try { - await ref.set( - { - maintenanceMode: { enabled: raw.enabled, message }, - maintenanceModeUpdatedAt: admin.firestore.FieldValue.serverTimestamp(), - }, - { merge: true }, - ); - } catch (error) { - console.error( - "[/api/tenant-maintenance-mode][PUT] Firestore write failed:", - error, - ); - return NextResponse.json( - { error: "Failed to save maintenance mode" }, - { status: 500 }, - ); - } - - return NextResponse.json({ ok: true }); -} diff --git a/booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx b/booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx deleted file mode 100644 index 6a0a086b9..000000000 --- a/booking-app/components/src/client/routes/admin/components/MaintenanceModeSettings.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { - Box, - Button, - FormControlLabel, - Switch, - TextField, - Typography, -} from "@mui/material"; -import { useParams } from "next/navigation"; -import { useContext, useEffect, useState } from "react"; -import { DEFAULT_MAINTENANCE_MODE_MESSAGE } from "@/lib/utils/maintenanceMode"; -import { DatabaseContext } from "../../components/Provider"; - -export default function MaintenanceModeSettings() { - const { maintenanceMode, reloadAdminUsers } = useContext(DatabaseContext); - const params = useParams<{ tenant: string }>(); - const tenant = typeof params?.tenant === "string" ? params.tenant : undefined; - - const [enabled, setEnabled] = useState(maintenanceMode.enabled); - const [message, setMessage] = useState(maintenanceMode.message); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - setEnabled(maintenanceMode.enabled); - setMessage(maintenanceMode.message || DEFAULT_MAINTENANCE_MODE_MESSAGE); - }, [maintenanceMode.enabled, maintenanceMode.message]); - - const handleSave = async () => { - if (!tenant) return; - setSaving(true); - setError(null); - try { - const res = await fetch("/api/tenant-maintenance-mode", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - tenant, - maintenanceMode: { enabled, message }, - }), - }); - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { - error?: string; - }; - throw new Error(body.error ?? `Save failed (${res.status})`); - } - await reloadAdminUsers(); - } catch (e) { - setError(e instanceof Error ? e.message : "Save failed"); - } finally { - setSaving(false); - } - }; - - return ( - - Maintenance mode - - Stop the public booking form from accepting new reservation requests. - - - setEnabled(value)} - color="primary" - /> - } - label="Maintenance mode enabled" - /> - - setMessage(event.target.value)} - multiline - minRows={4} - fullWidth - sx={{ mt: 2 }} - inputProps={{ maxLength: 4000 }} - disabled={saving} - /> - - - - {error && ( - - {error} - - )} - - - ); -} diff --git a/booking-app/components/src/client/routes/admin/components/Settings.tsx b/booking-app/components/src/client/routes/admin/components/Settings.tsx index 8f0845a8d..dfb635433 100644 --- a/booking-app/components/src/client/routes/admin/components/Settings.tsx +++ b/booking-app/components/src/client/routes/admin/components/Settings.tsx @@ -8,7 +8,6 @@ import { BannedUsers } from "./Ban"; import BookingTypes from "./BookingTypes"; import { Departments } from "./Departments"; import ExportDatabase from "./ExportDatabase"; -import MaintenanceModeSettings from "./MaintenanceModeSettings"; import { PAUsers } from "./PAUsers"; import PolicySettings from "./PolicySettings"; import SafetyTrainedUsers from "./SafetyTraining"; @@ -26,7 +25,6 @@ const tabs = [ { label: "Ban", id: "ban" }, { label: "Booking Types", id: "bookingTypes" }, { label: "Policy Settings", id: "policy" }, - { label: "Maintenance mode", id: "maintenanceMode" }, { label: "Export", id: "export" }, { label: "Sync Calendars", id: "syncCalendars" }, { label: "Site banner", id: "siteBanner" }, @@ -63,7 +61,6 @@ export default function Settings() { {tab === "departments" && } {tab === "bookingTypes" && } {tab === "policy" && } - {tab === "maintenanceMode" && } {tab === "export" && } {tab === "syncCalendars" && } {tab === "siteBanner" && } diff --git a/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx b/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx index a6e5f4b2a..c57390342 100644 --- a/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/LandingPage.tsx @@ -1,11 +1,10 @@ -import { Alert, Box, CircularProgress, Typography } from "@mui/material"; +import { Box, CircularProgress, Typography } from "@mui/material"; import { useTenantSchema } from "@/components/src/client/routes/components/SchemaProvider"; import Button from "@mui/material/Button"; import { styled } from "@mui/system"; import { useParams, useRouter } from "next/navigation"; -import { useContext, useState } from "react"; -import { DatabaseContext } from "../../components/Provider"; +import { useState } from "react"; const Center = styled(Box)` display: flex; @@ -33,7 +32,6 @@ export default function LandingPage() { const router = useRouter(); const { tenant } = useParams(); const schema = useTenantSchema(); - const { maintenanceMode, permissionsLoading } = useContext(DatabaseContext); const [isLoading, setIsLoading] = useState(false); const handleAccept = () => { @@ -48,46 +46,34 @@ export default function LandingPage() { padding={{ xs: 3 }} > {schema.tenant.nameForPolicy} Reservation Form -

- Thank you for your interest in booking with the {schema.tenant.name} -

- {!permissionsLoading && maintenanceMode.enabled ? ( - - - - {maintenanceMode.message} - - - - ) : ( - - - Please read our policy for using the {schema.tenant.nameForPolicy} - -
- - - )} +

Thank you for your interest in booking with the {schema.tenant.name}

+ + + Please read our policy for using the {schema.tenant.nameForPolicy} + +
+ + ); } diff --git a/booking-app/components/src/client/routes/components/Provider.tsx b/booking-app/components/src/client/routes/components/Provider.tsx index e7f60265a..69b760fde 100644 --- a/booking-app/components/src/client/routes/components/Provider.tsx +++ b/booking-app/components/src/client/routes/components/Provider.tsx @@ -19,7 +19,6 @@ import { clientFetchAllDataFromCollection, reviveTimestamps, } from "@/lib/firebase/firebase"; -import { DEFAULT_MAINTENANCE_MODE_SETTINGS } from "@/lib/utils/maintenanceMode"; import { DEFAULT_SITE_BANNER_COLOR_HEX } from "@/lib/utils/siteBannerHex"; import { AdminUser, @@ -30,7 +29,6 @@ import { BookingType, DepartmentType, Filters, - MaintenanceModeSettings, OperationHours, PaUser, PagePermission, @@ -66,7 +64,6 @@ export interface DatabaseContextType { paUsers: PaUser[]; superAdminUsers: AdminUser[]; policySettings: PolicySettings; - maintenanceMode: MaintenanceModeSettings; siteBanner: SiteBannerSettings; roomSettings: RoomSetting[]; safetyTrainedUsers: SafetyTraining[]; @@ -92,7 +89,7 @@ export interface DatabaseContextType { fetchAllBookings: (clicked: boolean) => Promise; updateBookingInList: ( calendarEventId: string, - updatedFields: Partial, + updatedFields: Partial ) => void; setFilters: (x: Filters) => void; setLoadMoreEnabled: (x: boolean) => void; @@ -117,7 +114,6 @@ export const DatabaseContext = createContext({ paUsers: [], superAdminUsers: [], policySettings: { finalApproverEmail: "" }, - maintenanceMode: DEFAULT_MAINTENANCE_MODE_SETTINGS, siteBanner: { enabled: false, message: "", @@ -172,8 +168,6 @@ export const DatabaseProvider = ({ const [policySettings, setPolicySettings] = useState({ finalApproverEmail: "", }); - const [maintenanceMode, setMaintenanceMode] = - useState(DEFAULT_MAINTENANCE_MODE_SETTINGS); const [siteBanner, setSiteBanner] = useState({ enabled: false, message: "", @@ -245,14 +239,8 @@ export const DatabaseProvider = ({ if (paEmails.includes(userEmail)) return PagePermission.PA; return PagePermission.BOOKING; - }, [ - userEmail, - adminUsers, - liaisonUsers, - paUsers, - equipmentUsers, - superAdminUsers, - ]); + }, [userEmail, adminUsers, liaisonUsers, paUsers, equipmentUsers, superAdminUsers]); + // Defer non-permission data fetches to pages that actually need them. // The landing page only needs permissions; booking/admin data loads on navigation. @@ -261,17 +249,7 @@ export const DatabaseProvider = ({ if (!pathname) return false; const segments = pathname.split("/").filter(Boolean); // e.g. /mc/book, /mc/edit, /mc/admin, /mc/walk-in, /mc/vip, /mc/services, /mc/pa, /mc/liaison, /mc/modification - const dataRoutes = [ - "book", - "edit", - "admin", - "walk-in", - "vip", - "services", - "pa", - "liaison", - "modification", - ]; + const dataRoutes = ["book", "edit", "admin", "walk-in", "vip", "services", "pa", "liaison", "modification"]; return segments.some((s) => dataRoutes.includes(s)); }, [pathname]); @@ -460,7 +438,6 @@ export const DatabaseProvider = ({ equipmentUsers: Approver[]; superAdminUsers: AdminUser[]; policySettings: PolicySettings; - maintenanceMode?: MaintenanceModeSettings; siteBanner?: SiteBannerSettings; }; setAdminUsers(data.adminUsers ?? []); @@ -468,9 +445,8 @@ export const DatabaseProvider = ({ setLiaisonUsers(data.liaisonUsers ?? []); setEquipmentUsers(data.equipmentUsers ?? []); setSuperAdminUsers(data.superAdminUsers ?? []); - setPolicySettings(data.policySettings ?? { finalApproverEmail: "" }); - setMaintenanceMode( - data.maintenanceMode ?? DEFAULT_MAINTENANCE_MODE_SETTINGS, + setPolicySettings( + data.policySettings ?? { finalApproverEmail: "" }, ); setSiteBanner( data.siteBanner ?? { @@ -576,6 +552,7 @@ export const DatabaseProvider = ({ }); } }); + } } else { // No rooms provided, fetch all (no resource filter) @@ -597,6 +574,7 @@ export const DatabaseProvider = ({ }); } }); + } } catch (error: any) { console.error( @@ -774,7 +752,6 @@ export const DatabaseProvider = ({ superAdminUsers, pagePermission, policySettings, - maintenanceMode, siteBanner, roomSettings, safetyTrainedUsers, diff --git a/booking-app/components/src/types.ts b/booking-app/components/src/types.ts index dfa170e21..ea94bbe74 100644 --- a/booking-app/components/src/types.ts +++ b/booking-app/components/src/types.ts @@ -197,7 +197,7 @@ export type Inputs = { staffing?: string; cleaning?: string; // origin of the booking - origin?: BookingOrigin; + origin?: BookingOrigin }; export type DepartmentType = { @@ -301,12 +301,6 @@ export type SiteBannerSettings = { colorHex: string; }; -/** Tenant-wide switch that disables new public booking requests. */ -export type MaintenanceModeSettings = { - enabled: boolean; - message: string; -}; - export enum Role { STUDENT = "Student", RESIDENT_FELLOW = "Resident/Fellow", diff --git a/booking-app/lib/maintenanceModeServer.ts b/booking-app/lib/maintenanceModeServer.ts deleted file mode 100644 index 48f4e8b9e..000000000 --- a/booking-app/lib/maintenanceModeServer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { TableNames, getTenantCollectionName } from "@/components/src/policy"; -import admin from "@/lib/firebase/server/firebaseAdmin"; -import { - DEFAULT_MAINTENANCE_MODE_SETTINGS, - MAINTENANCE_MODE_SETTINGS_DOC_ID, - MaintenanceModeSettings, - parseMaintenanceModeFromDoc, -} from "@/lib/utils/maintenanceMode"; - -export async function getMaintenanceModeSettings( - tenant?: string, -): Promise { - if (!tenant) { - return DEFAULT_MAINTENANCE_MODE_SETTINGS; - } - - try { - const snap = await admin - .firestore() - .collection(getTenantCollectionName(TableNames.SETTINGS, tenant)) - .doc(MAINTENANCE_MODE_SETTINGS_DOC_ID) - .get(); - - return snap.exists - ? parseMaintenanceModeFromDoc(snap.data() as Record) - : DEFAULT_MAINTENANCE_MODE_SETTINGS; - } catch (error) { - console.error("[maintenanceMode] Failed to read settings:", error); - return DEFAULT_MAINTENANCE_MODE_SETTINGS; - } -} diff --git a/booking-app/lib/utils/maintenanceMode.ts b/booking-app/lib/utils/maintenanceMode.ts deleted file mode 100644 index 46e52c181..000000000 --- a/booking-app/lib/utils/maintenanceMode.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const MAINTENANCE_MODE_SETTINGS_DOC_ID = "maintenanceMode"; - -export const MAINTENANCE_MODE_MESSAGE_MAX_LEN = 4000; - -export const DEFAULT_MAINTENANCE_MODE_MESSAGE = - "The booking tool is temporarily not accepting new requests. Please check back later."; - -export type MaintenanceModeSettings = { - enabled: boolean; - message: string; -}; - -export const DEFAULT_MAINTENANCE_MODE_SETTINGS: MaintenanceModeSettings = { - enabled: false, - message: DEFAULT_MAINTENANCE_MODE_MESSAGE, -}; - -export function parseMaintenanceModeFromDoc( - data: Record | undefined, -): MaintenanceModeSettings { - const raw = data?.maintenanceMode; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return DEFAULT_MAINTENANCE_MODE_SETTINGS; - } - - const record = raw as Record; - const message = - typeof record.message === "string" - ? record.message.slice(0, MAINTENANCE_MODE_MESSAGE_MAX_LEN) - : DEFAULT_MAINTENANCE_MODE_MESSAGE; - - return { - enabled: record.enabled === true, - message: message.trim() || DEFAULT_MAINTENANCE_MODE_MESSAGE, - }; -} diff --git a/booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts b/booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts deleted file mode 100644 index 8c605fa57..000000000 --- a/booking-app/tests/unit/api-bookings-maintenance-mode.unit.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - mockGetMaintenanceModeSettings: vi.fn(), - mockInsertEvent: vi.fn(), -})); - -vi.mock("@/lib/maintenanceModeServer", () => ({ - getMaintenanceModeSettings: (...args: unknown[]) => - mocks.mockGetMaintenanceModeSettings(...args), -})); - -vi.mock("@/components/src/client/utils/serverDate", () => ({ - toFirebaseTimestampFromString: vi.fn(), -})); - -vi.mock("@/components/src/server/admin", () => ({ - firstApproverEmails: vi.fn(), - serverApproveInstantBooking: vi.fn(), - serverSendBookingDetailEmail: vi.fn(), - serverUpdateDataByCalendarEventId: vi.fn(), -})); - -vi.mock("@/components/src/server/serviceApproverNotifications", () => ({ - isServicesRequestState: vi.fn(), - notifyServiceApproversForRequestedServices: vi.fn(), -})); - -vi.mock("@/components/src/server/calendars", () => ({ - bookingContentsToDescription: vi.fn(), - insertEvent: (...args: unknown[]) => mocks.mockInsertEvent(...args), -})); - -vi.mock("@/components/src/server/emails", () => ({ - getTenantEmailConfig: vi.fn(), -})); - -vi.mock("@/lib/firebase/server/adminDb", () => ({ - logServerBookingChange: vi.fn(), - serverGetNextSequentialId: vi.fn(), - serverSaveDataToFirestore: vi.fn(), - serverGetDocumentById: vi.fn(), -})); - -vi.mock("@/lib/stateMachines/itpBookingMachine", () => ({ - itpBookingMachine: { id: "itp" }, -})); - -vi.mock("@/lib/stateMachines/mcBookingMachine", () => ({ - mcBookingMachine: { id: "mc" }, -})); - -vi.mock("xstate", () => ({ - createActor: vi.fn(), -})); - -vi.mock("@/app/lib/sendHTMLEmail", () => ({ - sendHTMLEmail: vi.fn(), -})); - -vi.mock("@/lib/googleClient", () => ({ - getCalendarClient: vi.fn(), -})); - -vi.mock("@/lib/utils/calendarEnvironment", () => ({ - applyEnvironmentCalendarIds: vi.fn((resources) => resources), -})); - -vi.mock("@/lib/bookingRequestLimits", () => ({ - enforceRequestLimits: vi.fn(), - getRequestLimitRoleKey: vi.fn(), -})); - -import { POST } from "@/app/api/bookings/route"; - -const createPostRequest = () => - new NextRequest("http://localhost:3000/api/bookings", { - method: "POST", - headers: new Headers({ - "Content-Type": "application/json", - "x-tenant": "mc", - }), - body: JSON.stringify({ - email: "requester@nyu.edu", - selectedRooms: [], - bookingCalendarInfo: null, - data: {}, - isAutoApproval: false, - }), - }); - -describe("POST /api/bookings maintenance mode", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 503 before creating a booking when maintenance mode is enabled", async () => { - mocks.mockGetMaintenanceModeSettings.mockResolvedValue({ - enabled: true, - message: "Requests are paused.", - }); - - const response = await POST(createPostRequest()); - const body = await response.json(); - - expect(response.status).toBe(503); - expect(body).toEqual({ - error: "Requests are paused.", - maintenanceMode: true, - }); - expect(mocks.mockGetMaintenanceModeSettings).toHaveBeenCalledWith("mc"); - expect(mocks.mockInsertEvent).not.toHaveBeenCalled(); - }); -}); diff --git a/booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts b/booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts deleted file mode 100644 index 60ea0d56a..000000000 --- a/booking-app/tests/unit/api-tenant-maintenance-mode.unit.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { PagePermission } from "@/components/src/types"; -import { - DEFAULT_MAINTENANCE_MODE_MESSAGE, - MAINTENANCE_MODE_SETTINGS_DOC_ID, -} from "@/lib/utils/maintenanceMode"; - -const maintenanceMode = (overrides: Record = {}) => ({ - enabled: false, - message: "Requests are paused.", - ...overrides, -}); - -const mocks = vi.hoisted(() => { - const mockSet = vi.fn(); - const mockDoc = vi.fn(() => ({ - set: (...args: unknown[]) => mockSet(...args), - })); - const mockCollection = vi.fn(() => ({ - doc: (...args: unknown[]) => mockDoc(...args), - })); - const mockFirestoreFn = Object.assign( - () => ({ - collection: (...args: unknown[]) => mockCollection(...args), - }), - { - FieldValue: { - serverTimestamp: vi.fn(() => ({ __serverTimestamp: true })), - }, - }, - ); - return { - mockRequireSession: vi.fn(), - mockResolveCallerRole: vi.fn(), - mockCollection, - mockDoc, - mockSet, - mockFirestoreFn, - }; -}); - -vi.mock("@/lib/api/requireSession", () => ({ - requireSession: () => mocks.mockRequireSession(), -})); - -vi.mock("@/lib/api/authz", () => ({ - resolveCallerRole: (...args: unknown[]) => - mocks.mockResolveCallerRole(...args), -})); - -vi.mock("@/lib/firebase/server/firebaseAdmin", () => ({ - default: { - firestore: mocks.mockFirestoreFn, - }, -})); - -import { PUT } from "@/app/api/tenant-maintenance-mode/route"; - -const createPutRequest = (body: object) => - new NextRequest("http://localhost:3000/api/tenant-maintenance-mode", { - method: "PUT", - headers: new Headers({ "Content-Type": "application/json" }), - body: JSON.stringify(body), - }); - -const parseJson = async (response: Response) => ({ - data: await response.json(), - status: response.status, -}); - -describe("PUT /api/tenant-maintenance-mode", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.mockRequireSession.mockResolvedValue({ - email: "admin@nyu.edu", - netId: "admin", - }); - mocks.mockResolveCallerRole.mockResolvedValue(PagePermission.ADMIN); - mocks.mockSet.mockResolvedValue(undefined); - }); - - it("returns 401 when session is missing", async () => { - mocks.mockRequireSession.mockResolvedValue(null); - - const res = await PUT( - createPutRequest({ - tenant: "mc", - maintenanceMode: maintenanceMode({ enabled: true }), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(401); - expect(data.error).toBe("Unauthorized"); - expect(mocks.mockSet).not.toHaveBeenCalled(); - }); - - it("returns 400 for invalid tenant", async () => { - const res = await PUT( - createPutRequest({ - tenant: "not-a-real-tenant", - maintenanceMode: maintenanceMode(), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(400); - expect(data.error).toBe("Invalid tenant"); - expect(mocks.mockSet).not.toHaveBeenCalled(); - }); - - it("returns 403 when caller is not admin or super-admin", async () => { - mocks.mockResolveCallerRole.mockResolvedValue(PagePermission.BOOKING); - - const res = await PUT( - createPutRequest({ - tenant: "mc", - maintenanceMode: maintenanceMode({ enabled: true }), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(403); - expect(data.error).toBe("Forbidden"); - expect(mocks.mockSet).not.toHaveBeenCalled(); - }); - - it("returns 400 when maintenanceMode is missing", async () => { - const res = await PUT(createPutRequest({ tenant: "mc" })); - const { data, status } = await parseJson(res); - - expect(status).toBe(400); - expect(data.error).toBe("maintenanceMode required"); - }); - - it("returns 400 when enabled is not a boolean", async () => { - const res = await PUT( - createPutRequest({ - tenant: "mc", - maintenanceMode: maintenanceMode({ enabled: "false" }), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(400); - expect(data.error).toBe("maintenanceMode.enabled must be a boolean"); - }); - - it("returns 400 when message is not a string", async () => { - const res = await PUT( - createPutRequest({ - tenant: "mc", - maintenanceMode: maintenanceMode({ message: null }), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(400); - expect(data.error).toBe("maintenanceMode.message must be a string"); - }); - - it("uses the default message when saving a blank message", async () => { - const res = await PUT( - createPutRequest({ - tenant: "mc", - maintenanceMode: maintenanceMode({ enabled: true, message: " " }), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(200); - expect(data).toEqual({ ok: true }); - expect(mocks.mockSet).toHaveBeenCalledWith( - expect.objectContaining({ - maintenanceMode: { - enabled: true, - message: DEFAULT_MAINTENANCE_MODE_MESSAGE, - }, - }), - { merge: true }, - ); - }); - - it("returns 200 and writes Firestore for valid admin payload", async () => { - const res = await PUT( - createPutRequest({ - tenant: "mc", - maintenanceMode: maintenanceMode({ - enabled: true, - message: "Requests are paused for maintenance.", - }), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(200); - expect(data).toEqual({ ok: true }); - expect(mocks.mockCollection).toHaveBeenCalledWith("mc-settings"); - expect(mocks.mockDoc).toHaveBeenCalledWith( - MAINTENANCE_MODE_SETTINGS_DOC_ID, - ); - expect(mocks.mockSet).toHaveBeenCalledWith( - expect.objectContaining({ - maintenanceMode: { - enabled: true, - message: "Requests are paused for maintenance.", - }, - }), - { merge: true }, - ); - }); - - it("returns 500 with JSON when Firestore set throws", async () => { - mocks.mockSet.mockRejectedValueOnce(new Error("network")); - - const res = await PUT( - createPutRequest({ - tenant: "mc", - maintenanceMode: maintenanceMode(), - }), - ); - const { data, status } = await parseJson(res); - - expect(status).toBe(500); - expect(data.error).toBe("Failed to save maintenance mode"); - }); -}); diff --git a/booking-app/tests/unit/maintenanceMode.unit.test.ts b/booking-app/tests/unit/maintenanceMode.unit.test.ts deleted file mode 100644 index eadec1a36..000000000 --- a/booking-app/tests/unit/maintenanceMode.unit.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - DEFAULT_MAINTENANCE_MODE_MESSAGE, - DEFAULT_MAINTENANCE_MODE_SETTINGS, - MAINTENANCE_MODE_MESSAGE_MAX_LEN, - parseMaintenanceModeFromDoc, -} from "@/lib/utils/maintenanceMode"; - -describe("parseMaintenanceModeFromDoc", () => { - it("returns disabled defaults when the document has no maintenance mode", () => { - expect(parseMaintenanceModeFromDoc(undefined)).toEqual( - DEFAULT_MAINTENANCE_MODE_SETTINGS, - ); - expect(parseMaintenanceModeFromDoc({})).toEqual( - DEFAULT_MAINTENANCE_MODE_SETTINGS, - ); - }); - - it("parses enabled state and message", () => { - expect( - parseMaintenanceModeFromDoc({ - maintenanceMode: { - enabled: true, - message: "Requests are paused.", - }, - }), - ).toEqual({ - enabled: true, - message: "Requests are paused.", - }); - }); - - it("falls back to the default message for blank or invalid messages", () => { - expect( - parseMaintenanceModeFromDoc({ - maintenanceMode: { enabled: true, message: " " }, - }), - ).toEqual({ - enabled: true, - message: DEFAULT_MAINTENANCE_MODE_MESSAGE, - }); - - expect( - parseMaintenanceModeFromDoc({ - maintenanceMode: { enabled: true, message: null }, - }), - ).toEqual({ - enabled: true, - message: DEFAULT_MAINTENANCE_MODE_MESSAGE, - }); - }); - - it("caps stored messages to the maximum length", () => { - const longMessage = "x".repeat(MAINTENANCE_MODE_MESSAGE_MAX_LEN + 1); - - expect( - parseMaintenanceModeFromDoc({ - maintenanceMode: { enabled: true, message: longMessage }, - }).message, - ).toHaveLength(MAINTENANCE_MODE_MESSAGE_MAX_LEN); - }); -}); From 186d9e85076d01c82ddefcce594112d1bf20efe0 Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Thu, 2 Jul 2026 17:22:30 -0500 Subject: [PATCH 41/51] chore: remove Google Apps Script era leftovers The app was migrated from a Google Apps Script web app to Next.js; the GAS API layer is already gone but several orphaned artifacts remained. - Delete components/src/server/README.md (GAS build/runtime docs for files that no longer exist) - Delete components/src/server/auth.js (empty, 0 references) - Delete components/src/server/.babelrc (ES3 preset for the GAS Rhino runtime; Next.js uses SWC) - Delete components/src/test/ (standalone Express+ws mock harness with its own package.json; not imported anywhere) - Remove getOldSafetyTrainingEmails from db.ts (unused export returning [] plus a commented-out SpreadsheetApp block; 0 callers) - Drop the stale google.script.run wrapper comment in Ban.tsx (the component reads from Firestore via DatabaseContext) --- .../client/routes/admin/components/Ban.tsx | 1 - booking-app/components/src/server/.babelrc | 18 - booking-app/components/src/server/README.md | 19 - booking-app/components/src/server/auth.js | 0 booking-app/components/src/server/db.ts | 30 - .../components/src/test/fakeBookingData.ts | 105 -- booking-app/components/src/test/index.js | 33 - .../components/src/test/package-lock.json | 1317 ----------------- booking-app/components/src/test/package.json | 19 - booking-app/components/src/test/testData.ts | 112 -- 10 files changed, 1654 deletions(-) delete mode 100644 booking-app/components/src/server/.babelrc delete mode 100644 booking-app/components/src/server/README.md delete mode 100644 booking-app/components/src/server/auth.js delete mode 100644 booking-app/components/src/test/fakeBookingData.ts delete mode 100644 booking-app/components/src/test/index.js delete mode 100644 booking-app/components/src/test/package-lock.json delete mode 100644 booking-app/components/src/test/package.json delete mode 100644 booking-app/components/src/test/testData.ts diff --git a/booking-app/components/src/client/routes/admin/components/Ban.tsx b/booking-app/components/src/client/routes/admin/components/Ban.tsx index c91992336..cc9a5a71b 100644 --- a/booking-app/components/src/client/routes/admin/components/Ban.tsx +++ b/booking-app/components/src/client/routes/admin/components/Ban.tsx @@ -1,4 +1,3 @@ -// This is a wrapper for google.script.run that lets us use promises. import React, { useContext } from "react"; import { DatabaseContext } from "../../components/Provider"; diff --git a/booking-app/components/src/server/.babelrc b/booking-app/components/src/server/.babelrc deleted file mode 100644 index 2b14d1bc8..000000000 --- a/booking-app/components/src/server/.babelrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "presets": [ - [ - "@babel/env", - { - "modules": false - } - ] - ], - "plugins": [ - "transform-es3-property-literals", - "transform-es3-member-expression-literals", - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-transform-object-assign", - "@babel/plugin-proposal-optional-chaining" - ] -} diff --git a/booking-app/components/src/server/README.md b/booking-app/components/src/server/README.md deleted file mode 100644 index dfce47b4c..000000000 --- a/booking-app/components/src/server/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Server (our Google Apps Script code) - -This directory is where we store the source code for our Google Apps Script code, which runs on Google's servers. - -## Requirements - -The server code will need: -- an entrypoint, usually a file named `index.js`, that loads the app. -- the entry point will need to declare any public functions by attaching them to the "`global`" object, like this: - ```javascript - global.onOpen = someFunction; - ``` - -See the [ui.js](./ui.js) file for how to open menu items and set up the development settings properly. - -## Build - -Server-side code here will be compiled using settings that are compatible with the V8 or Rhino runtime (https://developers.google.com/apps-script/guides/v8-runtime). Update the [appsscript.json](../../appsscript.json) file as needed to switch runtimes. - diff --git a/booking-app/components/src/server/auth.js b/booking-app/components/src/server/auth.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/booking-app/components/src/server/db.ts b/booking-app/components/src/server/db.ts index ba1e20820..954573556 100644 --- a/booking-app/components/src/server/db.ts +++ b/booking-app/components/src/server/db.ts @@ -125,36 +125,6 @@ export const fetchAllBookings = async ( ); }; -export const getOldSafetyTrainingEmails = () => - // TODO: implement this - []; -// const activeSpreadSheet = SpreadsheetApp.openById( -// OLD_SAFETY_TRAINING_SHEET_ID -// ); -// const activeSheet = activeSpreadSheet.getSheetByName( -// OLD_SAFETY_TRAINING_SHEET_NAME -// ); -// var lastRow = activeSheet.getLastRow(); - -// // get all row3(email) data -// var range = activeSheet.getRange(1, 5, lastRow); -// var values = range.getValues(); - -// const secondSpreadSheet = SpreadsheetApp.openById( -// SECOND_OLD_SAFETY_TRAINING_SHEET_ID -// ); -// const secondSheet = secondSpreadSheet -// .getSheets() -// .find( -// (sheet) => sheet.getSheetId() === SECOND_OLD_SAFETY_TRAINING_SHEET_GID -// ); -// const secondLastRow = secondSheet.getLastRow(); -// const secondRange = secondSheet.getRange(1, 2, secondLastRow); -// const secondValues = secondRange.getValues(); - -// const combinedValues = [...values, ...secondValues]; -// return combinedValues; - export const decline = async ( id: string, email: string, diff --git a/booking-app/components/src/test/fakeBookingData.ts b/booking-app/components/src/test/fakeBookingData.ts deleted file mode 100644 index 197ca64a4..000000000 --- a/booking-app/components/src/test/fakeBookingData.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Booking, BookingStatus } from "../types"; - -function genFakeBookingRow( - calendarEventId: string, - email: string, - fakeData?: any -): Booking { - const today = new Date(); - today.setMinutes(0); // show a nice time - today.setSeconds(0); - today.setMilliseconds(0); - const endTime = new Date(); - endTime.setHours(today.getHours() + 4); - endTime.setMinutes(0); - endTime.setSeconds(0); - endTime.setMilliseconds(0); - - return { - calendarEventId, - roomId: "224", - email, - startDate: today, - endDate: endTime, - firstName: "Grace", - lastName: "Hopper", - secondaryName: "", - nNumber: "N12345678", - netId: "gh123", - phoneNumber: "555-123-4567", - department: "IDM", - role: "Student", - sponsorFirstName: "Noah", - sponsorLastName: "Pivnick", - sponsorEmail: "nnp278@nyu.edu", - title: "[Test] My Event", - description: "This is a fake booking for testing", - bookingType: "Workshop", - expectedAttendance: "1", - attendeeAffiliation: "NYU Members", - roomSetup: "no", - setupDetails: "", - mediaServices: "", - mediaServicesDetails: "", - equipmentServices: "", - equipmentServicesDetails: "", - staffingServices: "", - staffingServicesDetails: "", - catering: "no", - cateringService: "", - cleaningService: "no", - hireSecurity: "no", - chartFieldForCatering: "", - chartFieldForCleaning: "", - chartFieldForSecurity: "", - chartFieldForRoomSetup: "", - devBranch: "development", - ...fakeData, - }; -} - -function genFakeBookingStatusRow( - calendarEventId: string, - email: string, - fakeData?: any -): BookingStatus { - return { - calendarEventId, - email, - requestedAt: new Date(), - firstApprovedAt: "", - finalApprovedAt: "", - declinedAt: "", - canceledAt: "", - checkedInAt: "", - checkedOutAt: "", - noShowedAt: "", - ...fakeData, - }; -} - -function generateUUID() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - var r = (Math.random() * 16) | 0, - v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -export function addFakeBookingData( - n: number, - fakeData: any, - fakeBookingStatusData: any -) { - let calendarEventId; - const email = "booking-app-devs@itp.nyu.edu"; - const bookingRows = []; - const bookingStatusRows = []; - - for (let i = 0; i < n; i++) { - calendarEventId = generateUUID(); - bookingRows.push(genFakeBookingRow(calendarEventId, email, fakeData)); - } - - return { bookingRows }; -} diff --git a/booking-app/components/src/test/index.js b/booking-app/components/src/test/index.js deleted file mode 100644 index 50010a0ed..000000000 --- a/booking-app/components/src/test/index.js +++ /dev/null @@ -1,33 +0,0 @@ -const express = require('express'); -const bodyParser = require('body-parser'); -const http = require('http'); -const WebSocket = require('ws'); - -const app = express(); -app.use(bodyParser.json()); -const server = http.createServer(app); -const wss = new WebSocket.Server({ server }); -const port = 3001; - -let clients = []; - -wss.on('connection', function connection(ws) { - console.log('Client connected'); - clients.push(ws); - - ws.on('close', () => { - console.log('Client disconnected'); - clients = clients.filter((client) => client !== ws); - }); -}); - -app.post('/bookings', (req, res) => { - console.log(req.body); - console.log('Sending test data to client...'); - clients.forEach((client) => client.send('ADD:' + JSON.stringify(req.body))); - res.send({ status: 200, message: 'Success' }); -}); - -server.listen(port, () => { - console.log('Server started on port ' + port); -}); diff --git a/booking-app/components/src/test/package-lock.json b/booking-app/components/src/test/package-lock.json deleted file mode 100644 index de723d113..000000000 --- a/booking-app/components/src/test/package-lock.json +++ /dev/null @@ -1,1317 +0,0 @@ -{ - "name": "test", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "test", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "body-parser": "^1.20.2", - "ws": "^8.17.0" - }, - "devDependencies": { - "express": "^4.19.2", - "nodemon": "^3.1.3" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/nodemon": { - "version": "3.1.13", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.13.tgz", - "integrity": "sha512-nPN6L7A9cTA3BnJ3zZIibH5FiDh3GbmibeS17bl5YEU1IRO2mcfvR0ZJXH3ndoeKItjUcaX81FSKc/Kq/IiG6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "dev": true, - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/booking-app/components/src/test/package.json b/booking-app/components/src/test/package.json deleted file mode 100644 index 4049198e3..000000000 --- a/booking-app/components/src/test/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "test", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "start": "nodemon index.js" - }, - "author": "", - "license": "ISC", - "devDependencies": { - "express": "^4.19.2", - "nodemon": "^3.1.3" - }, - "dependencies": { - "body-parser": "^1.20.2", - "ws": "^8.17.0" - } -} diff --git a/booking-app/components/src/test/testData.ts b/booking-app/components/src/test/testData.ts deleted file mode 100644 index f9f8bc33d..000000000 --- a/booking-app/components/src/test/testData.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Options } from "yargs"; -import { addFakeBookingData } from "./fakeBookingData"; -import axios from "axios"; -import { hideBin } from "yargs/helpers"; -//@ts-ignore -import yargs from "yargs/yargs"; - -const SERVER_URL = "http://localhost:3001/bookings"; - -const args = process.argv.slice(2); -const n = parseInt(args[0], 10); - -if (isNaN(n)) { - console.error("Specify how many fake bookings to generate"); - process.exit(1); -} - -let fakeData = {}; -let fakeBookingStatusData = {}; - -const options: Record = { - calendarEventId: { type: "string" }, - roomId: { type: "string" }, - email: { type: "string" }, - startDate: { type: "string" }, - endDate: { type: "string" }, - firstName: { type: "string" }, - lastName: { type: "string" }, - secondaryName: { type: "string" }, - nNumber: { type: "string" }, - netId: { type: "string" }, - phoneNumber: { type: "string" }, - department: { type: "string" }, - role: { type: "string" }, - sponsorFirstName: { type: "string" }, - sponsorLastName: { type: "string" }, - sponsorEmail: { type: "string" }, - title: { type: "string" }, - description: { type: "string" }, - bookingType: { type: "string" }, - expectedAttendance: { type: "string" }, - attendeeAffiliation: { type: "string" }, - roomSetup: { type: "string" }, - setupDetails: { type: "string" }, - mediaServices: { type: "string" }, - mediaServicesDetails: { type: "string" }, - equipmentServices: { type: "string" }, - equipmentServicesDetails: { type: "string" }, - staffingServices: { type: "string" }, - staffingServicesDetails: { type: "string" }, - catering: { type: "string" }, - cateringService: { type: "string" }, - cleaningService: { type: "string" }, - hireSecurity: { type: "string" }, - chartFieldForCatering: { type: "string" }, - chartFieldForCleaning: { type: "string" }, - chartFieldForSecurity: { type: "string" }, - chartFieldForRoomSetup: { type: "string" }, - devBranch: { type: "string" }, - status: { type: "string" }, -}; - -const argv = yargs(hideBin(process.argv)) - .options(options) - .parserConfiguration({ "camel-case-expansion": false }).argv; - -const handleStatus = (status: string) => { - const date = new Date(); - switch (status) { - case "finalApproved": - fakeBookingStatusData["finalApprovedAt"] = date; - case "firstApproved": - fakeBookingStatusData["firstApprovedAt"] = date; - return; - case "declined": - fakeBookingStatusData["declinedAt"] = date; - return; - case "canceled": - fakeBookingStatusData["canceledAt"] = date; - return; - case "checkedIn": - fakeBookingStatusData["checkedInAt"] = date; - return; - case "noShow": - fakeBookingStatusData["noShowedAt"] = date; - } -}; - -Object.keys(argv).forEach((key) => { - if (key === "status") { - handleStatus(argv[key] as string); - } - if ( - key != "status" && - key !== "_" && - key !== "$0" && - argv[key] !== undefined - ) { - fakeData[key] = argv[key]; - } -}); - -const data = addFakeBookingData(n, fakeData, fakeBookingStatusData); -axios - .post(SERVER_URL, data) - .then((response) => console.log(response.data)) - .catch((error) => console.error(error)); - -// Usage: -// cd src/test -// start test server: npm run start -// npx tsx testData [number] [any args] From f5bed80b6e8e20232a92fed09bcb8f2f81284a5c Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Thu, 2 Jul 2026 17:29:43 -0500 Subject: [PATCH 42/51] security: stop exposing server secrets through next.config env Next.js inlines every `env` entry into any bundle that references it, including client bundles. The `env` block listed server-only secrets (FIREBASE_PRIVATE_KEY, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN, GOOGLE_API_KEY, NYU_API_CLIENT_SECRET, NYU_API_PASSWORD, and the rest of the Firebase admin service-account fields). No client code references them today, so nothing leaks currently, but a single client-side `process.env.X` reference would ship a secret to the browser. Keep only the browser-facing NEXT_PUBLIC_* values in `env`. Server code already reads these secrets from `process.env` directly at runtime (loaded from the deploy-time env file), which does not require an `env` entry. Verified no client-reachable file references any of the removed vars; `next build` passes. --- booking-app/next.config.mjs | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/booking-app/next.config.mjs b/booking-app/next.config.mjs index 2d829050f..2ebb9df1d 100644 --- a/booking-app/next.config.mjs +++ b/booking-app/next.config.mjs @@ -22,32 +22,17 @@ const nextConfig = { ignoreDuringBuilds: true, }, serverExternalPackages: ["newrelic"], + // Only non-secret, browser-facing values belong here. Next.js inlines every + // `env` entry into any bundle that references it (including client bundles), + // so server secrets must NOT be listed — a single client `process.env.X` + // reference would ship them to the browser. Server code reads secrets from + // `process.env` directly at runtime (loaded from the deploy-time env file), + // which does not require an `env` entry. env: { NEXT_PUBLIC_IS_TEST_ENV: isTestEnv ? "true" : "false", - FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID, - FIREBASE_PRIVATE_KEY_ID: process.env.FIREBASE_PRIVATE_KEY_ID, - FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY, - FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL, - FIREBASE_CLIENT_ID: process.env.FIREBASE_CLIENT_ID, - FIREBASE_AUTH_URI: process.env.FIREBASE_AUTH_URI, - FIREBASE_TOKEN_URI: process.env.FIREBASE_TOKEN_URI, - FIREBASE_AUTH_PROVIDER_X509_CERT_URL: - process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL, - FIREBASE_CLIENT_X509_CERT_URL: process.env.FIREBASE_CLIENT_X509_CERT_URL, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_BRANCH_NAME: process.env.NEXT_PUBLIC_BRANCH_NAME, NEXT_PUBLIC_GCP_LOG_NAME: process.env.NEXT_PUBLIC_GCP_LOG_NAME, - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, - GOOGLE_REDIRECT_URI: process.env.GOOGLE_REDIRECT_URI, - GOOGLE_REFRESH_TOKEN: process.env.GOOGLE_REFRESH_TOKEN, - GOOGLE_SHEET_ID: process.env.GOOGLE_SHEET_ID, - GOOGLE_SPREADSHEET_ID: process.env.GOOGLE_SPREADSHEET_ID, - GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, - NYU_API_CLIENT_ID: process.env.NYU_API_CLIENT_ID, - NYU_API_CLIENT_SECRET: process.env.NYU_API_CLIENT_SECRET, - NYU_API_USER_NAME: process.env.NYU_API_USER_NAME, - NYU_API_PASSWORD: process.env.NYU_API_PASSWORD, }, compiler: { styledComponents: true, From 3a0f0a62d7b9aa7b6e597c67477e5f3c4bdbc927 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:33:41 +0000 Subject: [PATCH 43/51] chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates Bumps the npm_and_yarn group with 3 updates in the /booking-app directory: [@opentelemetry/core](https://github.com/open-telemetry/opentelemetry-js), [form-data](https://github.com/form-data/form-data) and [undici](https://github.com/nodejs/undici). Updates `@opentelemetry/core` from 2.0.1 to 2.8.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.0.1...v2.8.0) Updates `form-data` from 4.0.5 to 4.0.6 - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `form-data` from 2.5.5 to 2.5.6 - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) Updates `undici` from 7.25.0 to 7.28.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.25.0...v7.28.0) --- updated-dependencies: - dependency-name: "@opentelemetry/core" dependency-version: 2.8.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: form-data dependency-version: 2.5.6 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: undici dependency-version: 7.28.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- booking-app/package-lock.json | 675 ++++++++++++++-------------------- booking-app/package.json | 2 +- 2 files changed, 279 insertions(+), 398 deletions(-) diff --git a/booking-app/package-lock.json b/booking-app/package-lock.json index 0747a5dd6..ae822467c 100644 --- a/booking-app/package-lock.json +++ b/booking-app/package-lock.json @@ -36,7 +36,7 @@ "handlebars": "^4.7.9", "json2csv": "^6.0.0-alpha.2", "mysql2": "^3.10.1", - "newrelic": "^13.12.0", + "newrelic": "^14.1.2", "next": "15.5.18", "next-auth": "^5.0.0-beta.30", "prop-types": "^15.8.1", @@ -100,18 +100,35 @@ } }, "node_modules/@apm-js-collab/code-transformer": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", - "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==", - "license": "Apache-2.0" + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.14.0.tgz", + "integrity": "sha512-6G+FETQ/VyRBsIkDDZ5sc9fb7O6d6W9rm8bZPHumISZw/6nwxvnS+VTyxd3sKM04aZwHG5hJIHb7VPdPCLieSQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/@apm-js-collab/code-transformer/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/@apm-js-collab/tracing-hooks": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz", - "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.9.1.tgz", + "integrity": "sha512-9uzuUftJ+xF90g5u411OgU+TpdZqqGB4igq2c8swiS7BfDGe16bnDRfQSCU6jxk+hjiospSG/t9FI4Ja9iw3+g==", "license": "Apache-2.0", "dependencies": { - "@apm-js-collab/code-transformer": "^0.8.0", + "@apm-js-collab/code-transformer": "^0.14.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } @@ -1733,6 +1750,32 @@ "node": ">=18" } }, + "node_modules/@datadog/pprof": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.16.0.tgz", + "integrity": "sha512-/m/YYXM/uPN5BVvRXjQaJWW9VF3rEOMaacvvjr9hdX2YFS0Tv9Tq440t/OIxunmQ5QGjRJs/qjabO+XE7Je0Vg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.8.4", + "pprof-format": "^2.2.1", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@datadog/pprof/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -4750,9 +4793,9 @@ } }, "node_modules/@newrelic/fn-inspect": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@newrelic/fn-inspect/-/fn-inspect-4.4.0.tgz", - "integrity": "sha512-VgoXZp3zqP1167XvrA772EHDFUNuYGQh14whFq1d2sE6dC3ZL46tXI9JY0yZdAAeyCERi7mhq7CPx9BYiTOl/A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@newrelic/fn-inspect/-/fn-inspect-5.1.0.tgz", + "integrity": "sha512-2ogTrZYTSr/hTh+kMIzzSOIGoary2MqUFxDV9abC2kBY3renTfTWMHM0dUkenBNFtjtQDyARDVwVqsdbbJSbSw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4762,13 +4805,13 @@ "prebuildify": "^6.0.1" }, "engines": { - "node": ">=16.9.1" + "node": ">=22" } }, "node_modules/@newrelic/native-metrics": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@newrelic/native-metrics/-/native-metrics-12.0.0.tgz", - "integrity": "sha512-l0MTkuazDMaEDWJk4ufHVgco4ssWhk/uSUYTn33dFOHAqpHLyxZHxxzKJb4vyZkhRy11UaKNhmNiAiXH557SJQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@newrelic/native-metrics/-/native-metrics-13.1.0.tgz", + "integrity": "sha512-MoBcOr3FmO7CUOLafnN9D8FGbpSYz2RTXk/yhXPm6lNkNge+IFgSGMowImYm8oYe20m3PN1Dv+bGqEhJ7UI+7g==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, @@ -4778,7 +4821,7 @@ "prebuildify": "^6.0.1" }, "engines": { - "node": ">=20", + "node": ">=22", "npm": ">=6" } }, @@ -5073,9 +5116,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.203.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.203.0.tgz", - "integrity": "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -5085,9 +5128,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5100,16 +5143,16 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.201.1.tgz", - "integrity": "sha512-LMRVg2yTev28L51RLLUK3gY0avMa1RVBq7IkYNtXDBxJRcd0TGGq/0rqfk7Y4UIM9NCJhDIUFHeGg8NpSgSWcw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz", + "integrity": "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/otlp-exporter-base": "0.201.1", - "@opentelemetry/otlp-transformer": "0.201.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-metrics": "2.0.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5118,128 +5161,14 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", - "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.201.1.tgz", - "integrity": "sha512-9ie2jcaUQZdIoe6B02r0rF4Gz+JsZ9mev/2pYou1N0woOUkFM8xwO6BAlORnrFVslqF/XO5WG3q5FsTbuC5iiw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.201.1", - "@opentelemetry/otlp-exporter-base": "0.201.1", - "@opentelemetry/otlp-transformer": "0.201.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-metrics": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", - "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.201.1.tgz", - "integrity": "sha512-FiS/mIWmZXyRxYGyXPHY+I/4+XrYVTD7Fz/zwOHkVPQsA1JTakAOP9fAi6trXMio0dIpzvQujLNiBqGM7ExrQw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/otlp-transformer": "0.201.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5248,34 +5177,18 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.201.1.tgz", - "integrity": "sha512-+q/8Yuhtu9QxCcjEAXEO8fXLjlSnrnVwfzi9jiWaMAppQp69MoagHHomQj02V2WnGjvBod5ajgkbK4IoWab50A==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.201.1", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/sdk-logs": "0.201.1", - "@opentelemetry/sdk-metrics": "2.0.1", - "@opentelemetry/sdk-trace-base": "2.0.1", - "protobufjs": "^7.3.0" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5284,106 +5197,13 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.201.1.tgz", - "integrity": "sha512-IxcFDP1IGMDemVFG2by/AMK+/o6EuBQ8idUq3xZ6MxgQGeumYZuX5OwR0h9HuvcUc/JPjQGfU5OHKIKYDJcXeA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-logs": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.201.1.tgz", - "integrity": "sha512-Ug8gtpssUNUnfpotB9ZhnSsPSGDu+7LngTMgKl31mmVJwLAKyl6jC8diZrMcGkSgBh0o5dbg9puvLyR25buZfw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.201.1", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", - "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", - "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/resources": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", - "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -5394,61 +5214,31 @@ } }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.203.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.203.0.tgz", - "integrity": "sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.203.0", - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.0.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", - "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/resources": "2.5.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5458,13 +5248,13 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", - "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/resources": "2.5.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -6304,7 +6094,6 @@ }, "node_modules/@types/estree": { "version": "1.0.8", - "dev": true, "license": "MIT" }, "node_modules/@types/hoist-non-react-statics": { @@ -6403,14 +6192,16 @@ } }, "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.5", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", "license": "MIT", "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, @@ -6418,6 +6209,19 @@ "node": ">= 0.12" } }, + "node_modules/@types/request/node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/@types/styled-components": { "version": "5.1.36", "dev": true, @@ -7674,6 +7478,7 @@ }, "node_modules/acorn": { "version": "8.15.0", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -7682,15 +7487,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -7959,6 +7755,15 @@ "dev": true, "license": "MIT" }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async-function": { "version": "1.0.0", "dev": true, @@ -8425,9 +8230,9 @@ "optional": true }, "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "license": "MIT" }, "node_modules/client-only": { @@ -9199,10 +9004,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "license": "MIT" }, "node_modules/es-object-atoms": { @@ -9908,7 +9712,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -9932,7 +9735,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -10399,19 +10201,33 @@ } }, "node_modules/form-data": { - "version": "4.0.5", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" } }, + "node_modules/form-data/node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -11072,15 +10888,17 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", - "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.0.tgz", + "integrity": "sha512-v4+k+PLt4z6g2ydEcXhfinTJxfgKJDWOW0v0GZNA5n2qToHFObpH4nGLhJdwnXnX4T14oREx/M62dS9GkpmSbQ==", "license": "Apache-2.0", "dependencies": { - "acorn": "^8.14.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" } }, "node_modules/imurmurhash": { @@ -12319,6 +12137,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/micromatch": { "version": "4.0.8", "license": "MIT", @@ -12472,9 +12299,9 @@ } }, "node_modules/nan": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", - "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "license": "MIT", "optional": true }, @@ -12520,32 +12347,33 @@ "license": "MIT" }, "node_modules/newrelic": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/newrelic/-/newrelic-13.12.0.tgz", - "integrity": "sha512-tLIhkHK2+5nYGeeuTMiKI/F0qUnk5rK4m6UV0M4bk7D5r80+FV0EbB9eUtepkFRSp1hqPO4SfumlraAKRJuDAw==", + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/newrelic/-/newrelic-14.1.2.tgz", + "integrity": "sha512-RYEIchr5bd7sJ6r6XE/GQQ7aXq9pTcqXulmN5Z7wCLqulXAGKZpHDbfWs0MxGixTHj3Xnm+YY5C4XhhmrnHWUg==", "license": "Apache-2.0", "dependencies": { - "@apm-js-collab/tracing-hooks": "^0.3.1", + "@apm-js-collab/tracing-hooks": "^0.9.1", "@grpc/grpc-js": "^1.13.2", - "@grpc/proto-loader": "^0.7.5", + "@grpc/proto-loader": "^0.8.1", "@newrelic/security-agent": "^3.0.0", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/api-logs": "^0.219.0", "@opentelemetry/core": "^2.0.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.201.1", + "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", + "@opentelemetry/otlp-exporter-base": "^0.219.0", + "@opentelemetry/otlp-transformer": "^0.219.0", "@opentelemetry/resources": "^2.0.1", - "@opentelemetry/sdk-logs": "^0.203.0", + "@opentelemetry/sdk-logs": "^0.219.0", "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-trace-base": "^2.0.0", "@tyriar/fibonacci-heap": "^2.0.7", "concat-stream": "^2.0.0", - "https-proxy-agent": "^7.0.1", - "import-in-the-middle": "^1.13.0", + "https-proxy-agent": "^9.0.0", + "import-in-the-middle": "^3.0.1", "json-bigint": "^1.0.0", "json-stringify-safe": "^5.0.0", "module-details-from-path": "^1.0.3", - "readable-stream": "^3.6.1", - "require-in-the-middle": "^7.4.0", + "require-in-the-middle": "^8.0.1", "semver": "^7.5.2", "winston-transport": "^4.5.0" }, @@ -12553,12 +12381,13 @@ "newrelic-naming-rules": "bin/test-naming-rules.js" }, "engines": { - "node": ">=20", + "node": ">=22", "npm": ">=6.0.0" }, "optionalDependencies": { - "@newrelic/fn-inspect": "^4.4.0", - "@newrelic/native-metrics": "^12.0.0", + "@datadog/pprof": "^5.13.3", + "@newrelic/fn-inspect": "^5.0.0", + "@newrelic/native-metrics": "^13.0.0", "@prisma/prisma-fmt-wasm": "^4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085" } }, @@ -12575,15 +12404,15 @@ "node": ">=12.10.0" } }, - "node_modules/newrelic/node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "node_modules/newrelic/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", - "protobufjs": "^7.5.3", + "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { @@ -12593,6 +12422,29 @@ "node": ">=6" } }, + "node_modules/newrelic/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/newrelic/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/newrelic/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -12719,9 +12571,9 @@ } }, "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", "license": "MIT", "optional": true, "dependencies": { @@ -12732,9 +12584,9 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -13206,9 +13058,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -13349,6 +13201,13 @@ "version": "4.2.0", "license": "MIT" }, + "node_modules/pprof-format": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.2.2.tgz", + "integrity": "sha512-hd90rHVDhNOhgHTmazVzDSVwTLOBjpZQ26AO/0j46sAFZ9uSWY0DcK2zJcwnuo2R6EzufBbOoWlPazA5nSyHcg==", + "license": "MIT", + "optional": true + }, "node_modules/preact": { "version": "10.12.1", "license": "MIT", @@ -13509,6 +13368,23 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -13519,9 +13395,9 @@ } }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "optional": true, "dependencies": { @@ -13773,17 +13649,16 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", - "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", "license": "MIT", "dependencies": { "debug": "^4.3.5", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.8" + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">=8.6.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/reselect": { @@ -14031,6 +13906,12 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, "node_modules/semver": { "version": "6.3.1", "license": "ISC", @@ -14754,9 +14635,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", "optional": true, "dependencies": { @@ -15376,9 +15257,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/booking-app/package.json b/booking-app/package.json index c4aca5584..d8ddcad35 100644 --- a/booking-app/package.json +++ b/booking-app/package.json @@ -52,7 +52,7 @@ "handlebars": "^4.7.9", "json2csv": "^6.0.0-alpha.2", "mysql2": "^3.10.1", - "newrelic": "^13.12.0", + "newrelic": "^14.1.2", "next": "15.5.18", "next-auth": "^5.0.0-beta.30", "prop-types": "^15.8.1", From 8530c177522fb43649527dd69c3dfecf82d92588 Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Fri, 3 Jul 2026 23:46:29 -0400 Subject: [PATCH 44/51] ci: add Claude PR review workflow Adds an automated AI code review on every non-draft PR targeting main, posting High/Medium severity findings as a single batched review. Adapted from the Bedrock-based setup in tesla-statement-ai-backend, using Claude subscription OAuth token auth (CLAUDE_CODE_OAUTH_TOKEN secret) instead of AWS Bedrock. Security design: the review agent is only allowed 'gh pr diff' and Write; the review is posted by a separate trusted step with a hardcoded endpoint, so a prompt injection in the PR diff cannot choose which API call runs. submit_review.py folds inline comments that GitHub cannot anchor to the diff into the review body to avoid 422s. --- .github/scripts/submit_review.py | 187 ++++++++++++++++++++++++++++ .github/workflows/claude-review.yml | 121 ++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 .github/scripts/submit_review.py create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/scripts/submit_review.py b/.github/scripts/submit_review.py new file mode 100644 index 000000000..8027fb214 --- /dev/null +++ b/.github/scripts/submit_review.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Submit the Claude review payload as one PR review, resilient to inline +comments GitHub cannot anchor to the diff. + +The model writes ``/tmp/review.json`` with inline comments keyed to new-file +line numbers. GitHub's create-review API rejects the WHOLE review (HTTP 422, +"Line could not be resolved") if any single comment's line falls outside the +PR's diff hunks. To stay green without dropping findings, this script: + + 1. Computes the set of commentable RIGHT-side lines from ``gh pr diff``. + 2. Keeps inline comments whose (path, line) is anchorable; folds the rest + into the review body so the finding still shows up. + 3. POSTs once. On any residual failure, retries body-only, then gives up + gracefully -- a review-infra hiccup must not block the PR. + +Trust boundary: this is the trusted step. The endpoint is hardcoded here and +the model never holds ``gh api``; a malicious diff can only influence comment +*text* (which already lands in the review body either way), never which API +call runs. +""" +from __future__ import annotations + +import json +import os +import re +import subprocess + +REVIEW_PATH = "/tmp/review.json" + + +def parse_commentable_lines(diff: str) -> dict[str, set[int]]: + """Map new-file path -> set of RIGHT-side line numbers inside diff hunks. + + GitHub allows a RIGHT-side comment on added (``+``) and context (`` ``) + lines within a hunk; removed (``-``) lines have no new-file line. Pure + function over unified-diff text so it can be unit-tested without network. + """ + valid: dict[str, set[int]] = {} + path: str | None = None + newln = 0 + in_hunk = False + for line in diff.splitlines(): + # A new file section resets state so inter-file metadata + # (``diff --git``, ``index``, ``--- ``/``+++ `` headers) is never + # mistaken for hunk content. + if line.startswith("diff --git"): + path, in_hunk = None, False + continue + if not in_hunk: + if line.startswith("+++ "): + p = line[4:].strip() + if p.startswith("b/"): + p = p[2:] + path = None if p == "/dev/null" else p + if path is not None: + valid.setdefault(path, set()) + elif line.startswith("@@"): + m = re.search(r"\+(\d+)", line) + newln = int(m.group(1)) if m else 0 + in_hunk = True + continue + # Inside a hunk. + if line.startswith("@@"): # next hunk of the same file + m = re.search(r"\+(\d+)", line) + newln = int(m.group(1)) if m else 0 + continue + if path is None: + continue + if line.startswith("+"): # added line (commentable on RIGHT) + valid[path].add(newln) + newln += 1 + elif line.startswith(" "): # context line (commentable on RIGHT) + valid[path].add(newln) + newln += 1 + # ``-`` (removed, no RIGHT line) and ``\`` ("No newline") consume nothing. + return valid + + +def post(repo: str, pr: str, payload: dict) -> tuple[int, str]: + proc = subprocess.run( + ["gh", "api", "--method", "POST", + f"/repos/{repo}/pulls/{pr}/reviews", "--input", "-"], + input=json.dumps(payload), capture_output=True, text=True, + ) + return proc.returncode, (proc.stdout + proc.stderr).strip() + + +def partition_comments( + comments: list[dict], valid: dict[str, set[int]] +) -> tuple[list[dict], list[dict]]: + """Split comments into (anchorable-inline, must-fold-into-body). + + A comment is anchorable when it targets the RIGHT side and both its + ``line`` and (for a multi-line span) ``start_line`` land on a commentable + line of the file in the diff. + """ + kept, folded = [], [] + for c in comments: + p, ln, sl = c.get("path"), c.get("line"), c.get("start_line") + anchorable = ( + c.get("side", "RIGHT") == "RIGHT" + and p in valid and ln in valid[p] + and (sl is None or sl in valid[p]) + ) + (kept if anchorable else folded).append(c) + return kept, folded + + +def _loc(c: dict) -> str: + return f"{c.get('path')}:{c.get('line')}" + + +def main() -> None: + pr = os.environ["PR_NUMBER"] + repo = os.environ["REPO"] + # The model occasionally writes a /tmp/review.json that isn't valid JSON + # (e.g. an unescaped quote or newline inside a comment body), which made + # ``json.load`` raise and fail the whole job. Review posting is advisory + # and must not block the PR -- mirror the graceful give-up the submit + # fallbacks already use: log loudly and return. Re-running the review + # regenerates the file. + try: + with open(REVIEW_PATH) as f: + review = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print( + f"warning: could not read/parse {REVIEW_PATH} ({exc}); " + "skipping review submit, not blocking the PR." + ) + return + if not isinstance(review, dict): + print( + f"warning: {REVIEW_PATH} is not a JSON object " + f"(got {type(review).__name__}); skipping review submit, " + "not blocking the PR." + ) + return + comments = review.get("comments") or [] + body = review.get("body") or "" + base = {"event": review.get("event", "COMMENT")} + if review.get("commit_id"): + base["commit_id"] = review["commit_id"] + + valid: dict[str, set[int]] = {} + try: + diff = subprocess.run( + ["gh", "pr", "diff", pr], capture_output=True, text=True, check=True + ).stdout + valid = parse_commentable_lines(diff) + except Exception as exc: # diff fetch/parse failed -> fall back to body-only + print(f"warning: could not compute diff lines ({exc}); folding all inline comments") + + kept, folded = partition_comments(comments, valid) + + def fold_into(text: str, items: list[dict], heading: str) -> str: + if not items: + return text + out = text + f"\n\n---\n**{heading}:**\n" + for c in items: + out += f"\n- `{_loc(c)}` -- {c.get('body', '').strip()}" + return out + + # Attempt 1: anchorable comments inline, the rest folded into the body. + body1 = fold_into(body, folded, "Findings that could not be anchored to the diff") + rc, out = post(repo, pr, {**base, "body": body1, "comments": kept}) + if rc == 0: + print(f"Posted review: {len(kept)} inline, {len(folded)} folded into body.") + return + print(f"Inline submit failed (rc={rc}); falling back to body-only.\n{out}") + + # Attempt 2: everything in the body, no inline anchors at all. + body2 = fold_into(body1, kept, "Inline findings (anchoring unavailable)") + rc, out = post(repo, pr, {**base, "body": body2 or "Review produced no anchorable findings.", + "comments": []}) + if rc == 0: + print("Posted body-only review.") + return + + # A body-only COMMENT review needs no line resolution, so this is an + # auth/rate-limit/transport problem, not the 422 we set out to fix. + # Log loudly but do not fail the job: review posting is advisory and must + # not block the PR. + print(f"warning: review submit failed entirely (rc={rc}); not blocking the PR.\n{out}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..77178bb09 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,121 @@ +name: Claude PR Review + +on: + pull_request: + branches: [main] + # ready_for_review is required so a draft promoted to ready gets reviewed — + # without it the draft guard below would silently skip such PRs forever. + types: [opened, synchronize, ready_for_review] + +# One review per PR; a new push cancels the in-flight review instead of +# stacking duplicate threads (and duplicate API spend). +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + review: + # Skip drafts, and skip Dependabot PRs — Dependabot-triggered runs don't get + # the repo's Actions secrets, so the auth step can't load the OAuth token + # and the check fails. Dep bumps don't need an AI code review anyway. + if: >- + github.actor != 'dependabot[bot]' && + github.event.pull_request.draft == false + runs-on: ubuntu-latest + # A hung API call would otherwise hold the runner for the 6h default. + # Sized to fit the --max-turns budget below (~25s/turn observed) with margin, + # so the turn cap — not this wall clock — stays the limiting factor. + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Claude review + # Pinned to the commit the v1 tag pointed at (mutable tags are a + # supply-chain risk for a step that runs with repo credentials). + uses: anthropics/claude-code-action@36a69b6a90b850823f86de06fdfd56264772ad98 # v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + # Claude subscription auth (no Bedrock / no API key). Generate with + # `claude setup-token` and store as the CLAUDE_CODE_OAUTH_TOKEN secret. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Claude only READS (gh pr diff) and WRITES the review payload to a file. + # It is deliberately NOT given gh api: the PR diff is attacker-controlled, + # so a prompt injection must not be able to choose which API endpoint runs. + # The fixed "Submit review" step below makes the one hardcoded POST call. + # Write is left unscoped on purpose: a path-scoped matcher (Write(/tmp/**)) + # silently blocked the /tmp/review.json write and the review never posted. + # Residual risk is low — workspace writes have no execution path here and the + # runner is ephemeral; the real injection lever (the API call) is already gone. + # max-turns must cover: read the diff, reason over every hunk, then + # write /tmp/review.json. Large PRs keep exhausting smaller budgets + # before the write, ending in error_max_turns with no review posted. + # timeout-minutes above is sized to fit 80 turns (~25s/turn ≈ 33 min) + # so the turn cap stays the limiting factor. + claude_args: >- + --model claude-sonnet-5 + --max-turns 80 + --allowedTools "Bash(gh pr diff:*),Write" + prompt: | + You are reviewing pull request #${{ github.event.pull_request.number }} + in the repository ${{ github.repository }}. The head commit SHA is + ${{ github.event.pull_request.head.sha }}. + + Report ONLY High and Medium severity findings: + - High: correctness bugs, security issues (auth, injection, secret + leakage, unsafe file handling), data loss, multi-tenant / app-scope + boundary breaks. + - Medium: likely-incorrect behavior, missing error handling, race + conditions, configuration that fails silently. + Do NOT report Low severity, nitpicks, style, formatting, naming, or + subjective preferences. If a finding is not clearly High or Medium, omit it. + + Post everything as ONE single pull request review (GitHub Copilot style), + NOT as separate standalone comments. Steps: + 1. Read the diff: gh pr diff ${{ github.event.pull_request.number }} + 2. For each High/Medium finding, note the file path and the exact line + number in the NEW version of the file (the right side of the diff). + 3. Use the Write tool to create /tmp/review.json with this shape: + { + "event": "COMMENT", + "commit_id": "${{ github.event.pull_request.head.sha }}", + "body": "<1-2 sentence summary, e.g. '2 High, 1 Medium.'>", + "comments": [ + {"path": "", "line": , "side": "RIGHT", + "body": "**[High]** "} + ] + } + Prefix every comment body with **[High]** or **[Medium]**. For a + multi-line span, also set "start_line" and "start_side": "RIGHT". + 4. Do NOT submit the review yourself and do NOT call gh api — you do not + have that permission. Your only job is to WRITE /tmp/review.json. A + separate trusted workflow step submits it. + Always write /tmp/review.json. If there are no High or Medium findings, + write it with an empty "comments" array and body "No High or Medium + severity issues found." + + # Trusted submit: the endpoint is hardcoded in the script, so even if a + # malicious PR injects instructions into the diff, it can only influence + # comment text — never which API call runs. Claude never holds an + # API-write tool. The script also drops inline comments GitHub can't + # anchor to the diff (which otherwise 422 the whole review) by folding + # them into the review body, so one bad line number no longer fails the + # job or loses the finding. + - name: Submit batched review + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + if [ ! -f /tmp/review.json ]; then + echo "No /tmp/review.json produced; nothing to submit." + exit 0 + fi + python3 .github/scripts/submit_review.py From 3f190d46214fa2f29c48aa6905efc7c7be73434c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:54:07 +0000 Subject: [PATCH 45/51] chore(deps): bump js-yaml Bumps the npm_and_yarn group with 1 update in the /booking-app directory: [js-yaml](https://github.com/nodeca/js-yaml). Updates `js-yaml` from 4.1.1 to 4.3.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.1.1...4.3.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- booking-app/package-lock.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/booking-app/package-lock.json b/booking-app/package-lock.json index ae822467c..f26a15720 100644 --- a/booking-app/package-lock.json +++ b/booking-app/package-lock.json @@ -11394,7 +11394,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" From f10e477bb7d0f028129c689fa5444f0161059ba0 Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Fri, 3 Jul 2026 23:55:41 -0400 Subject: [PATCH 46/51] ci: harden claude-review trust boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the submit path: - Pin the review event to COMMENT and take commit_id from the workflow env (HEAD_SHA) instead of trusting model-written review.json, so an injected payload cannot submit an APPROVE/REQUEST_CHANGES review. - Restore submit_review.py from HEAD before executing it — the model holds an unscoped Write tool, so the working-tree copy is untrusted. - Drop non-object entries from comments and default start_side to RIGHT when start_line is present (missing start_side 422s the whole review). --- .github/scripts/submit_review.py | 26 +++++++++++++++++++++----- .github/workflows/claude-review.yml | 12 ++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/scripts/submit_review.py b/.github/scripts/submit_review.py index 8027fb214..37de7244e 100644 --- a/.github/scripts/submit_review.py +++ b/.github/scripts/submit_review.py @@ -135,11 +135,21 @@ def main() -> None: "not blocking the PR." ) return - comments = review.get("comments") or [] - body = review.get("body") or "" - base = {"event": review.get("event", "COMMENT")} - if review.get("commit_id"): - base["commit_id"] = review["commit_id"] + # review.json is untrusted model output: the review verdict is pinned to + # COMMENT (never APPROVE/REQUEST_CHANGES) and commit_id comes from the + # workflow env, so an injected payload can only influence comment text. + raw_comments = review.get("comments") + comments = ( + [c for c in raw_comments if isinstance(c, dict)] + if isinstance(raw_comments, list) + else [] + ) + body = review.get("body") + body = body if isinstance(body, str) else "" + base = {"event": "COMMENT"} + head_sha = os.environ.get("HEAD_SHA") + if head_sha: + base["commit_id"] = head_sha valid: dict[str, set[int]] = {} try: @@ -152,6 +162,12 @@ def main() -> None: kept, folded = partition_comments(comments, valid) + # GitHub's create-review API requires start_side when start_line is set; + # a comment that omits it would 422 the whole review. + for c in kept: + if c.get("start_line") is not None: + c.setdefault("start_side", "RIGHT") + def fold_into(text: str, items: list[dict], heading: str) -> str: if not items: return text diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 77178bb09..14efc3b85 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -51,8 +51,9 @@ jobs: # The fixed "Submit review" step below makes the one hardcoded POST call. # Write is left unscoped on purpose: a path-scoped matcher (Write(/tmp/**)) # silently blocked the /tmp/review.json write and the review never posted. - # Residual risk is low — workspace writes have no execution path here and the - # runner is ephemeral; the real injection lever (the API call) is already gone. + # The one workspace write with an execution path — this script itself — is + # neutralized by the submit step restoring it from HEAD before running it; + # the runner is ephemeral, and the real injection lever (the API call) is gone. # max-turns must cover: read the diff, reason over every hunk, then # write /tmp/review.json. Large PRs keep exhausting smaller budgets # before the write, ending in error_max_turns with no review posted. @@ -113,7 +114,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | + # The model held an unscoped Write tool while reading an + # attacker-controlled diff, so the working-tree copy of this script + # is untrusted at this point. Restore the committed version before + # executing it, or a prompt injection could rewrite the "trusted" + # script instead of calling gh api directly. + git checkout HEAD -- .github/scripts/submit_review.py if [ ! -f /tmp/review.json ]; then echo "No /tmp/review.json produced; nothing to submit." exit 0 From 9fafc4cbefb161a3472d1da67dafd729400abdac Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Sat, 4 Jul 2026 00:02:17 -0400 Subject: [PATCH 47/51] ci: run submit script from base ref and gate on same-repo PRs - Execute submit_review.py from the PR's base ref instead of the PR checkout: restoring from HEAD only defeated runtime tampering by the review agent's Write tool, not a PR that commits an edited script. Bootstrap fallback to the PR's committed copy while the script does not exist on the base branch yet. - Skip fork PRs entirely: they receive no secrets (the review cannot auth anyway), and the gate keeps the write-scoped GITHUB_TOKEN away from untrusted heads. --- .github/workflows/claude-review.yml | 34 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 14efc3b85..b61e8a4c2 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -22,9 +22,13 @@ jobs: # Skip drafts, and skip Dependabot PRs — Dependabot-triggered runs don't get # the repo's Actions secrets, so the auth step can't load the OAuth token # and the check fails. Dep bumps don't need an AI code review anyway. + # Also skip fork PRs: they don't receive secrets either (the review can't + # auth), and gating on same-repo keeps the write-scoped GITHUB_TOKEN away + # from untrusted heads entirely. if: >- github.actor != 'dependabot[bot]' && - github.event.pull_request.draft == false + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest # A hung API call would otherwise hold the runner for the 6h default. # Sized to fit the --max-turns budget below (~25s/turn observed) with margin, @@ -51,8 +55,8 @@ jobs: # The fixed "Submit review" step below makes the one hardcoded POST call. # Write is left unscoped on purpose: a path-scoped matcher (Write(/tmp/**)) # silently blocked the /tmp/review.json write and the review never posted. - # The one workspace write with an execution path — this script itself — is - # neutralized by the submit step restoring it from HEAD before running it; + # The one workspace write with an execution path — submit_review.py — is + # neutralized by the submit step executing the BASE-ref copy of the script; # the runner is ephemeral, and the real injection lever (the API call) is gone. # max-turns must cover: read the diff, reason over every hunk, then # write /tmp/review.json. Large PRs keep exhausting smaller budgets @@ -116,14 +120,24 @@ jobs: REPO: ${{ github.repository }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - # The model held an unscoped Write tool while reading an - # attacker-controlled diff, so the working-tree copy of this script - # is untrusted at this point. Restore the committed version before - # executing it, or a prompt injection could rewrite the "trusted" - # script instead of calling gh api directly. - git checkout HEAD -- .github/scripts/submit_review.py + # Execute the submit script from the trusted BASE ref, not the PR's + # copy. The PR checkout is untrusted twice over: the Claude step + # holds an unscoped Write tool (runtime tampering), and the PR + # itself may commit an edited submit_review.py (committed + # tampering). Neither version may run with the write-scoped token. + # Bootstrap fallback: if the base branch doesn't have the script + # yet (the PR introducing it), use the PR copy restored from its + # committed state. + BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + if git show "$BASE_REF:.github/scripts/submit_review.py" > /tmp/submit_review.py 2>/dev/null; then + SCRIPT=/tmp/submit_review.py + else + echo "warning: submit_review.py not on $BASE_REF yet (bootstrap PR); using the PR's committed copy" + git checkout HEAD -- .github/scripts/submit_review.py + SCRIPT=.github/scripts/submit_review.py + fi if [ ! -f /tmp/review.json ]; then echo "No /tmp/review.json produced; nothing to submit." exit 0 fi - python3 .github/scripts/submit_review.py + python3 "$SCRIPT" From dd291ac62a4faa2ab9e0201a8258429801b20c45 Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Sat, 4 Jul 2026 00:09:23 -0400 Subject: [PATCH 48/51] ci: sanitize environment for the trusted submit step - Run submit_review.py under 'env -i' with an explicit allowlist and invoke all binaries by absolute path: the review agent's unscoped Write could poison $GITHUB_ENV/$GITHUB_PATH, and a shadowed gh or an injected GH_HOST would redirect the hardcoded API call (token included) to an attacker host. - Make the base-ref fallback bootstrap-only: fail the step when the base ref cannot be resolved instead of silently executing the PR's copy on any git error. --- .github/workflows/claude-review.yml | 40 +++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index b61e8a4c2..edf6ca227 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -55,9 +55,11 @@ jobs: # The fixed "Submit review" step below makes the one hardcoded POST call. # Write is left unscoped on purpose: a path-scoped matcher (Write(/tmp/**)) # silently blocked the /tmp/review.json write and the review never posted. - # The one workspace write with an execution path — submit_review.py — is - # neutralized by the submit step executing the BASE-ref copy of the script; - # the runner is ephemeral, and the real injection lever (the API call) is gone. + # The writes with an execution path — submit_review.py itself, and the + # $GITHUB_ENV/$GITHUB_PATH file commands — are neutralized by the submit + # step executing the BASE-ref copy via absolute paths under a clean + # `env -i` environment; the runner is ephemeral, and the real injection + # lever (the API call) is gone. # max-turns must cover: read the diff, reason over every hunk, then # write /tmp/review.json. Large PRs keep exhausting smaller budgets # before the write, ending in error_max_turns with no review posted. @@ -125,19 +127,37 @@ jobs: # holds an unscoped Write tool (runtime tampering), and the PR # itself may commit an edited submit_review.py (committed # tampering). Neither version may run with the write-scoped token. - # Bootstrap fallback: if the base branch doesn't have the script - # yet (the PR introducing it), use the PR copy restored from its - # committed state. + # + # All binaries are invoked by absolute path and the script runs + # under `env -i` with an explicit allowlist: the Claude step's + # unscoped Write could poison $GITHUB_ENV/$GITHUB_PATH, and a + # shadowed `gh` on PATH or an injected GH_HOST would otherwise + # redirect the hardcoded API call (token included) elsewhere. BASE_REF="origin/${{ github.event.pull_request.base.ref }}" - if git show "$BASE_REF:.github/scripts/submit_review.py" > /tmp/submit_review.py 2>/dev/null; then + if ! /usr/bin/git rev-parse --verify --quiet "$BASE_REF^{commit}" > /dev/null; then + echo "error: cannot resolve $BASE_REF; refusing to fall back to the PR's copy of submit_review.py" + exit 1 + fi + if /usr/bin/git cat-file -e "$BASE_REF:.github/scripts/submit_review.py" 2>/dev/null; then + /usr/bin/git show "$BASE_REF:.github/scripts/submit_review.py" > /tmp/submit_review.py SCRIPT=/tmp/submit_review.py else - echo "warning: submit_review.py not on $BASE_REF yet (bootstrap PR); using the PR's committed copy" - git checkout HEAD -- .github/scripts/submit_review.py + # Bootstrap-only: the base branch resolves but doesn't have the + # script yet (the PR introducing it). Any other git failure exits + # above instead of silently trusting the PR copy. + echo "bootstrap: submit_review.py not on $BASE_REF yet; using the PR's committed copy" + /usr/bin/git checkout HEAD -- .github/scripts/submit_review.py SCRIPT=.github/scripts/submit_review.py fi if [ ! -f /tmp/review.json ]; then echo "No /tmp/review.json produced; nothing to submit." exit 0 fi - python3 "$SCRIPT" + /usr/bin/env -i \ + PATH=/usr/bin:/bin \ + HOME="$HOME" \ + GH_TOKEN="$GH_TOKEN" \ + PR_NUMBER="$PR_NUMBER" \ + REPO="$REPO" \ + HEAD_SHA="$HEAD_SHA" \ + /usr/bin/python3 "$SCRIPT" From bc461f1da3bbdb8b7dbbe17ce304b53b67e268dd Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Sat, 4 Jul 2026 00:14:43 -0400 Subject: [PATCH 49/51] ci: add reopened trigger and document allowlist residual risk - Trigger on reopened PRs: a closed-then-reopened PR with no new commit would otherwise silently never be reviewed. - Document why the Bash allowlist is an accepted residual risk: Claude Code's permission engine evaluates compound commands per segment and rejects command substitution in non-interactive mode, and the submit step's base-ref + env -i containment bounds the blast radius. --- .github/workflows/claude-review.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index edf6ca227..a8e8c5f5a 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -5,7 +5,9 @@ on: branches: [main] # ready_for_review is required so a draft promoted to ready gets reviewed — # without it the draft guard below would silently skip such PRs forever. - types: [opened, synchronize, ready_for_review] + # reopened is required for the same reason: a closed-then-reopened PR with + # no new commit would otherwise never be reviewed. + types: [opened, synchronize, ready_for_review, reopened] # One review per PR; a new push cancels the in-flight review instead of # stacking duplicate threads (and duplicate API spend). @@ -52,6 +54,13 @@ jobs: # Claude only READS (gh pr diff) and WRITES the review payload to a file. # It is deliberately NOT given gh api: the PR diff is attacker-controlled, # so a prompt injection must not be able to choose which API endpoint runs. + # Accepted residual risk: the allowlist relies on Claude Code's permission + # engine, which evaluates compound commands (;, &&, |) per segment and + # rejects command substitution in non-interactive mode — not on naive + # string-prefix matching. If that engine were bypassed, the blast radius + # is contained by the submit step (base-ref script, env -i, absolute + # paths); token exfiltration from the agent's own step is inherent to + # running a hosted agent and is not mitigable at this layer. # The fixed "Submit review" step below makes the one hardcoded POST call. # Write is left unscoped on purpose: a path-scoped matcher (Write(/tmp/**)) # silently blocked the /tmp/review.json write and the review never posted. From 304291486009b2775c51bce326bc9938f0c210b3 Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Sat, 4 Jul 2026 00:22:08 -0400 Subject: [PATCH 50/51] ci: remove claude-review bootstrap fallback, fail closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback that executed the PR's own committed copy of submit_review.py was only needed while the script did not yet exist on main (the PR introducing it, #1521). Now that it is on main, any failure to read the script from the base ref — unresolvable ref, deleted script, transient git error — skips the submit instead of ever running the PR-controlled copy with the write-scoped token. Also wrap the gh invocation in submit_review.py so a missing/broken gh binary surfaces as a failed attempt instead of an unhandled exception; review posting is advisory and must never crash the job. --- .github/scripts/submit_review.py | 15 ++++++++++----- .github/workflows/claude-review.yml | 19 ++++++------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/.github/scripts/submit_review.py b/.github/scripts/submit_review.py index 37de7244e..894922ed1 100644 --- a/.github/scripts/submit_review.py +++ b/.github/scripts/submit_review.py @@ -77,11 +77,16 @@ def parse_commentable_lines(diff: str) -> dict[str, set[int]]: def post(repo: str, pr: str, payload: dict) -> tuple[int, str]: - proc = subprocess.run( - ["gh", "api", "--method", "POST", - f"/repos/{repo}/pulls/{pr}/reviews", "--input", "-"], - input=json.dumps(payload), capture_output=True, text=True, - ) + # Review posting is advisory and must never crash the job — surface a + # missing/broken gh binary as a failed attempt, not an unhandled exception. + try: + proc = subprocess.run( + ["gh", "api", "--method", "POST", + f"/repos/{repo}/pulls/{pr}/reviews", "--input", "-"], + input=json.dumps(payload), capture_output=True, text=True, + ) + except OSError as exc: + return 1, f"could not invoke gh: {exc}" return proc.returncode, (proc.stdout + proc.stderr).strip() diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index a8e8c5f5a..ecc2d7231 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -142,22 +142,15 @@ jobs: # unscoped Write could poison $GITHUB_ENV/$GITHUB_PATH, and a # shadowed `gh` on PATH or an injected GH_HOST would otherwise # redirect the hardcoded API call (token included) elsewhere. + # Fail closed: if the script cannot be read from the base ref for + # ANY reason (unresolvable ref, deleted script, transient git + # error), skip the submit rather than ever executing the PR's copy. BASE_REF="origin/${{ github.event.pull_request.base.ref }}" - if ! /usr/bin/git rev-parse --verify --quiet "$BASE_REF^{commit}" > /dev/null; then - echo "error: cannot resolve $BASE_REF; refusing to fall back to the PR's copy of submit_review.py" + if ! /usr/bin/git show "$BASE_REF:.github/scripts/submit_review.py" > /tmp/submit_review.py 2>/dev/null; then + echo "error: could not read submit_review.py from $BASE_REF; refusing to run the PR's copy" exit 1 fi - if /usr/bin/git cat-file -e "$BASE_REF:.github/scripts/submit_review.py" 2>/dev/null; then - /usr/bin/git show "$BASE_REF:.github/scripts/submit_review.py" > /tmp/submit_review.py - SCRIPT=/tmp/submit_review.py - else - # Bootstrap-only: the base branch resolves but doesn't have the - # script yet (the PR introducing it). Any other git failure exits - # above instead of silently trusting the PR copy. - echo "bootstrap: submit_review.py not on $BASE_REF yet; using the PR's committed copy" - /usr/bin/git checkout HEAD -- .github/scripts/submit_review.py - SCRIPT=.github/scripts/submit_review.py - fi + SCRIPT=/tmp/submit_review.py if [ ! -f /tmp/review.json ]; then echo "No /tmp/review.json produced; nothing to submit." exit 0 From 91ba95dd7c3249e33365d8de6ea0895356e4482b Mon Sep 17 00:00:00 2001 From: "riho.takagi" Date: Sun, 5 Jul 2026 23:13:32 -0500 Subject: [PATCH 51/51] ci: give Claude PR review read access to the checkout for cross-file tracing The review agent could previously only run 'gh pr diff', so any bug whose trigger lives outside the changed hunks was invisible to it. On PR #1514 it missed a blocking regression (MaintenanceModeGate spinning forever for unauthenticated users) because the culprit code path in Provider.tsx was not part of the diff, and it could not verify sibling booking routes for the maintenance-enforcement gap. - Allow Read/Grep/Glob over the already-present checkout (local file reads only; no new API surface). The prompt now instructs the agent to trace callers, state producers, and sibling code paths before reporting. - Set persist-credentials: false on checkout so the read tools cannot reach the write-scoped GITHUB_TOKEN that actions/checkout would otherwise store in .git/config. gh authenticates via the env token and the submit step only runs local git against already-fetched refs. - Bump --max-turns 80 -> 100 and timeout 45 -> 60 min to cover the extra file-reading turns. Co-Authored-By: Claude Fable 5 --- .github/workflows/claude-review.yml | 59 +++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index a8e8c5f5a..d416615ce 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -33,14 +33,23 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest # A hung API call would otherwise hold the runner for the 6h default. - # Sized to fit the --max-turns budget below (~25s/turn observed) with margin, - # so the turn cap — not this wall clock — stays the limiting factor. - timeout-minutes: 45 + # Sized to fit the --max-turns budget below (~25s/turn observed ≈ 42 min + # at 100 turns) with margin, so the turn cap — not this wall clock — + # stays the limiting factor. + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + # Claude holds read-only file tools (Read/Grep/Glob below), and + # actions/checkout by default persists the write-scoped GITHUB_TOKEN + # into .git/config — an on-disk secret a prompt injection could read + # and exfiltrate through the review text. Nothing downstream needs + # git-over-HTTP auth: `gh pr diff` authenticates via the env token, + # and the submit step only runs local git (rev-parse/show) against + # refs this full fetch already brought down. + persist-credentials: false - name: Claude review # Pinned to the commit the v1 tag pointed at (mutable tags are a @@ -51,7 +60,15 @@ jobs: # Claude subscription auth (no Bedrock / no API key). Generate with # `claude setup-token` and store as the CLAUDE_CODE_OAUTH_TOKEN secret. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # Claude only READS (gh pr diff) and WRITES the review payload to a file. + # Claude only READS (gh pr diff, plus local Read/Grep/Glob over the + # checkout) and WRITES the review payload to a file. The local file + # tools exist so the review can trace cross-file impact — a diff-only + # view kept missing bugs whose trigger lives outside the changed + # hunks (e.g. a new gate blocking on state that another file never + # clears). They add no new injection lever: the checkout content is + # the same attacker-controlled input as the diff, the runner is + # ephemeral, and with persist-credentials: false there is no on-disk + # secret for a read tool to reach. # It is deliberately NOT given gh api: the PR diff is attacker-controlled, # so a prompt injection must not be able to choose which API endpoint runs. # Accepted residual risk: the allowlist relies on Claude Code's permission @@ -69,19 +86,22 @@ jobs: # step executing the BASE-ref copy via absolute paths under a clean # `env -i` environment; the runner is ephemeral, and the real injection # lever (the API call) is gone. - # max-turns must cover: read the diff, reason over every hunk, then + # max-turns must cover: read the diff, reason over every hunk, chase + # callers/definitions of changed code through the checkout, then # write /tmp/review.json. Large PRs keep exhausting smaller budgets # before the write, ending in error_max_turns with no review posted. - # timeout-minutes above is sized to fit 80 turns (~25s/turn ≈ 33 min) + # timeout-minutes above is sized to fit 100 turns (~25s/turn ≈ 42 min) # so the turn cap stays the limiting factor. claude_args: >- --model claude-sonnet-5 - --max-turns 80 - --allowedTools "Bash(gh pr diff:*),Write" + --max-turns 100 + --allowedTools "Bash(gh pr diff:*),Read,Grep,Glob,Write" prompt: | You are reviewing pull request #${{ github.event.pull_request.number }} in the repository ${{ github.repository }}. The head commit SHA is - ${{ github.event.pull_request.head.sha }}. + ${{ github.event.pull_request.head.sha }}. The repository is checked + out in your working directory at the PR's merge result, so you can + Read/Grep/Glob any file — not just the diff. Report ONLY High and Medium severity findings: - High: correctness bugs, security issues (auth, injection, secret @@ -95,9 +115,24 @@ jobs: Post everything as ONE single pull request review (GitHub Copilot style), NOT as separate standalone comments. Steps: 1. Read the diff: gh pr diff ${{ github.event.pull_request.number }} - 2. For each High/Medium finding, note the file path and the exact line + 2. Trace the diff through the rest of the codebase — bugs whose + trigger lives outside the changed hunks are in scope: + - For each function/component the diff changes or newly calls, + Read the full enclosing file and Grep for its callers and + consumers; check the change holds on every path that reaches it. + - For state, flags, or context values the new code reads + (loading flags, permissions, settings), find where they are + set and cleared, and check every branch — including the + unauthenticated / empty / error cases. + - When new enforcement or validation is added (auth checks, + rate limits, feature gates), Grep for sibling code paths that + mutate the same data and would bypass it. + - Before reporting a finding, verify the trigger path in the + surrounding code instead of assuming it from the diff alone; + drop findings the surrounding code refutes. + 3. For each High/Medium finding, note the file path and the exact line number in the NEW version of the file (the right side of the diff). - 3. Use the Write tool to create /tmp/review.json with this shape: + 4. Use the Write tool to create /tmp/review.json with this shape: { "event": "COMMENT", "commit_id": "${{ github.event.pull_request.head.sha }}", @@ -109,7 +144,7 @@ jobs: } Prefix every comment body with **[High]** or **[Medium]**. For a multi-line span, also set "start_line" and "start_side": "RIGHT". - 4. Do NOT submit the review yourself and do NOT call gh api — you do not + 5. Do NOT submit the review yourself and do NOT call gh api — you do not have that permission. Your only job is to WRITE /tmp/review.json. A separate trusted workflow step submits it. Always write /tmp/review.json. If there are no High or Medium findings,