Skip to content

Commit 4537a5d

Browse files
realcodesimanclaude
andcommitted
feat(appointments): add public API for appointments, calendars, external calendars, and reminders
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 <noreply@anthropic.com>
1 parent ba7d56e commit 4537a5d

17 files changed

Lines changed: 1737 additions & 0 deletions

File tree

apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,86 @@ exports[`public API spec — operation naming guard > operation list (operationI
207207
"operationId": "analytics.uniqueConversationsByAdmin",
208208
"path": "/v1/analytics/unique-conversations-by-admin",
209209
},
210+
{
211+
"method": "POST",
212+
"operationId": "appointmentCalendars.create",
213+
"path": "/v1/appointment-calendars",
214+
},
215+
{
216+
"method": "DELETE",
217+
"operationId": "appointmentCalendars.delete",
218+
"path": "/v1/appointment-calendars/{id}",
219+
},
220+
{
221+
"method": "POST",
222+
"operationId": "appointmentCalendars.duplicate",
223+
"path": "/v1/appointment-calendars/{id}/duplicate",
224+
},
225+
{
226+
"method": "GET",
227+
"operationId": "appointmentCalendars.get",
228+
"path": "/v1/appointment-calendars/{id}",
229+
},
230+
{
231+
"method": "GET",
232+
"operationId": "appointmentCalendars.getAvailability",
233+
"path": "/v1/appointment-calendars/{id}/availability",
234+
},
235+
{
236+
"method": "GET",
237+
"operationId": "appointmentCalendars.list",
238+
"path": "/v1/appointment-calendars",
239+
},
240+
{
241+
"method": "PATCH",
242+
"operationId": "appointmentCalendars.setActive",
243+
"path": "/v1/appointment-calendars/{id}/active",
244+
},
245+
{
246+
"method": "PUT",
247+
"operationId": "appointmentCalendars.update",
248+
"path": "/v1/appointment-calendars/{id}",
249+
},
250+
{
251+
"method": "DELETE",
252+
"operationId": "appointmentExternalCalendars.disconnect",
253+
"path": "/v1/appointment-external-calendars/{integrationId}",
254+
},
255+
{
256+
"method": "GET",
257+
"operationId": "appointmentExternalCalendars.list",
258+
"path": "/v1/appointment-external-calendars",
259+
},
260+
{
261+
"method": "GET",
262+
"operationId": "appointmentReminders.list",
263+
"path": "/v1/appointment-reminders",
264+
},
265+
{
266+
"method": "POST",
267+
"operationId": "appointments.book",
268+
"path": "/v1/appointments",
269+
},
270+
{
271+
"method": "POST",
272+
"operationId": "appointments.cancel",
273+
"path": "/v1/appointments/{id}/cancel",
274+
},
275+
{
276+
"method": "DELETE",
277+
"operationId": "appointments.delete",
278+
"path": "/v1/appointments/{id}",
279+
},
280+
{
281+
"method": "GET",
282+
"operationId": "appointments.get",
283+
"path": "/v1/appointments/{id}",
284+
},
285+
{
286+
"method": "GET",
287+
"operationId": "appointments.list",
288+
"path": "/v1/appointments",
289+
},
210290
{
211291
"method": "PUT",
212292
"operationId": "botFields.bulkUpdate",
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { beforeEach, describe, expect, test, vi } from "vitest"
2+
3+
type RouteConfig = {
4+
method: string
5+
path: string
6+
summary: string
7+
tags: string[]
8+
successStatus?: number
9+
}
10+
11+
type CapturedProcedure = {
12+
route: RouteConfig
13+
handler?: (...args: any[]) => any
14+
}
15+
16+
const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => {
17+
const capturedProcedures: CapturedProcedure[] = []
18+
19+
const makeProcedure = (route: RouteConfig) => {
20+
const record: CapturedProcedure = { route }
21+
capturedProcedures.push(record)
22+
23+
const chain = {
24+
input: vi.fn(() => chain),
25+
output: vi.fn(() => chain),
26+
errors: vi.fn(() => chain),
27+
handler: vi.fn((fn: (...args: any[]) => any) => {
28+
record.handler = fn
29+
return { handler: fn }
30+
}),
31+
}
32+
return chain
33+
}
34+
35+
const workspaceTokenAuthAPI = {
36+
route: vi.fn((config: RouteConfig) => makeProcedure(config)),
37+
}
38+
39+
return {
40+
workspaceTokenAuthAPIForScope: vi.fn(
41+
(_scope: string) => workspaceTokenAuthAPI,
42+
),
43+
capturedProcedures,
44+
}
45+
})
46+
47+
vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope }))
48+
49+
const appointmentCalendarService = {
50+
list: vi.fn(),
51+
getForEdit: vi.fn(),
52+
create: vi.fn(),
53+
update: vi.fn(),
54+
setActive: vi.fn(),
55+
duplicate: vi.fn(),
56+
deleteMany: vi.fn(),
57+
}
58+
const appointmentService = {
59+
checkAvailability: vi.fn(),
60+
}
61+
vi.mock("@chatbotx.io/business", () => ({
62+
appointmentCalendarService,
63+
appointmentService,
64+
}))
65+
66+
vi.mock("@chatbotx.io/database/schema", () => {
67+
const schema = {
68+
pick: vi.fn(() => schema),
69+
extend: vi.fn(() => schema),
70+
omit: vi.fn(() => schema),
71+
and: vi.fn(() => schema),
72+
optional: vi.fn(() => schema),
73+
}
74+
return {
75+
createSelectSchema: vi.fn(() => schema),
76+
appointmentCalendarModel: {},
77+
appointmentCalendarAvailabilityModel: {},
78+
appointmentCalendarReminderModel: {},
79+
}
80+
})
81+
82+
await import("@/features/appointment-calendars/api/public")
83+
84+
const findProcedure = (method: string, path: string) => {
85+
const found = capturedProcedures.find(
86+
(p) => p.route.method === method && p.route.path === path,
87+
)
88+
if (!found) {
89+
throw new Error(`No procedure registered for ${method} ${path}`)
90+
}
91+
return found
92+
}
93+
94+
const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0]
95+
96+
beforeEach(() => {
97+
vi.clearAllMocks()
98+
})
99+
100+
test("registers the appointment calendars public router under the appointments scope", () => {
101+
expect(scopeArgAtImport).toBe("appointments")
102+
})
103+
104+
describe("GET /v1/appointment-calendars", () => {
105+
const procedure = findProcedure("GET", "/v1/appointment-calendars")
106+
107+
test("delegates to appointmentCalendarService.list", async () => {
108+
appointmentCalendarService.list.mockResolvedValueOnce({
109+
data: [],
110+
pageCount: 1,
111+
})
112+
113+
await procedure.handler?.({
114+
context: { workspace: { id: "workspace-1" } },
115+
input: { page: 1, perPage: 50, search: "sales" },
116+
})
117+
118+
expect(appointmentCalendarService.list).toHaveBeenCalledWith({
119+
page: 1,
120+
perPage: 50,
121+
search: "sales",
122+
workspaceId: "workspace-1",
123+
})
124+
})
125+
})
126+
127+
describe("GET /v1/appointment-calendars/{id}", () => {
128+
const procedure = findProcedure("GET", "/v1/appointment-calendars/{id}")
129+
130+
test("delegates to appointmentCalendarService.getForEdit", async () => {
131+
appointmentCalendarService.getForEdit.mockResolvedValueOnce({
132+
id: "cal-1",
133+
})
134+
135+
await procedure.handler?.({
136+
context: { workspace: { id: "workspace-1" } },
137+
input: { id: "cal-1" },
138+
})
139+
140+
expect(appointmentCalendarService.getForEdit).toHaveBeenCalledWith({
141+
workspaceId: "workspace-1",
142+
id: "cal-1",
143+
})
144+
})
145+
})
146+
147+
describe("POST /v1/appointment-calendars", () => {
148+
const procedure = findProcedure("POST", "/v1/appointment-calendars")
149+
150+
test("delegates to appointmentCalendarService.create", async () => {
151+
appointmentCalendarService.create.mockResolvedValueOnce("cal-1")
152+
153+
const result = await procedure.handler?.({
154+
context: { workspace: { id: "workspace-1" } },
155+
input: { name: "Sales calls" },
156+
})
157+
158+
expect(appointmentCalendarService.create).toHaveBeenCalledWith({
159+
workspaceId: "workspace-1",
160+
name: "Sales calls",
161+
})
162+
expect(result).toEqual({ id: "cal-1" })
163+
})
164+
})
165+
166+
describe("PUT /v1/appointment-calendars/{id}", () => {
167+
const procedure = findProcedure("PUT", "/v1/appointment-calendars/{id}")
168+
169+
test("derives scheduleWindowType from scheduleWindowConfig", async () => {
170+
appointmentCalendarService.update.mockResolvedValueOnce(undefined)
171+
172+
await procedure.handler?.({
173+
context: { workspace: { id: "workspace-1" } },
174+
input: {
175+
id: "cal-1",
176+
name: "Sales calls",
177+
active: true,
178+
timezone: "UTC",
179+
durationMinutes: 30,
180+
scheduleWindowConfig: {
181+
scheduleWindowType: "rollingDays",
182+
rollingDays: 14,
183+
},
184+
availability: [],
185+
reminders: [],
186+
},
187+
})
188+
189+
expect(appointmentCalendarService.update).toHaveBeenCalledWith(
190+
expect.objectContaining({
191+
workspaceId: "workspace-1",
192+
id: "cal-1",
193+
scheduleWindowType: "rollingDays",
194+
}),
195+
)
196+
})
197+
})
198+
199+
describe("PATCH /v1/appointment-calendars/{id}/active", () => {
200+
const procedure = findProcedure(
201+
"PATCH",
202+
"/v1/appointment-calendars/{id}/active",
203+
)
204+
205+
test("delegates to appointmentCalendarService.setActive", async () => {
206+
appointmentCalendarService.setActive.mockResolvedValueOnce(undefined)
207+
208+
await procedure.handler?.({
209+
context: { workspace: { id: "workspace-1" } },
210+
input: { id: "cal-1", active: false },
211+
})
212+
213+
expect(appointmentCalendarService.setActive).toHaveBeenCalledWith({
214+
workspaceId: "workspace-1",
215+
id: "cal-1",
216+
active: false,
217+
})
218+
})
219+
})
220+
221+
describe("POST /v1/appointment-calendars/{id}/duplicate", () => {
222+
const procedure = findProcedure(
223+
"POST",
224+
"/v1/appointment-calendars/{id}/duplicate",
225+
)
226+
227+
test("delegates to appointmentCalendarService.duplicate", async () => {
228+
appointmentCalendarService.duplicate.mockResolvedValueOnce("cal-2")
229+
230+
const result = await procedure.handler?.({
231+
context: { workspace: { id: "workspace-1" } },
232+
input: { id: "cal-1" },
233+
})
234+
235+
expect(appointmentCalendarService.duplicate).toHaveBeenCalledWith({
236+
workspaceId: "workspace-1",
237+
id: "cal-1",
238+
})
239+
expect(result).toEqual({ id: "cal-2" })
240+
})
241+
})
242+
243+
describe("DELETE /v1/appointment-calendars/{id}", () => {
244+
const procedure = findProcedure("DELETE", "/v1/appointment-calendars/{id}")
245+
246+
test("delegates to appointmentCalendarService.deleteMany", async () => {
247+
appointmentCalendarService.deleteMany.mockResolvedValueOnce(undefined)
248+
249+
await procedure.handler?.({
250+
context: { workspace: { id: "workspace-1" } },
251+
input: { id: "cal-1" },
252+
})
253+
254+
expect(appointmentCalendarService.deleteMany).toHaveBeenCalledWith({
255+
workspaceId: "workspace-1",
256+
ids: ["cal-1"],
257+
})
258+
})
259+
})
260+
261+
describe("GET /v1/appointment-calendars/{id}/availability", () => {
262+
const procedure = findProcedure(
263+
"GET",
264+
"/v1/appointment-calendars/{id}/availability",
265+
)
266+
267+
test("delegates to appointmentService.checkAvailability", async () => {
268+
const startDate = new Date("2026-01-01T00:00:00Z")
269+
const endDate = new Date("2026-01-07T00:00:00Z")
270+
appointmentService.checkAvailability.mockResolvedValueOnce({
271+
text: "",
272+
slots: [],
273+
})
274+
275+
await procedure.handler?.({
276+
context: { workspace: { id: "workspace-1" } },
277+
input: { id: "cal-1", startDate, endDate, contactId: "contact-1" },
278+
})
279+
280+
expect(appointmentService.checkAvailability).toHaveBeenCalledWith({
281+
workspaceId: "workspace-1",
282+
calendarId: "cal-1",
283+
contactId: "contact-1",
284+
startDate,
285+
endDate,
286+
})
287+
})
288+
})

0 commit comments

Comments
 (0)