From 4537a5d5928e3dab97dbbce44e542d978dd4d76e Mon Sep 17 00:00:00 2001 From: Real Codesiman Date: Thu, 10 Sep 2026 05:05:06 +0700 Subject: [PATCH] feat(appointments): add public API for appointments, calendars, external calendars, and reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the workspace-token public API surface to cover appointment scheduling — calendars, external calendar connections, reminders, and appointments themselves — following the existing public router pattern. Co-Authored-By: Claude Sonnet 5 --- .../public-spec-operations.test.ts.snap | 80 +++++ .../appointment-calendars-public-api.test.ts | 288 ++++++++++++++++++ ...ment-external-calendars-public-api.test.ts | 157 ++++++++++ .../appointment-reminders-public-api.test.ts | 126 ++++++++ .../__tests__/appointments-public-api.test.ts | 212 +++++++++++++ .../appointments-public-scope.test.ts | 116 +++++++ .../appointment-calendars/api/public.ts | 193 ++++++++++++ .../appointment-calendars/schema/public.ts | 78 +++++ .../api/public.ts | 61 ++++ .../schema/public.ts | 32 ++ .../appointment-management/api/public.ts | 38 +++ .../appointment-management/schema/public.ts | 29 ++ .../src/features/appointments/api/public.ts | 132 ++++++++ .../features/appointments/schema/public.ts | 62 ++++ .../builder/src/lib/orpc/orpc-error-helper.ts | 62 ++++ apps/builder/src/routers/public.ts | 8 + docs/developer/workspace-api-tokens.md | 63 ++++ 17 files changed, 1737 insertions(+) create mode 100644 apps/builder/__tests__/appointment-calendars-public-api.test.ts create mode 100644 apps/builder/__tests__/appointment-external-calendars-public-api.test.ts create mode 100644 apps/builder/__tests__/appointment-reminders-public-api.test.ts create mode 100644 apps/builder/__tests__/appointments-public-api.test.ts create mode 100644 apps/builder/__tests__/appointments-public-scope.test.ts create mode 100644 apps/builder/src/features/appointment-calendars/api/public.ts create mode 100644 apps/builder/src/features/appointment-calendars/schema/public.ts create mode 100644 apps/builder/src/features/appointment-external-calendars/api/public.ts create mode 100644 apps/builder/src/features/appointment-external-calendars/schema/public.ts create mode 100644 apps/builder/src/features/appointment-management/api/public.ts create mode 100644 apps/builder/src/features/appointment-management/schema/public.ts create mode 100644 apps/builder/src/features/appointments/api/public.ts create mode 100644 apps/builder/src/features/appointments/schema/public.ts diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index f47a635f2f..e9a36a79b4 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -207,6 +207,86 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "analytics.uniqueConversationsByAdmin", "path": "/v1/analytics/unique-conversations-by-admin", }, + { + "method": "POST", + "operationId": "appointmentCalendars.create", + "path": "/v1/appointment-calendars", + }, + { + "method": "DELETE", + "operationId": "appointmentCalendars.delete", + "path": "/v1/appointment-calendars/{id}", + }, + { + "method": "POST", + "operationId": "appointmentCalendars.duplicate", + "path": "/v1/appointment-calendars/{id}/duplicate", + }, + { + "method": "GET", + "operationId": "appointmentCalendars.get", + "path": "/v1/appointment-calendars/{id}", + }, + { + "method": "GET", + "operationId": "appointmentCalendars.getAvailability", + "path": "/v1/appointment-calendars/{id}/availability", + }, + { + "method": "GET", + "operationId": "appointmentCalendars.list", + "path": "/v1/appointment-calendars", + }, + { + "method": "PATCH", + "operationId": "appointmentCalendars.setActive", + "path": "/v1/appointment-calendars/{id}/active", + }, + { + "method": "PUT", + "operationId": "appointmentCalendars.update", + "path": "/v1/appointment-calendars/{id}", + }, + { + "method": "DELETE", + "operationId": "appointmentExternalCalendars.disconnect", + "path": "/v1/appointment-external-calendars/{integrationId}", + }, + { + "method": "GET", + "operationId": "appointmentExternalCalendars.list", + "path": "/v1/appointment-external-calendars", + }, + { + "method": "GET", + "operationId": "appointmentReminders.list", + "path": "/v1/appointment-reminders", + }, + { + "method": "POST", + "operationId": "appointments.book", + "path": "/v1/appointments", + }, + { + "method": "POST", + "operationId": "appointments.cancel", + "path": "/v1/appointments/{id}/cancel", + }, + { + "method": "DELETE", + "operationId": "appointments.delete", + "path": "/v1/appointments/{id}", + }, + { + "method": "GET", + "operationId": "appointments.get", + "path": "/v1/appointments/{id}", + }, + { + "method": "GET", + "operationId": "appointments.list", + "path": "/v1/appointments", + }, { "method": "PUT", "operationId": "botFields.bulkUpdate", diff --git a/apps/builder/__tests__/appointment-calendars-public-api.test.ts b/apps/builder/__tests__/appointment-calendars-public-api.test.ts new file mode 100644 index 0000000000..8b458fbbd6 --- /dev/null +++ b/apps/builder/__tests__/appointment-calendars-public-api.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const appointmentCalendarService = { + list: vi.fn(), + getForEdit: vi.fn(), + create: vi.fn(), + update: vi.fn(), + setActive: vi.fn(), + duplicate: vi.fn(), + deleteMany: vi.fn(), +} +const appointmentService = { + checkAvailability: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ + appointmentCalendarService, + appointmentService, +})) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + optional: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + appointmentCalendarModel: {}, + appointmentCalendarAvailabilityModel: {}, + appointmentCalendarReminderModel: {}, + } +}) + +await import("@/features/appointment-calendars/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the appointment calendars public router under the appointments scope", () => { + expect(scopeArgAtImport).toBe("appointments") +}) + +describe("GET /v1/appointment-calendars", () => { + const procedure = findProcedure("GET", "/v1/appointment-calendars") + + test("delegates to appointmentCalendarService.list", async () => { + appointmentCalendarService.list.mockResolvedValueOnce({ + data: [], + pageCount: 1, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50, search: "sales" }, + }) + + expect(appointmentCalendarService.list).toHaveBeenCalledWith({ + page: 1, + perPage: 50, + search: "sales", + workspaceId: "workspace-1", + }) + }) +}) + +describe("GET /v1/appointment-calendars/{id}", () => { + const procedure = findProcedure("GET", "/v1/appointment-calendars/{id}") + + test("delegates to appointmentCalendarService.getForEdit", async () => { + appointmentCalendarService.getForEdit.mockResolvedValueOnce({ + id: "cal-1", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "cal-1" }, + }) + + expect(appointmentCalendarService.getForEdit).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "cal-1", + }) + }) +}) + +describe("POST /v1/appointment-calendars", () => { + const procedure = findProcedure("POST", "/v1/appointment-calendars") + + test("delegates to appointmentCalendarService.create", async () => { + appointmentCalendarService.create.mockResolvedValueOnce("cal-1") + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { name: "Sales calls" }, + }) + + expect(appointmentCalendarService.create).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + name: "Sales calls", + }) + expect(result).toEqual({ id: "cal-1" }) + }) +}) + +describe("PUT /v1/appointment-calendars/{id}", () => { + const procedure = findProcedure("PUT", "/v1/appointment-calendars/{id}") + + test("derives scheduleWindowType from scheduleWindowConfig", async () => { + appointmentCalendarService.update.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + id: "cal-1", + name: "Sales calls", + active: true, + timezone: "UTC", + durationMinutes: 30, + scheduleWindowConfig: { + scheduleWindowType: "rollingDays", + rollingDays: 14, + }, + availability: [], + reminders: [], + }, + }) + + expect(appointmentCalendarService.update).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "workspace-1", + id: "cal-1", + scheduleWindowType: "rollingDays", + }), + ) + }) +}) + +describe("PATCH /v1/appointment-calendars/{id}/active", () => { + const procedure = findProcedure( + "PATCH", + "/v1/appointment-calendars/{id}/active", + ) + + test("delegates to appointmentCalendarService.setActive", async () => { + appointmentCalendarService.setActive.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "cal-1", active: false }, + }) + + expect(appointmentCalendarService.setActive).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "cal-1", + active: false, + }) + }) +}) + +describe("POST /v1/appointment-calendars/{id}/duplicate", () => { + const procedure = findProcedure( + "POST", + "/v1/appointment-calendars/{id}/duplicate", + ) + + test("delegates to appointmentCalendarService.duplicate", async () => { + appointmentCalendarService.duplicate.mockResolvedValueOnce("cal-2") + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "cal-1" }, + }) + + expect(appointmentCalendarService.duplicate).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "cal-1", + }) + expect(result).toEqual({ id: "cal-2" }) + }) +}) + +describe("DELETE /v1/appointment-calendars/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/appointment-calendars/{id}") + + test("delegates to appointmentCalendarService.deleteMany", async () => { + appointmentCalendarService.deleteMany.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "cal-1" }, + }) + + expect(appointmentCalendarService.deleteMany).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ids: ["cal-1"], + }) + }) +}) + +describe("GET /v1/appointment-calendars/{id}/availability", () => { + const procedure = findProcedure( + "GET", + "/v1/appointment-calendars/{id}/availability", + ) + + test("delegates to appointmentService.checkAvailability", async () => { + const startDate = new Date("2026-01-01T00:00:00Z") + const endDate = new Date("2026-01-07T00:00:00Z") + appointmentService.checkAvailability.mockResolvedValueOnce({ + text: "", + slots: [], + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "cal-1", startDate, endDate, contactId: "contact-1" }, + }) + + expect(appointmentService.checkAvailability).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + calendarId: "cal-1", + contactId: "contact-1", + startDate, + endDate, + }) + }) +}) diff --git a/apps/builder/__tests__/appointment-external-calendars-public-api.test.ts b/apps/builder/__tests__/appointment-external-calendars-public-api.test.ts new file mode 100644 index 0000000000..95e72623b6 --- /dev/null +++ b/apps/builder/__tests__/appointment-external-calendars-public-api.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const appointmentExternalCalendarService = { + list: vi.fn(), + listWithConnectedCount: vi.fn(), + disconnect: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ appointmentExternalCalendarService })) + +await import("@/features/appointment-external-calendars/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the appointment external calendars public router under the appointments scope", () => { + expect(scopeArgAtImport).toBe("appointments") +}) + +describe("GET /v1/appointment-external-calendars", () => { + const procedure = findProcedure("GET", "/v1/appointment-external-calendars") + + test("delegates to listWithConnectedCount, never the raw list method that can carry OAuth credentials", async () => { + appointmentExternalCalendarService.listWithConnectedCount.mockResolvedValueOnce( + [ + { + id: "integration-1", + providerType: "googleCalendar", + label: "user@example.com (primary)", + providerCalendarId: "primary", + email: "user@example.com", + workspaceId: "workspace-1", + connectedCount: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + ) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect( + appointmentExternalCalendarService.listWithConnectedCount, + ).toHaveBeenCalledWith({ workspaceId: "workspace-1" }) + expect(appointmentExternalCalendarService.list).not.toHaveBeenCalled() + expect(result.data).toHaveLength(1) + expect(result.data[0]).not.toHaveProperty("auth") + }) + + test("paginates in memory", async () => { + appointmentExternalCalendarService.listWithConnectedCount.mockResolvedValueOnce( + Array.from({ length: 3 }, (_, index) => ({ + id: `integration-${index}`, + providerType: "googleCalendar", + label: `cal-${index}`, + providerCalendarId: "primary", + email: null, + workspaceId: "workspace-1", + connectedCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + })), + ) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 2 }, + }) + + expect(result.data).toHaveLength(2) + expect(result.pageCount).toBe(2) + }) +}) + +describe("DELETE /v1/appointment-external-calendars/{integrationId}", () => { + const procedure = findProcedure( + "DELETE", + "/v1/appointment-external-calendars/{integrationId}", + ) + + test("delegates to appointmentExternalCalendarService.disconnect", async () => { + appointmentExternalCalendarService.disconnect.mockResolvedValueOnce( + "integration-1", + ) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { integrationId: "integration-1" }, + }) + + expect(appointmentExternalCalendarService.disconnect).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + integrationId: "integration-1", + }) + }) +}) diff --git a/apps/builder/__tests__/appointment-reminders-public-api.test.ts b/apps/builder/__tests__/appointment-reminders-public-api.test.ts new file mode 100644 index 0000000000..985bbd2aa6 --- /dev/null +++ b/apps/builder/__tests__/appointment-reminders-public-api.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const appointmentReminderService = { + listDispatches: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ appointmentReminderService })) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + optional: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + appointmentReminderDispatchModel: {}, + } +}) + +await import("@/features/appointment-management/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the appointment reminders public router under the appointments scope", () => { + expect(scopeArgAtImport).toBe("appointments") +}) + +describe("GET /v1/appointment-reminders", () => { + const procedure = findProcedure("GET", "/v1/appointment-reminders") + + test("always passes workspaceId explicitly, since the repository's list input treats it as optional", async () => { + appointmentReminderService.listDispatches.mockResolvedValueOnce({ + data: [], + pageCount: 1, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50, status: "pending" }, + }) + + expect(appointmentReminderService.listDispatches).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + status: "pending", + page: 1, + perPage: 50, + }) + }) + + test("never omits workspaceId even when the caller's input has none", async () => { + appointmentReminderService.listDispatches.mockResolvedValueOnce({ + data: [], + pageCount: 1, + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-2" } }, + input: { page: 1, perPage: 50 }, + }) + + const call = appointmentReminderService.listDispatches.mock.calls[0]?.[0] + expect(call.workspaceId).toBe("workspace-2") + }) +}) diff --git a/apps/builder/__tests__/appointments-public-api.test.ts b/apps/builder/__tests__/appointments-public-api.test.ts new file mode 100644 index 0000000000..5ffa388e51 --- /dev/null +++ b/apps/builder/__tests__/appointments-public-api.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const appointmentService = { + list: vi.fn(), + findByOrFail: vi.fn(), + bookAppointment: vi.fn(), + cancelAppointmentById: vi.fn(), + deleteAppointmentById: vi.fn(), +} +const resolveTenantSettings = vi.fn() +vi.mock("@chatbotx.io/business", () => ({ + appointmentService, + resolveTenantSettings, +})) + +vi.mock("@chatbotx.io/database/schema", () => { + const schema = { + pick: vi.fn(() => schema), + extend: vi.fn(() => schema), + omit: vi.fn(() => schema), + and: vi.fn(() => schema), + optional: vi.fn(() => schema), + } + return { + createSelectSchema: vi.fn(() => schema), + appointmentModel: {}, + } +}) + +await import("@/features/appointments/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the appointments public router under the appointments scope", () => { + expect(scopeArgAtImport).toBe("appointments") +}) + +describe("GET /v1/appointments", () => { + const procedure = findProcedure("GET", "/v1/appointments") + + test("resolves appUrl itself and delegates to appointmentService.list without a session", async () => { + resolveTenantSettings.mockResolvedValueOnce({ appUrl: "https://app.test" }) + appointmentService.list.mockResolvedValueOnce({ data: [], pageCount: 1 }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50, calendarId: "cal-1", tab: "next" }, + }) + + expect(resolveTenantSettings).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + }) + expect(appointmentService.list).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + calendarId: "cal-1", + tab: "next", + search: undefined, + page: 1, + perPage: 50, + appUrl: "https://app.test", + }) + }) +}) + +describe("GET /v1/appointments/{id}", () => { + const procedure = findProcedure("GET", "/v1/appointments/{id}") + + test("delegates to appointmentService.findByOrFail", async () => { + appointmentService.findByOrFail.mockResolvedValueOnce({ id: "appt-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "appt-1" }, + }) + + expect(appointmentService.findByOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "appt-1", + }) + }) +}) + +describe("POST /v1/appointments", () => { + const procedure = findProcedure("POST", "/v1/appointments") + + test("delegates to appointmentService.bookAppointment", async () => { + const startAt = new Date("2026-01-01T10:00:00Z") + appointmentService.bookAppointment.mockResolvedValueOnce({ id: "appt-1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + calendarId: "cal-1", + contactId: "contact-1", + conversationId: "conv-1", + startAt, + inviteeTimezone: "UTC", + }, + }) + + expect(appointmentService.bookAppointment).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + calendarId: "cal-1", + contactId: "contact-1", + conversationId: "conv-1", + startAt, + inviteeTimezone: "UTC", + }) + }) + + test("declares the booking 409 error codes", () => { + expect(procedure.route.summary).toBe("Book an appointment") + }) +}) + +describe("POST /v1/appointments/{id}/cancel", () => { + const procedure = findProcedure("POST", "/v1/appointments/{id}/cancel") + + test("delegates to appointmentService.cancelAppointmentById", async () => { + appointmentService.cancelAppointmentById.mockResolvedValueOnce({ + id: "appt-1", + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "appt-1" }, + }) + + expect(appointmentService.cancelAppointmentById).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + appointmentId: "appt-1", + }) + }) +}) + +describe("DELETE /v1/appointments/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/appointments/{id}") + + test("delegates to appointmentService.deleteAppointmentById", async () => { + appointmentService.deleteAppointmentById.mockResolvedValueOnce(undefined) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "appt-1" }, + }) + + expect(appointmentService.deleteAppointmentById).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + appointmentId: "appt-1", + }) + }) +}) diff --git a/apps/builder/__tests__/appointments-public-scope.test.ts b/apps/builder/__tests__/appointments-public-scope.test.ts new file mode 100644 index 0000000000..3325256d5b --- /dev/null +++ b/apps/builder/__tests__/appointments-public-scope.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findWorkspaceByTokenHash, + isWorkspaceScheduledForDeletion, + getAccessState, + isAtLimit, + assertApiNotRateLimited, +} = vi.hoisted(() => ({ + findWorkspaceByTokenHash: vi.fn(), + isWorkspaceScheduledForDeletion: vi.fn().mockReturnValue(false), + getAccessState: vi.fn().mockResolvedValue({ blocked: false }), + isAtLimit: vi.fn().mockResolvedValue(false), + assertApiNotRateLimited: vi.fn().mockResolvedValue(undefined), +})) + +const appointmentCalendarService = { list: vi.fn() } + +vi.mock("@chatbotx.io/business", () => ({ + workspaceApiTokenService: { findWorkspaceByTokenHash }, + isWorkspaceScheduledForDeletion, + userQuotaService: { getAccessState }, + quotaEnforcementService: { isAtLimit }, + appointmentCalendarService, +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited, +})) + +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.9", +})) + +vi.mock("@/env", () => ({ isCloud: () => true })) + +// `@/orpc` also exports `authorizedAPI`, which pulls in the full better-auth +// stack via `authMiddleware` — irrelevant here and unsafe to initialize in a +// unit test. Same stub as workspace-token-scope-enforcement.test.ts. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), +})) + +const { call } = await import("@orpc/server") +const { appointmentCalendarsPublicRouter } = await import( + "../src/features/appointment-calendars/api/public" +) + +const TOKEN = "cbx_ws_fixture" + +const authResult = (scopes: string[] | null) => ({ + workspace: { id: "ws-1", ownerId: "owner-1" }, + apiToken: { id: "token-1", permission: "full" as const, scopes }, +}) + +const invoke = (procedure: typeof appointmentCalendarsPublicRouter.list) => + call( + procedure, + { page: 1, perPage: 50 }, + { + context: { headers: new Headers({ Authorization: `Bearer ${TOKEN}` }) }, + }, + ) + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + getAccessState.mockResolvedValue({ blocked: false }) + isAtLimit.mockResolvedValue(false) + assertApiNotRateLimited.mockResolvedValue(undefined) +}) + +describe("real router: appointment calendars public API scope wiring", () => { + test("a contacts-scoped token is denied the real GET /v1/appointment-calendars route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect( + invoke(appointmentCalendarsPublicRouter.list), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'appointments' scope", + }) + }) + + test("null scopes (unrestricted) passes the real GET /v1/appointment-calendars route", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + appointmentCalendarService.list.mockResolvedValue({ + data: [], + pageCount: 1, + total: 0, + }) + + await expect( + invoke(appointmentCalendarsPublicRouter.list), + ).resolves.toMatchObject({ data: [] }) + }) + + test("an appointments-scoped token passes the real GET /v1/appointment-calendars route", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["appointments"])) + appointmentCalendarService.list.mockResolvedValue({ + data: [], + pageCount: 1, + total: 0, + }) + + await expect( + invoke(appointmentCalendarsPublicRouter.list), + ).resolves.toMatchObject({ data: [] }) + }) +}) diff --git a/apps/builder/src/features/appointment-calendars/api/public.ts b/apps/builder/src/features/appointment-calendars/api/public.ts new file mode 100644 index 0000000000..3ab2d5484f --- /dev/null +++ b/apps/builder/src/features/appointment-calendars/api/public.ts @@ -0,0 +1,193 @@ +import { + appointmentCalendarService, + appointmentService, +} from "@chatbotx.io/business" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { publicListResponse } from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + createAppointmentCalendarRequest, + updateAppointmentCalendarRequest, +} from "../schema/action" +import { + appointmentCalendarAvailabilityPublicResponse, + appointmentCalendarForEditPublicResource, + appointmentCalendarIdPublicRequest, + appointmentCalendarPublicResource, + createAppointmentCalendarPublicResponse, + getAppointmentCalendarAvailabilityPublicRequest, + listAppointmentCalendarsPublicRequest, + setAppointmentCalendarActivePublicRequest, +} from "../schema/public" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("appointments") + +const tags = ["Appointment Calendars"] + +export const appointmentCalendarsPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/appointment-calendars", + summary: "List appointment calendars", + tags, + }) + .input(listAppointmentCalendarsPublicRequest) + .output(publicListResponse(appointmentCalendarPublicResource)) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const { data, pageCount } = await appointmentCalendarService.list({ + ...input, + workspaceId: context.workspace.id, + }) + return { data, pageCount } + }), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/appointment-calendars/{id}", + summary: "Get an appointment calendar", + description: + "Returns the calendar's full configuration including its availability intervals and reminders.", + tags, + }) + .input(appointmentCalendarIdPublicRequest) + .output(appointmentCalendarForEditPublicResource) + .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context, input }) => + await appointmentCalendarService.getForEdit({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/appointment-calendars", + summary: "Create an appointment calendar", + description: + "Creates a new calendar with default settings. Use the update endpoint to configure duration, availability, and reminders.", + successStatus: 201, + tags, + }) + .input(createAppointmentCalendarRequest) + .output(createAppointmentCalendarPublicResponse) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + const id = await appointmentCalendarService.create({ + workspaceId: context.workspace.id, + name: input.name, + }) + return { id } + }), + + update: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/appointment-calendars/{id}", + summary: "Update an appointment calendar", + tags, + }) + .input( + updateAppointmentCalendarRequest.and(appointmentCalendarIdPublicRequest), + ) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, ...data } = input + await appointmentCalendarService.update({ + workspaceId: context.workspace.id, + id, + ...data, + scheduleWindowType: data.scheduleWindowConfig.scheduleWindowType, + }) + }), + + setActive: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/appointment-calendars/{id}/active", + summary: "Activate or deactivate an appointment calendar", + tags, + }) + .input( + setAppointmentCalendarActivePublicRequest.and( + appointmentCalendarIdPublicRequest, + ), + ) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + await appointmentCalendarService.setActive({ + workspaceId: context.workspace.id, + id: input.id, + active: input.active, + }) + }), + + duplicate: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/appointment-calendars/{id}/duplicate", + summary: "Duplicate an appointment calendar", + successStatus: 201, + tags, + }) + .input(appointmentCalendarIdPublicRequest) + .output(createAppointmentCalendarPublicResponse) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const id = await appointmentCalendarService.duplicate({ + workspaceId: context.workspace.id, + id: input.id, + }) + return { id } + }), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/appointment-calendars/{id}", + summary: "Delete an appointment calendar", + successStatus: 204, + tags, + }) + .input(appointmentCalendarIdPublicRequest) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await appointmentCalendarService.deleteMany({ + workspaceId: context.workspace.id, + ids: [input.id], + }) + }), + + getAvailability: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/appointment-calendars/{id}/availability", + summary: "Check appointment availability for a calendar", + description: + "Returns bookable slots between startDate and endDate, accounting for existing appointments, buffers, and connected external calendars.", + tags, + }) + .input(getAppointmentCalendarAvailabilityPublicRequest) + .output(appointmentCalendarAvailabilityPublicResponse) + .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context, input }) => + await appointmentService.checkAvailability({ + workspaceId: context.workspace.id, + calendarId: input.id, + contactId: input.contactId, + startDate: input.startDate, + endDate: input.endDate, + }), + ), +} diff --git a/apps/builder/src/features/appointment-calendars/schema/public.ts b/apps/builder/src/features/appointment-calendars/schema/public.ts new file mode 100644 index 0000000000..5d929eca1d --- /dev/null +++ b/apps/builder/src/features/appointment-calendars/schema/public.ts @@ -0,0 +1,78 @@ +import { + appointmentCalendarAvailabilityModel, + appointmentCalendarReminderModel, + createSelectSchema, +} from "@chatbotx.io/database/schema" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { publicListRequest } from "@/lib/public-api/list" +import { appointmentCalendarResource } from "./resource" + +// Public request/response schemas — `workspaceId` is never accepted from +// client input (it comes from the token's resolved workspace) and never +// echoed in a response; see `public-spec-operations.test.ts`'s full sweep. + +export const appointmentCalendarPublicResource = + appointmentCalendarResource.omit({ workspaceId: true }) +export type AppointmentCalendarPublicResource = z.infer< + typeof appointmentCalendarPublicResource +> + +export const listAppointmentCalendarsPublicRequest = publicListRequest.extend({ + search: z.string().optional(), +}) + +const appointmentCalendarAvailabilityPublicResource = createSelectSchema( + appointmentCalendarAvailabilityModel, + { + id: z.string(), + calendarId: z.string(), + }, +).omit({ calendarId: true }) + +const appointmentCalendarReminderPublicResource = createSelectSchema( + appointmentCalendarReminderModel, + { + id: z.string(), + calendarId: z.string(), + flowId: z.string(), + }, +).omit({ calendarId: true }) + +export const appointmentCalendarForEditPublicResource = + appointmentCalendarPublicResource.extend({ + availability: z.array(appointmentCalendarAvailabilityPublicResource), + reminders: z.array(appointmentCalendarReminderPublicResource), + }) +export type AppointmentCalendarForEditPublicResource = z.infer< + typeof appointmentCalendarForEditPublicResource +> + +export const createAppointmentCalendarPublicResponse = z.object({ + id: z.string(), +}) + +export const appointmentCalendarIdPublicRequest = z.object({ + id: zodBigintAsString(), +}) + +export const setAppointmentCalendarActivePublicRequest = z.object({ + active: z.boolean(), +}) + +export const getAppointmentCalendarAvailabilityPublicRequest = z.object({ + id: zodBigintAsString(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + contactId: zodBigintAsString().optional(), +}) + +export const appointmentCalendarAvailabilityPublicResponse = z.object({ + text: z.string(), + slots: z.array( + z.object({ + startAt: z.date(), + endAt: z.date(), + }), + ), +}) diff --git a/apps/builder/src/features/appointment-external-calendars/api/public.ts b/apps/builder/src/features/appointment-external-calendars/api/public.ts new file mode 100644 index 0000000000..4977220f5d --- /dev/null +++ b/apps/builder/src/features/appointment-external-calendars/api/public.ts @@ -0,0 +1,61 @@ +import { appointmentExternalCalendarService } from "@chatbotx.io/business" +import { + possibleErrorsOnDisconnectingExternalCalendar, + possibleErrorsOnListingResource, +} from "@/lib/orpc/orpc-error-helper" +import { paginateInMemory, publicListRequest } from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + appointmentExternalCalendarIdPublicRequest, + listAppointmentExternalCalendarsPublicResponse, +} from "../schema/public" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("appointments") + +const tags = ["Appointment External Calendars"] + +export const appointmentExternalCalendarsPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/appointment-external-calendars", + summary: "List connected external calendars", + description: + "Lists Google/Outlook calendar connections available to attach to an appointment calendar, with a count of calendars currently using each connection.", + tags, + }) + .input(publicListRequest) + .output(listAppointmentExternalCalendarsPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const items = + await appointmentExternalCalendarService.listWithConnectedCount({ + workspaceId: context.workspace.id, + }) + // `listWithConnectedCount` is not paginated at the query layer (small, + // per-workspace, bounded by how many calendar integrations a workspace + // connects) — see `paginateInMemory`'s doc comment in + // `@/lib/public-api/list` for why this is the sanctioned temporary + // pattern rather than a new repository-level pagination path. + return paginateInMemory(items, input) + }), + + disconnect: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/appointment-external-calendars/{integrationId}", + summary: "Disconnect an external calendar", + description: + "Disconnects a Google/Outlook calendar connection. Fails if any appointment calendar is still using it.", + successStatus: 204, + tags, + }) + .input(appointmentExternalCalendarIdPublicRequest) + .errors(possibleErrorsOnDisconnectingExternalCalendar) + .handler(async ({ context, input }) => { + await appointmentExternalCalendarService.disconnect({ + workspaceId: context.workspace.id, + integrationId: input.integrationId, + }) + }), +} diff --git a/apps/builder/src/features/appointment-external-calendars/schema/public.ts b/apps/builder/src/features/appointment-external-calendars/schema/public.ts new file mode 100644 index 0000000000..5cf82371d4 --- /dev/null +++ b/apps/builder/src/features/appointment-external-calendars/schema/public.ts @@ -0,0 +1,32 @@ +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { publicListResponse } from "@/lib/public-api/list" + +// Mirrors `ExternalCalendarListItem` +// (packages/business/src/appointment-external-calendar/service.ts) — the +// return shape of `appointmentExternalCalendarService.listWithConnectedCount`, +// the only safe read of this resource. Its sibling `list()` method returns +// raw `Integration` rows joined with `IntegrationGoogleCalendar`, whose +// `auth` column holds the OAuth token blob — never expose that method on a +// public route. `workspaceId` is omitted; see +// `public-spec-operations.test.ts`'s full sweep. +export const appointmentExternalCalendarPublicResource = z.object({ + id: z.string(), + providerType: z.literal("googleCalendar"), + label: z.string(), + providerCalendarId: z.string(), + email: z.string().nullable(), + connectedCount: z.number().int(), + createdAt: z.date(), + updatedAt: z.date(), +}) +export type AppointmentExternalCalendarPublicResource = z.infer< + typeof appointmentExternalCalendarPublicResource +> + +export const listAppointmentExternalCalendarsPublicResponse = + publicListResponse(appointmentExternalCalendarPublicResource) + +export const appointmentExternalCalendarIdPublicRequest = z.object({ + integrationId: zodBigintAsString(), +}) diff --git a/apps/builder/src/features/appointment-management/api/public.ts b/apps/builder/src/features/appointment-management/api/public.ts new file mode 100644 index 0000000000..fee5c19a3c --- /dev/null +++ b/apps/builder/src/features/appointment-management/api/public.ts @@ -0,0 +1,38 @@ +import { appointmentReminderService } from "@chatbotx.io/business" +import { possibleErrorsOnListingResource } from "@/lib/orpc/orpc-error-helper" +import { publicListResponse } from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + appointmentReminderDispatchPublicResource, + listAppointmentRemindersPublicRequest, +} from "../schema/public" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("appointments") + +export const appointmentRemindersPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/appointment-reminders", + summary: "List appointment reminder dispatches", + description: + "Audits reminder dispatch rows for the workspace (pending/sent/cancelled/failed), optionally filtered by status.", + tags: ["Appointment Reminders"], + }) + .input(listAppointmentRemindersPublicRequest) + .output(publicListResponse(appointmentReminderDispatchPublicResource)) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + // `workspaceId` is optional on the repository's list input, so it must + // always be passed explicitly here — omitting it would return dispatch + // rows across every workspace, not just the caller's. + const { data, pageCount } = + await appointmentReminderService.listDispatches({ + workspaceId: context.workspace.id, + status: input.status, + page: input.page, + perPage: input.perPage, + }) + return { data, pageCount } + }), +} diff --git a/apps/builder/src/features/appointment-management/schema/public.ts b/apps/builder/src/features/appointment-management/schema/public.ts new file mode 100644 index 0000000000..814f4c4ec2 --- /dev/null +++ b/apps/builder/src/features/appointment-management/schema/public.ts @@ -0,0 +1,29 @@ +import { appointmentReminderDispatchStatuses } from "@chatbotx.io/database/partials" +import { + appointmentReminderDispatchModel, + createSelectSchema, +} from "@chatbotx.io/database/schema" +import { z } from "zod" +import { publicListRequest } from "@/lib/public-api/list" + +// Public request/response schemas — `workspaceId` is never accepted from +// client input (it comes from the token's resolved workspace) and never +// echoed in a response; see `public-spec-operations.test.ts`'s full sweep. + +export const appointmentReminderDispatchPublicResource = createSelectSchema( + appointmentReminderDispatchModel, + { + id: z.string(), + workspaceId: z.string(), + appointmentId: z.string(), + reminderConfigId: z.string(), + contactInboxId: z.string().nullable(), + }, +).omit({ workspaceId: true }) +export type AppointmentReminderDispatchPublicResource = z.infer< + typeof appointmentReminderDispatchPublicResource +> + +export const listAppointmentRemindersPublicRequest = publicListRequest.extend({ + status: appointmentReminderDispatchStatuses.optional(), +}) diff --git a/apps/builder/src/features/appointments/api/public.ts b/apps/builder/src/features/appointments/api/public.ts new file mode 100644 index 0000000000..bfb557ebd0 --- /dev/null +++ b/apps/builder/src/features/appointments/api/public.ts @@ -0,0 +1,132 @@ +import { + appointmentService, + resolveTenantSettings, +} from "@chatbotx.io/business" +import { + possibleErrorsOnBookingAppointment, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, +} from "@/lib/orpc/orpc-error-helper" +import { publicListResponse } from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + appointmentIdPublicRequest, + appointmentListItemPublicResource, + appointmentPublicResource, + bookAppointmentPublicRequest, + listAppointmentsPublicRequest, +} from "../schema/public" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("appointments") + +const tags = ["Appointments"] + +export const appointmentsPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/appointments", + summary: "List appointments", + description: + "Lists appointments in the workspace, optionally filtered by calendar and tab (next/past).", + tags, + }) + .input(listAppointmentsPublicRequest) + .output(publicListResponse(appointmentListItemPublicResource)) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const { appUrl } = await resolveTenantSettings({ + workspaceId: context.workspace.id, + }) + const { data, pageCount } = await appointmentService.list({ + workspaceId: context.workspace.id, + calendarId: input.calendarId, + tab: input.tab, + search: input.search, + page: input.page, + perPage: input.perPage, + appUrl, + }) + return { data, pageCount } + }), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/appointments/{id}", + summary: "Get an appointment by id", + tags, + }) + .input(appointmentIdPublicRequest) + .output(appointmentPublicResource) + .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context, input }) => + await appointmentService.findByOrFail({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + + book: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/appointments", + summary: "Book an appointment", + description: + "Books a slot on a calendar for a contact. Runs the same availability, capacity, and daily-limit checks as the booking webview, and schedules reminders/confirmation flow if configured on the calendar.", + successStatus: 201, + tags, + }) + .input(bookAppointmentPublicRequest) + .output(appointmentPublicResource) + .errors(possibleErrorsOnBookingAppointment) + .handler( + async ({ context, input }) => + await appointmentService.bookAppointment({ + workspaceId: context.workspace.id, + calendarId: input.calendarId, + contactId: input.contactId, + conversationId: input.conversationId, + startAt: input.startAt, + inviteeTimezone: input.inviteeTimezone, + }), + ), + + cancel: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/appointments/{id}/cancel", + summary: "Cancel an appointment", + tags, + }) + .input(appointmentIdPublicRequest) + .output(appointmentPublicResource) + .errors(possibleErrorsOnBookingAppointment) + .handler( + async ({ context, input }) => + await appointmentService.cancelAppointmentById({ + workspaceId: context.workspace.id, + appointmentId: input.id, + }), + ), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/appointments/{id}", + summary: "Delete an appointment", + description: + "Soft-deletes an appointment. Upcoming scheduled appointments must be cancelled first.", + successStatus: 204, + tags, + }) + .input(appointmentIdPublicRequest) + .errors(possibleErrorsOnBookingAppointment) + .handler(async ({ context, input }) => { + await appointmentService.deleteAppointmentById({ + workspaceId: context.workspace.id, + appointmentId: input.id, + }) + }), +} diff --git a/apps/builder/src/features/appointments/schema/public.ts b/apps/builder/src/features/appointments/schema/public.ts new file mode 100644 index 0000000000..e470434cbf --- /dev/null +++ b/apps/builder/src/features/appointments/schema/public.ts @@ -0,0 +1,62 @@ +import { + appointmentModel, + createSelectSchema, +} from "@chatbotx.io/database/schema" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { publicListRequest } from "@/lib/public-api/list" + +// Public request/response schemas — `workspaceId` is never accepted from +// client input (it comes from the token's resolved workspace) and never +// echoed in a response; see `public-spec-operations.test.ts`'s full sweep. + +const appointmentBaseResource = createSelectSchema(appointmentModel, { + id: z.string(), + workspaceId: z.string(), + calendarId: z.string(), + contactId: z.string(), + conversationId: z.string().nullable(), +}).omit({ workspaceId: true }) + +// The row shape returned by `appointmentService.findByOrFail`/ +// `bookAppointment`/`cancelAppointmentById`/`deleteAppointmentById` — the +// bare Appointment row. +export const appointmentPublicResource = appointmentBaseResource +export type AppointmentPublicResource = z.infer< + typeof appointmentPublicResource +> + +// The row shape returned by `appointmentService.list` — joined with calendar +// name and a pre-signed schedule URL, cancellable/deletable flags derived +// server-side. +export const appointmentListItemPublicResource = appointmentBaseResource.extend( + { + calendarName: z.string(), + scheduleUrl: z.string(), + cancellable: z.boolean(), + deletable: z.boolean(), + }, +) +export type AppointmentListItemPublicResource = z.infer< + typeof appointmentListItemPublicResource +> + +export const appointmentListTabs = ["next", "past"] as const + +export const listAppointmentsPublicRequest = publicListRequest.extend({ + calendarId: zodBigintAsString().optional(), + tab: z.enum(appointmentListTabs).optional(), + search: z.string().optional(), +}) + +export const appointmentIdPublicRequest = z.object({ + id: zodBigintAsString(), +}) + +export const bookAppointmentPublicRequest = z.object({ + calendarId: zodBigintAsString(), + contactId: zodBigintAsString(), + conversationId: zodBigintAsString().optional().nullable(), + startAt: z.coerce.date(), + inviteeTimezone: z.string().optional(), +}) diff --git a/apps/builder/src/lib/orpc/orpc-error-helper.ts b/apps/builder/src/lib/orpc/orpc-error-helper.ts index 7cc353eaf6..3125ab760d 100644 --- a/apps/builder/src/lib/orpc/orpc-error-helper.ts +++ b/apps/builder/src/lib/orpc/orpc-error-helper.ts @@ -115,3 +115,65 @@ export const possibleErrorsOnDeletingResource = { notFound, businessError, } satisfies ErrorMap + +/** + * Booking/cancel/delete on appointments can throw five `ChatbotXException` + * codes at status 409 that no other route set covers — `slotUnavailable`, + * `appointmentAvailabilityChanged`, `appointmentAlreadyScheduled` (booking), + * `appointmentNotCancellable` (cancel), `appointmentDeleteBlocked` (delete). + * oRPC matches a thrown error to its declaration by code *and* exact status; + * on a miss it silently degrades to `defined: false` — the error still + * reaches the caller but never appears in the spec — so these must be + * declared explicitly rather than folded into `businessError`. + */ +const slotUnavailable = { + message: "Appointment slot is unavailable", + status: 409, +} + +const appointmentAvailabilityChanged = { + message: "Appointment calendar availability changed. Please try again.", + status: 409, +} + +const appointmentAlreadyScheduled = { + message: "Contact already has a scheduled appointment for this calendar", + status: 409, +} + +const appointmentNotCancellable = { + message: "Appointment cannot be cancelled", + status: 409, +} + +const appointmentDeleteBlocked = { + message: "Cancel upcoming appointments before deleting them", + status: 409, +} + +export const possibleErrorsOnBookingAppointment = { + notFound, + businessError, + slotUnavailable, + appointmentAvailabilityChanged, + appointmentAlreadyScheduled, + appointmentNotCancellable, + appointmentDeleteBlocked, +} satisfies ErrorMap + +/** + * Disconnecting an external (Google/Outlook) calendar connection that is + * still referenced by an appointment calendar throws `connectionInUse` (409) + * — see `getDisconnectableGoogleConnection` in + * `packages/business/src/appointment-external-calendar/service.ts`. + */ +const connectionInUse = { + message: "Connection is in use", + status: 409, +} + +export const possibleErrorsOnDisconnectingExternalCalendar = { + notFound, + businessError, + connectionInUse, +} satisfies ErrorMap diff --git a/apps/builder/src/routers/public.ts b/apps/builder/src/routers/public.ts index 40a7485989..feb5da9f7d 100644 --- a/apps/builder/src/routers/public.ts +++ b/apps/builder/src/routers/public.ts @@ -2,6 +2,10 @@ import { inboxTeamsPublicRouter } from "@/enterprise/features/inbox-teams/api/pu import { aiAgentsPublicRouter } from "@/features/ai-agents/api/public" import { aiTriggersPublicRouter } from "@/features/ai-triggers/api/public" import { analyticsPublicRouter } from "@/features/analytics/api/public" +import { appointmentCalendarsPublicRouter } from "@/features/appointment-calendars/api/public" +import { appointmentExternalCalendarsPublicRouter } from "@/features/appointment-external-calendars/api/public" +import { appointmentRemindersPublicRouter } from "@/features/appointment-management/api/public" +import { appointmentsPublicRouter } from "@/features/appointments/api/public" import { keywordsPublicRouter } from "@/features/automated-response/api/public" import { botFieldsPublicRouter } from "@/features/bot-fields/api/public" import { broadcastsPublicRouter } from "@/features/broadcasts/api/public" @@ -31,6 +35,10 @@ export const publicRouter = { aiAgents: aiAgentsPublicRouter, aiTriggers: aiTriggersPublicRouter, analytics: analyticsPublicRouter, + appointmentCalendars: appointmentCalendarsPublicRouter, + appointmentExternalCalendars: appointmentExternalCalendarsPublicRouter, + appointmentReminders: appointmentRemindersPublicRouter, + appointments: appointmentsPublicRouter, botFields: botFieldsPublicRouter, broadcasts: broadcastsPublicRouter, channels: channelsPublicRouter, diff --git a/docs/developer/workspace-api-tokens.md b/docs/developer/workspace-api-tokens.md index 18ef4f7a54..0d6093315c 100644 --- a/docs/developer/workspace-api-tokens.md +++ b/docs/developer/workspace-api-tokens.md @@ -44,6 +44,10 @@ The `analytics` scope covers both `/v1/error-logs` analytics router, every `/v1/analytics/*` route (`apps/builder/src/features/analytics/api/public.ts`). +The `appointments` scope existed in the enum and UI registry for some time +before any endpoint used it — see "Appointments scope — endpoint-to-scope +table" below for the full surface now behind it. + ## The default token and `{{api_key}}` Exactly one row per workspace may have `isDefault = true` (partial unique @@ -202,6 +206,60 @@ Two invariants to preserve when touching this surface: `triggerRepository.findWithConditions` rather than reintroducing a hardcoded `[]`. +### Appointments scope — endpoint-to-scope table + +The `appointments` scope covers appointment calendars, appointments, reminder +dispatch audit reads, and external (Google/Outlook) calendar connections. +Every value below existed in `workspaceApiTokenScopes` and +`workspaceApiTokenScopeRegistry` well before any endpoint used it — it was a +reserved placeholder; this table documents the endpoints that finally consume +it. Every handler calls the same `packages/business` service method the +corresponding UI action or query calls. + +| Resource | Endpoint | Service method | +| --- | --- | --- | +| Appointment calendars | `GET /v1/appointment-calendars` | `appointmentCalendarService.list` | +| Appointment calendars | `GET /v1/appointment-calendars/{id}` | `appointmentCalendarService.getForEdit` | +| Appointment calendars | `POST /v1/appointment-calendars` | `appointmentCalendarService.create` | +| Appointment calendars | `PUT /v1/appointment-calendars/{id}` | `appointmentCalendarService.update` | +| Appointment calendars | `PATCH /v1/appointment-calendars/{id}/active` | `appointmentCalendarService.setActive` | +| Appointment calendars | `POST /v1/appointment-calendars/{id}/duplicate` | `appointmentCalendarService.duplicate` | +| Appointment calendars | `DELETE /v1/appointment-calendars/{id}` | `appointmentCalendarService.deleteMany` | +| Appointment calendars | `GET /v1/appointment-calendars/{id}/availability` | `appointmentService.checkAvailability` | +| Appointments | `GET /v1/appointments` | `appointmentService.list` | +| Appointments | `GET /v1/appointments/{id}` | `appointmentService.findByOrFail` | +| Appointments | `POST /v1/appointments` | `appointmentService.bookAppointment` | +| Appointments | `POST /v1/appointments/{id}/cancel` | `appointmentService.cancelAppointmentById` | +| Appointments | `DELETE /v1/appointments/{id}` | `appointmentService.deleteAppointmentById` | +| Appointment reminders | `GET /v1/appointment-reminders` | `appointmentReminderService.listDispatches` | +| Appointment external calendars | `GET /v1/appointment-external-calendars` | `appointmentExternalCalendarService.listWithConnectedCount` | +| Appointment external calendars | `DELETE /v1/appointment-external-calendars/{integrationId}` | `appointmentExternalCalendarService.disconnect` | + +Three invariants to preserve when touching this surface: + +- **`appUrl` must be resolved with `resolveTenantSettings`, never a + `.query.ts` adapter.** `appointmentService.list` signs a per-row schedule + token using `appUrl`, and the private `list-appointments.query.ts` adapter + gets it via `assertCurrentUserCanAccessChatbot`, which resolves a + better-auth session — a Bearer-token request has none. The public `list` + handler in `features/appointments/api/public.ts` calls + `resolveTenantSettings({ workspaceId })` directly instead, exactly like the + invariant `public-list-queries-no-session.test.ts` pins for every other + resource. +- **External calendars must use `listWithConnectedCount`, never `list`.** + `appointmentExternalCalendarService.list` returns raw `Integration` rows via + a relational query; the sibling `IntegrationGoogleCalendar` table holds the + OAuth token blob in its `auth` jsonb column. + `listWithConnectedCount` selects explicit columns and never touches `auth` + — it is the only safe shape to publish on this scope. +- **Reminder dispatch listing must always pass `workspaceId` explicitly.** + `AppointmentReminderDispatchListInput.workspaceId` is optional at the + repository layer (it also backs the internal due-reminder scan across every + workspace), so the public handler in + `features/appointment-management/api/public.ts` must never omit it — + omitting it would return dispatch rows across every workspace, not just the + caller's. + ## Adding a new scope value 1. Add the value to `workspaceApiTokenScopes` in @@ -245,6 +303,11 @@ these helpers — import from the business package directly. - `apps/builder/__tests__/workspace-token-scope-enforcement.test.ts` - `apps/builder/__tests__/workspace-token-scope-registry.test.ts` - `apps/builder/__tests__/broadcasts-workspace-token-scope.test.ts` +- `apps/builder/__tests__/appointments-public-scope.test.ts` +- `apps/builder/__tests__/appointment-calendars-public-api.test.ts`, + `appointments-public-api.test.ts`, `appointment-reminders-public-api.test.ts`, + `appointment-external-calendars-public-api.test.ts` — handler-behavior tests + for the appointments scope's four routers - `apps/builder/__tests__/contacts-public-scope.test.ts` - `apps/builder/__tests__/contacts-crud-public-api.test.ts`, `contacts-tags-and-fields-public-api.test.ts`,