CI / ci (push) Canceled after 0s
fixed super admin showing in client list added a reconnect button to google calendar syncying fixed ui issue with whatsapp undo link
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
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<string> {
|
|
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<void> {
|
|
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 };
|
|
}
|