Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,187 +0,0 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
|
||||
import { encryptToken } from "@/lib/security/token-encryption";
|
||||
import { MockCalendarProvider } from "../mock-calendar.provider";
|
||||
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
|
||||
|
||||
/**
|
||||
* Exercises the real sync policy (create/update/cancel/duplicate
|
||||
* prevention/reassignment) against a real database, with a
|
||||
* `MockCalendarProvider` standing in for Google — see that file's own
|
||||
* header for why (the brief: "Do not call real Google Calendar APIs in
|
||||
* automated tests").
|
||||
*/
|
||||
describe("calendar-sync.service (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let staffId: string;
|
||||
let customerId: string;
|
||||
let provider: MockCalendarProvider;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
const staff = await createTestStaff(business);
|
||||
staffId = staff.staffId;
|
||||
const customer = await createTestCustomer(business);
|
||||
customerId = customer.customerId;
|
||||
provider = new MockCalendarProvider();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
async function connectCalendar(): Promise<string> {
|
||||
const connection = await prisma.staffCalendarConnection.create({
|
||||
data: {
|
||||
staffId,
|
||||
externalAccountId: "mock-account",
|
||||
accountEmail: "barber@example.com",
|
||||
encryptedAccessToken: encryptToken("mock-access-token"),
|
||||
encryptedRefreshToken: encryptToken("mock-refresh-token"),
|
||||
tokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||
selectedCalendarId: "primary",
|
||||
selectedCalendarName: "Primary Calendar",
|
||||
},
|
||||
});
|
||||
return connection.id;
|
||||
}
|
||||
|
||||
async function createAppointment(status: AppointmentStatus, overrides: { staffId?: string } = {}) {
|
||||
return prisma.appointment.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
staffId: overrides.staffId ?? staffId,
|
||||
customerId,
|
||||
status,
|
||||
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("creates a remote event for a CONFIRMED appointment", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event?.status).toBe(CalendarSyncStatus.SYNCED);
|
||||
expect(provider.events.size).toBe(1);
|
||||
const [providerEvent] = [...provider.events.values()];
|
||||
expect(providerEvent?.summary).toContain("Test Customer");
|
||||
});
|
||||
|
||||
it("does not sync a PENDING appointment", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.PENDING);
|
||||
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event).toBeNull();
|
||||
expect(provider.events.size).toBe(0);
|
||||
});
|
||||
|
||||
it("is idempotent — syncing an already-synced appointment updates the same remote event rather than creating a second one", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const events = await prisma.appointmentCalendarEvent.findMany({ where: { appointmentId: appointment.id } });
|
||||
expect(events).toHaveLength(1);
|
||||
expect(provider.events.size).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes the remote event when the appointment is cancelled", async () => {
|
||||
await connectCalendar();
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
expect(provider.events.size).toBe(1);
|
||||
|
||||
await prisma.appointment.update({ where: { id: appointment.id }, data: { status: AppointmentStatus.CANCELLED } });
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
expect(provider.events.size).toBe(0);
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event?.status).toBe(CalendarSyncStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it("cancels the previous staff member's event on reassignment and creates one under the new staff member's connection", async () => {
|
||||
await connectCalendar();
|
||||
const otherStaff = await createTestStaff(business);
|
||||
const otherConnection = await prisma.staffCalendarConnection.create({
|
||||
data: {
|
||||
staffId: otherStaff.staffId,
|
||||
externalAccountId: "mock-account-2",
|
||||
accountEmail: "other-barber@example.com",
|
||||
encryptedAccessToken: encryptToken("mock-access-token-2"),
|
||||
encryptedRefreshToken: encryptToken("mock-refresh-token-2"),
|
||||
tokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||
selectedCalendarId: "primary",
|
||||
selectedCalendarName: "Primary Calendar",
|
||||
},
|
||||
});
|
||||
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
expect(provider.events.size).toBe(1);
|
||||
|
||||
await prisma.appointment.update({ where: { id: appointment.id }, data: { staffId: otherStaff.staffId } });
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const originalConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
|
||||
where: { appointmentId: appointment.id, connection: { staffId } },
|
||||
});
|
||||
expect(originalConnectionEvent?.status).toBe(CalendarSyncStatus.CANCELLED);
|
||||
|
||||
const newConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
|
||||
where: { appointmentId: appointment.id, connectionId: otherConnection.id },
|
||||
});
|
||||
expect(newConnectionEvent?.status).toBe(CalendarSyncStatus.SYNCED);
|
||||
|
||||
await prisma.staffCalendarConnection.delete({ where: { id: otherConnection.id } }).catch(() => {});
|
||||
});
|
||||
|
||||
it("runInitialSync syncs every future non-pending, non-cancelled appointment for a newly connected staff member", async () => {
|
||||
await connectCalendar();
|
||||
const confirmed = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
const pending = await createAppointment(AppointmentStatus.PENDING);
|
||||
const cancelled = await createAppointment(AppointmentStatus.CANCELLED);
|
||||
|
||||
const result = await runInitialSync(prisma, staffId, provider);
|
||||
|
||||
expect(result.synced).toBe(1);
|
||||
expect(provider.events.size).toBe(1);
|
||||
const syncedEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: confirmed.id } });
|
||||
expect(syncedEvent).toBeTruthy();
|
||||
const pendingEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: pending.id } });
|
||||
expect(pendingEvent).toBeNull();
|
||||
const cancelledEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: cancelled.id } });
|
||||
expect(cancelledEvent).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing when no calendar connection exists for the staff member", async () => {
|
||||
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||
|
||||
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||
expect(event).toBeNull();
|
||||
expect(provider.events.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||
|
||||
/**
|
||||
* Provider-neutral operations `CalendarSyncService` calls — Google is the
|
||||
* only implementation today (`google-calendar.provider.ts`), but nothing
|
||||
* in the sync service or appointment Server Actions calls Google's API
|
||||
* directly (see the brief's explicit "Do not call Google APIs directly
|
||||
* from appointment Server Actions"), so a second provider is additive.
|
||||
*/
|
||||
export interface CalendarProvider {
|
||||
name: string;
|
||||
|
||||
/** OAuth authorization URL the browser is redirected to — `state` is an opaque, server-generated CSRF/replay token the callback route re-verifies. */
|
||||
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string;
|
||||
|
||||
/** Exchanges the callback's `code` for tokens, and resolves which external account this actually is. */
|
||||
exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens>;
|
||||
|
||||
refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken>;
|
||||
|
||||
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
|
||||
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
|
||||
|
||||
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
|
||||
|
||||
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
|
||||
|
||||
deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void>;
|
||||
|
||||
/** Best-effort server-side revocation at disconnect — see the brief's "Revoke credentials where possible." */
|
||||
revoke(refreshToken: string): Promise<void>;
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 } });
|
||||
} catch (error) {
|
||||
const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly.";
|
||||
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncError: message } }).catch(() => {});
|
||||
if (existing) {
|
||||
await prisma.appointmentCalendarEvent
|
||||
.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
});
|
||||
|
||||
for (const appointment of appointments) {
|
||||
await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider });
|
||||
}
|
||||
return { synced: appointments.length };
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import type { CalendarProvider } from "./calendar-provider";
|
||||
import { CalendarProviderError } from "./types";
|
||||
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||
|
||||
/**
|
||||
* Google Calendar over its plain REST API (OAuth 2.0 + Calendar API v3)
|
||||
* via `fetch` — no `googleapis` SDK dependency, matching this app's
|
||||
* existing pattern of building directly against a documented REST
|
||||
* contract rather than adding a heavy client library (see
|
||||
* brevo-email-provider.ts's own header for the same reasoning applied to
|
||||
* email). Minimum scopes: `calendar.events` (create/update/delete events)
|
||||
* and `calendar.readonly` (list calendars to choose a destination) —
|
||||
* deliberately not the full `calendar` scope, which also grants calendar
|
||||
* *settings* management this app never needs.
|
||||
*
|
||||
* Unverified against a live Google account in this environment — no
|
||||
* OAuth credentials were available to test against. See
|
||||
* docs/phase-8-summary.md, "Unverified external-provider behaviour."
|
||||
*/
|
||||
|
||||
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" ");
|
||||
|
||||
interface GoogleErrorBody {
|
||||
error?: string | { message?: string; errors?: { reason?: string }[] };
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError {
|
||||
const reason =
|
||||
typeof body?.error === "object" ? body.error.errors?.[0]?.reason : typeof body?.error === "string" ? body.error : undefined;
|
||||
|
||||
if (status === 401 || reason === "authError") {
|
||||
return new CalendarProviderError("TOKEN_EXPIRED", "The connection to Google Calendar has expired.", true);
|
||||
}
|
||||
if (status === 403 && reason === "insufficientPermissions") {
|
||||
return new CalendarProviderError("CALENDAR_NOT_WRITABLE", "This calendar can't be written to.", false);
|
||||
}
|
||||
if (status === 403 || status === 429) {
|
||||
return new CalendarProviderError("RATE_LIMITED", "Google Calendar is temporarily rate-limiting requests.", true);
|
||||
}
|
||||
if (status === 404) {
|
||||
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false);
|
||||
}
|
||||
if (status === 400) {
|
||||
return new CalendarProviderError("INVALID_EVENT", "Google Calendar rejected this event.", false);
|
||||
}
|
||||
if (status >= 500) {
|
||||
return new CalendarProviderError("PROVIDER_OUTAGE", "Google Calendar is temporarily unavailable.", true);
|
||||
}
|
||||
return new CalendarProviderError("UNKNOWN", "Google Calendar returned an unexpected error.", true);
|
||||
}
|
||||
|
||||
async function googleFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch {
|
||||
throw new CalendarProviderError("TIMEOUT", "Could not reach Google Calendar.", true);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let body: GoogleErrorBody | undefined;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Non-JSON error body — mapGoogleError below still has the status code.
|
||||
}
|
||||
throw mapGoogleError(response.status, body);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
interface GoogleConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
function loadConfig(): GoogleConfig {
|
||||
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID;
|
||||
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error("GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET must be set to use Google Calendar sync.");
|
||||
}
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
function toGoogleEventBody(event: CalendarEventInput) {
|
||||
return {
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
start: { dateTime: event.startAt.toISOString() },
|
||||
end: { dateTime: event.endAt.toISOString() },
|
||||
// Never public — see the brief's "Events must not be public."
|
||||
visibility: "private",
|
||||
};
|
||||
}
|
||||
|
||||
export class GoogleCalendarProvider implements CalendarProvider {
|
||||
name = "google";
|
||||
|
||||
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string {
|
||||
const { clientId } = loadConfig();
|
||||
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
||||
url.searchParams.set("client_id", clientId);
|
||||
url.searchParams.set("redirect_uri", params.redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", SCOPES);
|
||||
url.searchParams.set("access_type", "offline");
|
||||
// Forces Google to reissue a refresh token even for a user who
|
||||
// previously connected — otherwise a reconnect after disconnect can
|
||||
// silently get no refresh token at all.
|
||||
url.searchParams.set("prompt", "consent");
|
||||
url.searchParams.set("state", params.state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens> {
|
||||
const { clientId, clientSecret } = loadConfig();
|
||||
const response = await googleFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
code: params.code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: params.redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
}),
|
||||
});
|
||||
const tokens = await response.json();
|
||||
if (!tokens.refresh_token) {
|
||||
throw new CalendarProviderError(
|
||||
"MISSING_REFRESH_TOKEN",
|
||||
"Google didn't return a refresh token — please try connecting again.",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const userInfoResponse = await googleFetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = await userInfoResponse.json();
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
externalAccountId: userInfo.id,
|
||||
accountEmail: userInfo.email,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken> {
|
||||
const { clientId, clientSecret } = loadConfig();
|
||||
const response = await googleFetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000) };
|
||||
}
|
||||
|
||||
async listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]> {
|
||||
const response = await googleFetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await response.json();
|
||||
const items: { id: string; summary: string; primary?: boolean; accessRole: string }[] = data.items ?? [];
|
||||
return items
|
||||
.filter((item) => item.accessRole === "owner" || item.accessRole === "writer")
|
||||
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
|
||||
}
|
||||
|
||||
async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||
const response = await googleFetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toGoogleEventBody(params.event)),
|
||||
},
|
||||
);
|
||||
const created = await response.json();
|
||||
return { providerEventId: created.id };
|
||||
}
|
||||
|
||||
async updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void> {
|
||||
await googleFetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toGoogleEventBody(params.event)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void> {
|
||||
try {
|
||||
await googleFetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
|
||||
{ method: "DELETE", headers: { Authorization: `Bearer ${params.accessToken}` } },
|
||||
);
|
||||
} catch (error) {
|
||||
// Already deleted on the Google side (e.g. the owner removed it
|
||||
// manually) — not a failure from this app's point of view.
|
||||
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(refreshToken)}`, { method: "POST" });
|
||||
} catch {
|
||||
// Best-effort — see the brief's "Revoke credentials where possible." A failure here doesn't block local disconnect.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { CalendarProvider } from "./calendar-provider";
|
||||
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||
|
||||
/**
|
||||
* In-memory `CalendarProvider` for tests — never makes a real network
|
||||
* call, per the brief's "Do not call real Google Calendar APIs in
|
||||
* automated tests." Records every create/update/delete so a test can
|
||||
* assert on sync behavior (idempotency, retry, error mapping) without a
|
||||
* live Google account.
|
||||
*/
|
||||
export class MockCalendarProvider implements CalendarProvider {
|
||||
name = "mock";
|
||||
|
||||
events = new Map<string, CalendarEventInput>();
|
||||
private nextEventId = 1;
|
||||
public calendars: RemoteCalendarInfo[] = [{ id: "primary", name: "Primary Calendar", writable: true, primary: true }];
|
||||
|
||||
buildAuthorizationUrl(): string {
|
||||
return "https://mock.local/authorize";
|
||||
}
|
||||
|
||||
async exchangeCodeForTokens(): Promise<ExchangedTokens> {
|
||||
return {
|
||||
accessToken: "mock-access-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
externalAccountId: "mock-account-id",
|
||||
accountEmail: "mock@example.com",
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<RefreshedAccessToken> {
|
||||
return { accessToken: "mock-access-token-refreshed", expiresAt: new Date(Date.now() + 3600_000) };
|
||||
}
|
||||
|
||||
async listWritableCalendars(): Promise<RemoteCalendarInfo[]> {
|
||||
return this.calendars;
|
||||
}
|
||||
|
||||
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||
const id = `mock-event-${this.nextEventId++}`;
|
||||
this.events.set(id, params.event);
|
||||
return { providerEventId: id };
|
||||
}
|
||||
|
||||
async updateEvent(params: { providerEventId: string; event: CalendarEventInput }): Promise<void> {
|
||||
this.events.set(params.providerEventId, params.event);
|
||||
}
|
||||
|
||||
async deleteEvent(params: { providerEventId: string }): Promise<void> {
|
||||
this.events.delete(params.providerEventId);
|
||||
}
|
||||
|
||||
async revoke(): Promise<void> {
|
||||
// No-op — nothing to revoke in-memory.
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Provider-neutral shapes for one-way (business → calendar) appointment
|
||||
* sync — see calendar-provider.ts for the interface these compose, and
|
||||
* docs/phase-8-summary.md for the overall architecture.
|
||||
*/
|
||||
|
||||
export interface CalendarEventInput {
|
||||
summary: string;
|
||||
description: string;
|
||||
location?: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
}
|
||||
|
||||
export interface RemoteCalendarInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Only calendars the connected account can write to are ever offered — see the brief's "Do not allow read-only calendars." */
|
||||
writable: boolean;
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
export interface ExchangedTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** Absent when the provider didn't return one (e.g. a token refresh call, which doesn't reissue a refresh token). */
|
||||
expiresAt: Date;
|
||||
externalAccountId: string;
|
||||
accountEmail: string;
|
||||
}
|
||||
|
||||
export interface RefreshedAccessToken {
|
||||
accessToken: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export type CalendarProviderErrorCode =
|
||||
| "CONSENT_DENIED"
|
||||
| "TOKEN_EXPIRED"
|
||||
| "TOKEN_REVOKED"
|
||||
| "MISSING_REFRESH_TOKEN"
|
||||
| "CALENDAR_NOT_FOUND"
|
||||
| "CALENDAR_NOT_WRITABLE"
|
||||
| "RATE_LIMITED"
|
||||
| "PROVIDER_OUTAGE"
|
||||
| "INVALID_EVENT"
|
||||
| "TIMEOUT"
|
||||
| "UNKNOWN";
|
||||
|
||||
/** Never exposes a raw provider response — see the brief's "Do not expose raw provider responses." */
|
||||
export class CalendarProviderError extends Error {
|
||||
code: CalendarProviderErrorCode;
|
||||
retryable: boolean;
|
||||
|
||||
constructor(code: CalendarProviderErrorCode, message: string, retryable: boolean) {
|
||||
super(message);
|
||||
this.name = "CalendarProviderError";
|
||||
this.code = code;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user