import type { PrismaClient } from "@/generated/prisma/client"; import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums"; import { decryptToken, encryptToken } from "@/lib/security/token-encryption"; import type { CalendarProvider } from "./calendar-provider"; import { GoogleCalendarProvider } from "./google-calendar.provider"; import { CalendarProviderError } from "./types"; import type { CalendarEventInput } from "./types"; /** * Appointment → Google Calendar sync policy — the one place that decides * *whether* and *how* an appointment's status maps to a calendar event. * Provider-neutral: everything here goes through `CalendarProvider`, never * a Google-specific call directly (see calendar-provider.ts). Every * public function here is safe to call from a Server Action after an * appointment mutation commits — sync failures are caught and recorded, * never thrown, so a Google outage can never roll back or block a real * booking change (see the brief's explicit requirement). */ function getProvider(): CalendarProvider { // Google is the only provider today — see CalendarProvider's own header // for why a second one is additive, not a rewrite, when needed. return new GoogleCalendarProvider(); } async function getValidAccessToken( prisma: PrismaClient, connection: { id: string; encryptedAccessToken: string | null; encryptedRefreshToken: string; tokenExpiresAt: Date | null }, provider: CalendarProvider, ): Promise { const stillValid = connection.encryptedAccessToken && connection.tokenExpiresAt && connection.tokenExpiresAt.getTime() - Date.now() > 60_000; if (stillValid) return decryptToken(connection.encryptedAccessToken!); const refreshToken = decryptToken(connection.encryptedRefreshToken); const refreshed = await provider.refreshAccessToken(refreshToken); await prisma.staffCalendarConnection.update({ where: { id: connection.id }, data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt }, }); return refreshed.accessToken; } function buildEventContent(appointment: { id: string; startAt: Date; endAt: Date; status: AppointmentStatus; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; business: { name: string; locations: { addressLine1: string; city: string; postalCode: string }[] }; }): CalendarEventInput { const serviceNames = appointment.services.map((s) => s.service.name).join(", "); const location = appointment.business.locations[0]; const locationLine = location ? `${location.addressLine1}, ${location.city} ${location.postalCode}` : undefined; const appUrl = process.env.NEXT_PUBLIC_APP_URL || ""; const descriptionLines = [ `Customer: ${appointment.customer.firstName} ${appointment.customer.lastName}`, `Service: ${serviceNames}`, `Business: ${appointment.business.name}`, locationLine ? `Address: ${locationLine}` : undefined, `Reference: ${appointment.id}`, appUrl ? `View: ${appUrl}/staff/appointments/${appointment.id}` : undefined, appointment.status === AppointmentStatus.NO_SHOW ? "Status: customer did not show up." : undefined, ].filter((line): line is string => !!line); return { summary: `${serviceNames} — ${appointment.customer.firstName} ${appointment.customer.lastName}`, description: descriptionLines.join("\n"), location: locationLine, startAt: appointment.startAt, endAt: appointment.endAt, }; } /** * Policy: PENDING appointments are never synced (an unapproved booking * shouldn't occupy the barber's personal calendar); CANCELLED deletes the * remote event outright (a cancelled slot has no reason to still show * busy); everything else (CONFIRMED/CHECKED_IN/IN_PROGRESS/COMPLETED/ * NO_SHOW) creates or updates the event, with NO_SHOW additionally noting * the outcome in the description rather than removing the event (the * barber's calendar should still show they were scheduled to work). */ async function syncOneConnection( prisma: PrismaClient, params: { appointmentId: string; connection: { id: string; encryptedAccessToken: string | null; encryptedRefreshToken: string; tokenExpiresAt: Date | null; selectedCalendarId: string; }; provider: CalendarProvider; }, ): Promise<"ok" | "revoked"> { const appointment = await prisma.appointment.findUnique({ where: { id: params.appointmentId }, include: { customer: { select: { firstName: true, lastName: true } }, services: { include: { service: { select: { name: true } } } }, business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } }, }, }); if (!appointment) return "ok"; const provider = params.provider; const existing = await prisma.appointmentCalendarEvent.findUnique({ where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } }, }); try { if (appointment.status === AppointmentStatus.PENDING) return "ok"; const accessToken = await getValidAccessToken(prisma, params.connection, provider); if (appointment.status === AppointmentStatus.CANCELLED) { if (existing) { await provider.deleteEvent({ accessToken, calendarId: params.connection.selectedCalendarId, providerEventId: existing.providerEventId }); await prisma.appointmentCalendarEvent.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null }, }); } return "ok"; } const event = buildEventContent(appointment); if (existing && existing.status !== CalendarSyncStatus.CANCELLED) { await provider.updateEvent({ accessToken, calendarId: params.connection.selectedCalendarId, providerEventId: existing.providerEventId, event, }); await prisma.appointmentCalendarEvent.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.SYNCED, lastSyncedAt: new Date(), lastSyncError: null, sourceVersion: { increment: 1 } }, }); } else { const created = await provider.createEvent({ accessToken, calendarId: params.connection.selectedCalendarId, event }); await prisma.appointmentCalendarEvent.upsert({ where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } }, create: { appointmentId: params.appointmentId, connectionId: params.connection.id, providerEventId: created.providerEventId, providerCalendarId: params.connection.selectedCalendarId, status: CalendarSyncStatus.SYNCED, }, update: { providerEventId: created.providerEventId, providerCalendarId: params.connection.selectedCalendarId, status: CalendarSyncStatus.SYNCED, lastSyncedAt: new Date(), lastSyncError: null, }, }); } await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncAt: new Date(), lastSyncError: null } }); return "ok"; } catch (error) { const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly."; // The user only ever sees `message`; the server log gets what's needed to // diagnose it (Google's status and reason — never tokens or the payload). console.error( `[CalendarSync] appointment ${params.appointmentId} failed:`, error instanceof CalendarProviderError ? `${error.code}${error.detail ? ` (${error.detail})` : ""}` : error, ); const revoked = error instanceof CalendarProviderError && error.code === "TOKEN_REVOKED"; await prisma.staffCalendarConnection .update({ where: { id: params.connection.id }, // A dead refresh token can never work again, so stop pretending to be // connected: mark it revoked (the settings page then offers // "Reconnect", and every later sync short-circuits instead of // hammering Google with a token it has already refused) and keep the // reason so the page can explain why. data: revoked ? { lastSyncError: message, revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null } : { lastSyncError: message }, }) .catch(() => {}); if (existing) { await prisma.appointmentCalendarEvent .update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } }) .catch(() => {}); } return revoked ? "revoked" : "ok"; } } /** * Call after any appointment mutation that should be reflected in * calendars — confirmation, approval, reschedule, service change, * cancellation, no-show. Never throws; a Google failure is recorded on * the connection/event rows for the staff settings page to surface, not * propagated back to the caller (see this file's own header). If the * appointment was reassigned to a different staff member, the previous * staff member's event (if any) is cancelled first so responsibility * moves cleanly rather than leaving a stale duplicate. */ export async function syncAppointmentSafely( prisma: PrismaClient, appointmentId: string, provider: CalendarProvider = getProvider(), ): Promise { const appointment = await prisma.appointment.findUnique({ where: { id: appointmentId }, select: { staffId: true } }); if (!appointment) return; const staleEvents = await prisma.appointmentCalendarEvent.findMany({ where: { appointmentId, status: { not: CalendarSyncStatus.CANCELLED }, connection: { staffId: { not: appointment.staffId } } }, include: { connection: true }, }); for (const stale of staleEvents) { await syncOneConnection(prisma, { appointmentId, provider, connection: { id: stale.connection.id, encryptedAccessToken: stale.connection.encryptedAccessToken, encryptedRefreshToken: stale.connection.encryptedRefreshToken, tokenExpiresAt: stale.connection.tokenExpiresAt, selectedCalendarId: stale.connection.selectedCalendarId, }, }).catch(() => {}); } // A reassigned-away staff member's event is deleted via the CANCELLED // path above regardless of the appointment's own current status — it's // no longer this connection's appointment to reflect. if (staleEvents.length > 0) { await prisma.appointmentCalendarEvent.updateMany({ where: { id: { in: staleEvents.map((e) => e.id) } }, data: { status: CalendarSyncStatus.CANCELLED }, }); } const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId: appointment.staffId } }); if (!connection || connection.revokedAt) return; await syncOneConnection(prisma, { appointmentId, connection, provider }); } /** * Sync every future appointment for a newly connected staff member — see * the brief's "Initial sync... default to the next 90 days." `provider` * is injectable (defaults to the real Google provider) purely so tests * can pass a `MockCalendarProvider` — see * src/services/calendar/mock-calendar.provider.ts and the brief's "Do * not call real Google Calendar APIs in automated tests." */ export async function runInitialSync( prisma: PrismaClient, staffId: string, provider: CalendarProvider = getProvider(), ): Promise<{ synced: number }> { const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } }); if (!connection) return { synced: 0 }; const appointments = await prisma.appointment.findMany({ where: { staffId, startAt: { gte: new Date(), lte: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) }, status: { notIn: [AppointmentStatus.PENDING, AppointmentStatus.CANCELLED] }, }, select: { id: true }, }); let synced = 0; for (const appointment of appointments) { const outcome = await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider }); if (outcome === "revoked") break; // every remaining one would fail the same way synced++; } return { synced }; }