@agent-native/scheduling 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/agent-native.package.json +154 -0
- package/dist/actions/_helpers.d.ts +10 -0
- package/dist/actions/_helpers.d.ts.map +1 -1
- package/dist/actions/_helpers.js +14 -0
- package/dist/actions/_helpers.js.map +1 -1
- package/dist/actions/add-booking-attendee.js +2 -0
- package/dist/actions/add-booking-attendee.js.map +1 -1
- package/dist/actions/add-booking-note.js +2 -1
- package/dist/actions/add-booking-note.js.map +1 -1
- package/dist/actions/confirm-booking.js +5 -0
- package/dist/actions/confirm-booking.js.map +1 -1
- package/dist/actions/duplicate-event-type.js +2 -0
- package/dist/actions/duplicate-event-type.js.map +1 -1
- package/dist/actions/get-booking.d.ts +3 -1
- package/dist/actions/get-booking.js +8 -1
- package/dist/actions/get-booking.js.map +1 -1
- package/dist/actions/mark-no-show.js +6 -0
- package/dist/actions/mark-no-show.js.map +1 -1
- package/dist/actions/remove-booking-attendee.js +2 -0
- package/dist/actions/remove-booking-attendee.js.map +1 -1
- package/dist/actions/revoke-private-link.js +18 -1
- package/dist/actions/revoke-private-link.js.map +1 -1
- package/dist/actions/send-reschedule-link.js +2 -0
- package/dist/actions/send-reschedule-link.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +6 -1
- package/dist/manifest.js.map +1 -1
- package/dist/server/availability-engine.d.ts +20 -0
- package/dist/server/availability-engine.d.ts.map +1 -1
- package/dist/server/availability-engine.js +73 -23
- package/dist/server/availability-engine.js.map +1 -1
- package/dist/server/booking-service.d.ts.map +1 -1
- package/dist/server/booking-service.js +16 -5
- package/dist/server/booking-service.js.map +1 -1
- package/dist/server/bookings-repo.d.ts.map +1 -1
- package/dist/server/bookings-repo.js +50 -48
- package/dist/server/bookings-repo.js.map +1 -1
- package/dist/server/providers/index.d.ts +2 -0
- package/dist/server/providers/index.d.ts.map +1 -1
- package/dist/server/providers/index.js +1 -0
- package/dist/server/providers/index.js.map +1 -1
- package/dist/server/providers/teams.d.ts +22 -0
- package/dist/server/providers/teams.d.ts.map +1 -0
- package/dist/server/providers/teams.js +112 -0
- package/dist/server/providers/teams.js.map +1 -0
- package/docs/eject.md +11 -10
- package/docs/llms-full.txt +75 -14
- package/docs/llms.txt +15 -1
- package/docs/providers.md +30 -2
- package/docs/skills/integrations/SKILL.md +19 -1
- package/package.json +7 -6
- package/src/actions/_helpers.ts +18 -0
- package/src/actions/add-booking-attendee.ts +2 -0
- package/src/actions/add-booking-note.ts +2 -1
- package/src/actions/booking-authz.spec.ts +288 -0
- package/src/actions/confirm-booking.ts +4 -0
- package/src/actions/duplicate-event-type.ts +2 -0
- package/src/actions/event-type-authz.spec.ts +209 -0
- package/src/actions/get-booking.ts +7 -1
- package/src/actions/mark-no-show.ts +5 -0
- package/src/actions/remove-booking-attendee.ts +2 -0
- package/src/actions/revoke-private-link.ts +16 -1
- package/src/actions/send-reschedule-link.ts +2 -0
- package/src/manifest.test.ts +19 -0
- package/src/manifest.ts +9 -7
- package/src/server/availability-engine.test.ts +344 -0
- package/src/server/availability-engine.ts +100 -22
- package/src/server/booking-service.spec.ts +557 -0
- package/src/server/booking-service.ts +17 -6
- package/src/server/bookings-repo.ts +54 -48
- package/src/server/providers/index.ts +2 -0
- package/src/server/providers/teams.test.ts +246 -0
- package/src/server/providers/teams.ts +183 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import * as schema from "../schema/index.js";
|
|
4
|
+
import type { BusyInterval } from "../shared/index.js";
|
|
5
|
+
import { aggregateBusy } from "./availability-engine.js";
|
|
6
|
+
import { setSchedulingContext } from "./context.js";
|
|
7
|
+
import { registerCalendarProvider } from "./providers/registry.js";
|
|
8
|
+
import type { CalendarProvider } from "./providers/types.js";
|
|
9
|
+
|
|
10
|
+
const RANGE_START = new Date("2026-07-10T09:00:00.000Z");
|
|
11
|
+
const RANGE_END = new Date("2026-07-10T17:00:00.000Z");
|
|
12
|
+
const TEST_USER_EMAIL = "availability-test@example.com";
|
|
13
|
+
|
|
14
|
+
interface CredentialRow {
|
|
15
|
+
id: string;
|
|
16
|
+
type: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SelectedCalendarRow {
|
|
20
|
+
credentialId: string;
|
|
21
|
+
externalId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FakeDbInput {
|
|
25
|
+
bookings?: Array<{
|
|
26
|
+
startTime: string;
|
|
27
|
+
endTime: string;
|
|
28
|
+
uid: string;
|
|
29
|
+
}>;
|
|
30
|
+
credentials?: CredentialRow[];
|
|
31
|
+
selected?: SelectedCalendarRow[];
|
|
32
|
+
/** Supports the pre-batching implementation during characterization. */
|
|
33
|
+
legacySelectedBatches?: SelectedCalendarRow[][];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createFakeDb(input: FakeDbInput) {
|
|
37
|
+
const reads = {
|
|
38
|
+
bookings: 0,
|
|
39
|
+
credentials: 0,
|
|
40
|
+
selectedCalendars: 0,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const db = {
|
|
44
|
+
select(projection?: unknown) {
|
|
45
|
+
return {
|
|
46
|
+
from(table: unknown) {
|
|
47
|
+
return {
|
|
48
|
+
async where() {
|
|
49
|
+
if (table === schema.bookings) {
|
|
50
|
+
reads.bookings += 1;
|
|
51
|
+
return input.bookings ?? [];
|
|
52
|
+
}
|
|
53
|
+
if (table === schema.schedulingCredentials) {
|
|
54
|
+
reads.credentials += 1;
|
|
55
|
+
return input.credentials ?? [];
|
|
56
|
+
}
|
|
57
|
+
if (table === schema.selectedCalendars) {
|
|
58
|
+
reads.selectedCalendars += 1;
|
|
59
|
+
if (projection) return input.selected ?? [];
|
|
60
|
+
return (
|
|
61
|
+
input.legacySelectedBatches?.[reads.selectedCalendars - 1] ??
|
|
62
|
+
input.selected ??
|
|
63
|
+
[]
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
throw new Error("Unexpected table in availability test");
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
setSchedulingContext({
|
|
75
|
+
getDb: () => db,
|
|
76
|
+
schema,
|
|
77
|
+
getCurrentUserEmail: () => TEST_USER_EMAIL,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { reads };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createCalendarProvider(
|
|
84
|
+
kind: string,
|
|
85
|
+
getBusy: CalendarProvider["getBusy"],
|
|
86
|
+
): CalendarProvider {
|
|
87
|
+
return {
|
|
88
|
+
kind,
|
|
89
|
+
label: `Test provider ${kind}`,
|
|
90
|
+
startOAuth: vi.fn(async () => ({ authUrl: "https://example.test/oauth" })),
|
|
91
|
+
completeOAuth: vi.fn(async () => ({
|
|
92
|
+
externalEmail: "provider-test@example.com",
|
|
93
|
+
calendars: [],
|
|
94
|
+
})),
|
|
95
|
+
listCalendars: vi.fn(async () => []),
|
|
96
|
+
getBusy,
|
|
97
|
+
createEvent: vi.fn(async () => ({ externalId: "event-example" })),
|
|
98
|
+
updateEvent: vi.fn(async () => ({ iCalSequence: 1 })),
|
|
99
|
+
deleteEvent: vi.fn(async () => {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function interval(source: string, hour: number): BusyInterval {
|
|
104
|
+
return {
|
|
105
|
+
start: `2026-07-10T${String(hour).padStart(2, "0")}:00:00.000Z`,
|
|
106
|
+
end: `2026-07-10T${String(hour + 1).padStart(2, "0")}:00:00.000Z`,
|
|
107
|
+
source,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readBusy(): Promise<BusyInterval[]> {
|
|
112
|
+
return aggregateBusy({
|
|
113
|
+
userEmail: TEST_USER_EMAIL,
|
|
114
|
+
rangeStart: RANGE_START,
|
|
115
|
+
rangeEnd: RANGE_END,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("aggregateBusy", () => {
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
vi.restoreAllMocks();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("groups selected calendars and preserves local and provider busy intervals", async () => {
|
|
125
|
+
const credentials = [
|
|
126
|
+
{ id: "credential-a", type: "availability-group-a" },
|
|
127
|
+
{ id: "credential-b", type: "availability-group-b" },
|
|
128
|
+
];
|
|
129
|
+
const selected = [
|
|
130
|
+
{ credentialId: "credential-a", externalId: "calendar-a-1" },
|
|
131
|
+
{ credentialId: "credential-a", externalId: "calendar-a-2" },
|
|
132
|
+
{ credentialId: "credential-b", externalId: "calendar-b-1" },
|
|
133
|
+
];
|
|
134
|
+
createFakeDb({
|
|
135
|
+
bookings: [
|
|
136
|
+
{
|
|
137
|
+
startTime: "2026-07-10T10:00:00.000Z",
|
|
138
|
+
endTime: "2026-07-10T11:00:00.000Z",
|
|
139
|
+
uid: "booking-example",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
credentials,
|
|
143
|
+
selected,
|
|
144
|
+
legacySelectedBatches: [selected.slice(0, 2), selected.slice(2)],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const getBusyA = vi.fn(async () => [interval("provider-a", 12)]);
|
|
148
|
+
const getBusyB = vi.fn(async () => [interval("provider-b", 14)]);
|
|
149
|
+
registerCalendarProvider(
|
|
150
|
+
createCalendarProvider("availability-group-a", getBusyA),
|
|
151
|
+
);
|
|
152
|
+
registerCalendarProvider(
|
|
153
|
+
createCalendarProvider("availability-group-b", getBusyB),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const busy = await readBusy();
|
|
157
|
+
|
|
158
|
+
expect(busy.map((entry) => entry.source)).toEqual([
|
|
159
|
+
"booking:booking-example",
|
|
160
|
+
"provider-a",
|
|
161
|
+
"provider-b",
|
|
162
|
+
]);
|
|
163
|
+
expect(getBusyA).toHaveBeenCalledWith({
|
|
164
|
+
credentialId: "credential-a",
|
|
165
|
+
calendarExternalIds: ["calendar-a-1", "calendar-a-2"],
|
|
166
|
+
start: RANGE_START,
|
|
167
|
+
end: RANGE_END,
|
|
168
|
+
});
|
|
169
|
+
expect(getBusyB).toHaveBeenCalledWith({
|
|
170
|
+
credentialId: "credential-b",
|
|
171
|
+
calendarExternalIds: ["calendar-b-1"],
|
|
172
|
+
start: RANGE_START,
|
|
173
|
+
end: RANGE_END,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("reads bookings, credentials, and selected calendars once each", async () => {
|
|
178
|
+
const credentials = [
|
|
179
|
+
{ id: "credential-query-a", type: "availability-query-a" },
|
|
180
|
+
{ id: "credential-query-b", type: "availability-query-b" },
|
|
181
|
+
];
|
|
182
|
+
const selected = [
|
|
183
|
+
{ credentialId: "credential-query-a", externalId: "calendar-query-a" },
|
|
184
|
+
{ credentialId: "credential-query-b", externalId: "calendar-query-b" },
|
|
185
|
+
];
|
|
186
|
+
const { reads } = createFakeDb({
|
|
187
|
+
credentials,
|
|
188
|
+
selected,
|
|
189
|
+
legacySelectedBatches: [[selected[0]], [selected[1]]],
|
|
190
|
+
});
|
|
191
|
+
registerCalendarProvider(
|
|
192
|
+
createCalendarProvider(
|
|
193
|
+
"availability-query-a",
|
|
194
|
+
vi.fn(async () => []),
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
registerCalendarProvider(
|
|
198
|
+
createCalendarProvider(
|
|
199
|
+
"availability-query-b",
|
|
200
|
+
vi.fn(async () => []),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await readBusy();
|
|
205
|
+
|
|
206
|
+
expect(reads).toEqual({
|
|
207
|
+
bookings: 1,
|
|
208
|
+
credentials: 1,
|
|
209
|
+
selectedCalendars: 1,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("runs provider reads concurrently with a maximum of four in flight", async () => {
|
|
214
|
+
const credentials = Array.from({ length: 6 }, (_, index) => ({
|
|
215
|
+
id: `credential-concurrency-${index}`,
|
|
216
|
+
type: `availability-concurrency-${index}`,
|
|
217
|
+
}));
|
|
218
|
+
const selected = credentials.map((credential, index) => ({
|
|
219
|
+
credentialId: credential.id,
|
|
220
|
+
externalId: `calendar-concurrency-${index}`,
|
|
221
|
+
}));
|
|
222
|
+
createFakeDb({
|
|
223
|
+
credentials,
|
|
224
|
+
selected,
|
|
225
|
+
legacySelectedBatches: selected.map((row) => [row]),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
let active = 0;
|
|
229
|
+
let maximumActive = 0;
|
|
230
|
+
const started: string[] = [];
|
|
231
|
+
const gates = new Map<string, () => void>();
|
|
232
|
+
for (const credential of credentials) {
|
|
233
|
+
let release = () => {};
|
|
234
|
+
const gate = new Promise<void>((resolve) => {
|
|
235
|
+
release = resolve;
|
|
236
|
+
});
|
|
237
|
+
gates.set(credential.id, release);
|
|
238
|
+
registerCalendarProvider(
|
|
239
|
+
createCalendarProvider(credential.type, async ({ credentialId }) => {
|
|
240
|
+
started.push(credentialId);
|
|
241
|
+
active += 1;
|
|
242
|
+
maximumActive = Math.max(maximumActive, active);
|
|
243
|
+
await gate;
|
|
244
|
+
active -= 1;
|
|
245
|
+
const index = Number(credentialId.split("-").at(-1));
|
|
246
|
+
return [interval(`provider-concurrency-${index}`, 10 + index)];
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const busyPromise = readBusy();
|
|
252
|
+
await vi.waitFor(() => expect(started.length).toBeGreaterThan(0));
|
|
253
|
+
for (const release of Array.from(gates.values()).reverse()) release();
|
|
254
|
+
const busy = await busyPromise;
|
|
255
|
+
|
|
256
|
+
expect(maximumActive).toBeGreaterThan(1);
|
|
257
|
+
expect(maximumActive).toBeLessThanOrEqual(4);
|
|
258
|
+
expect(started).toHaveLength(6);
|
|
259
|
+
expect(busy.map((entry) => entry.source)).toEqual(
|
|
260
|
+
credentials.map((_, index) => `provider-concurrency-${index}`),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("keeps successful provider intervals when another provider fails", async () => {
|
|
265
|
+
const credentials = [
|
|
266
|
+
{ id: "credential-failure", type: "availability-failure" },
|
|
267
|
+
{ id: "credential-success", type: "availability-success" },
|
|
268
|
+
];
|
|
269
|
+
const selected = [
|
|
270
|
+
{ credentialId: "credential-failure", externalId: "calendar-failure" },
|
|
271
|
+
{ credentialId: "credential-success", externalId: "calendar-success" },
|
|
272
|
+
];
|
|
273
|
+
createFakeDb({
|
|
274
|
+
credentials,
|
|
275
|
+
selected,
|
|
276
|
+
legacySelectedBatches: [[selected[0]], [selected[1]]],
|
|
277
|
+
});
|
|
278
|
+
registerCalendarProvider(
|
|
279
|
+
createCalendarProvider(
|
|
280
|
+
"availability-failure",
|
|
281
|
+
vi.fn(async () => {
|
|
282
|
+
throw new Error("Expected provider failure");
|
|
283
|
+
}),
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
registerCalendarProvider(
|
|
287
|
+
createCalendarProvider(
|
|
288
|
+
"availability-success",
|
|
289
|
+
vi.fn(async () => [interval("provider-success", 13)]),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
await expect(readBusy()).resolves.toEqual([
|
|
294
|
+
interval("provider-success", 13),
|
|
295
|
+
]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("skips credentials without selected calendars or a registered provider", async () => {
|
|
299
|
+
const credentials = [
|
|
300
|
+
{ id: "credential-empty", type: "availability-empty" },
|
|
301
|
+
{ id: "credential-unknown", type: "availability-unknown" },
|
|
302
|
+
];
|
|
303
|
+
const selected = [
|
|
304
|
+
{
|
|
305
|
+
credentialId: "credential-unknown",
|
|
306
|
+
externalId: "calendar-unknown",
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
createFakeDb({
|
|
310
|
+
credentials,
|
|
311
|
+
selected,
|
|
312
|
+
legacySelectedBatches: [[], selected],
|
|
313
|
+
});
|
|
314
|
+
const emptyProviderBusy = vi.fn(async () => []);
|
|
315
|
+
registerCalendarProvider(
|
|
316
|
+
createCalendarProvider("availability-empty", emptyProviderBusy),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
await expect(readBusy()).resolves.toEqual([]);
|
|
320
|
+
expect(emptyProviderBusy).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("does not read selected calendars when there are no valid credentials", async () => {
|
|
324
|
+
const { reads } = createFakeDb({
|
|
325
|
+
bookings: [
|
|
326
|
+
{
|
|
327
|
+
startTime: "2026-07-10T10:00:00.000Z",
|
|
328
|
+
endTime: "2026-07-10T11:00:00.000Z",
|
|
329
|
+
uid: "booking-only-example",
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
credentials: [],
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await expect(readBusy()).resolves.toEqual([
|
|
336
|
+
{
|
|
337
|
+
start: "2026-07-10T10:00:00.000Z",
|
|
338
|
+
end: "2026-07-10T11:00:00.000Z",
|
|
339
|
+
source: "booking:booking-only-example",
|
|
340
|
+
},
|
|
341
|
+
]);
|
|
342
|
+
expect(reads.selectedCalendars).toBe(0);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* world: it fetches the schedule, merges busy times from the selected
|
|
7
7
|
* calendars + existing bookings, and applies booking limits.
|
|
8
8
|
*/
|
|
9
|
-
import { eq, gte, lt, and } from "drizzle-orm";
|
|
9
|
+
import { eq, gte, lt, and, inArray } from "drizzle-orm";
|
|
10
10
|
|
|
11
|
+
import { expandSlotForConflictCheck } from "../core/buffers.js";
|
|
12
|
+
import { hasConflict } from "../core/conflicts.js";
|
|
11
13
|
import type { BookingCounts } from "../core/limits.js";
|
|
12
14
|
import { bucketKeysForSlot } from "../core/limits.js";
|
|
13
15
|
import { computeAvailableSlots } from "../core/slots.js";
|
|
@@ -21,6 +23,54 @@ import { getSchedulingContext } from "./context.js";
|
|
|
21
23
|
import { getCalendarProvider } from "./providers/registry.js";
|
|
22
24
|
import { getScheduleById } from "./schedules-repo.js";
|
|
23
25
|
|
|
26
|
+
const MAX_CALENDAR_BUSY_CONCURRENCY = 4;
|
|
27
|
+
|
|
28
|
+
export class SlotConflictError extends Error {
|
|
29
|
+
statusCode = 409;
|
|
30
|
+
constructor(message = "This time slot is no longer available") {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "SlotConflictError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AssertSlotAvailableInput {
|
|
37
|
+
hostEmail: string;
|
|
38
|
+
startTime: string;
|
|
39
|
+
endTime: string;
|
|
40
|
+
beforeEventBuffer?: number;
|
|
41
|
+
afterEventBuffer?: number;
|
|
42
|
+
/** Booking uid to ignore when checking conflicts (e.g. the one being rescheduled). */
|
|
43
|
+
excludeBookingUid?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Re-validate a single requested interval right before it's written —
|
|
48
|
+
* cheap by design: aggregates busy only over the buffer-expanded window
|
|
49
|
+
* instead of scanning a full day like `getAvailableSlots`. Throws
|
|
50
|
+
* `SlotConflictError` if the window collides with existing busy time.
|
|
51
|
+
*/
|
|
52
|
+
export async function assertSlotAvailable(
|
|
53
|
+
input: AssertSlotAvailableInput,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const expanded = expandSlotForConflictCheck(
|
|
56
|
+
new Date(input.startTime),
|
|
57
|
+
new Date(input.endTime),
|
|
58
|
+
input.beforeEventBuffer ?? 0,
|
|
59
|
+
input.afterEventBuffer ?? 0,
|
|
60
|
+
);
|
|
61
|
+
const busy = await aggregateBusy({
|
|
62
|
+
userEmail: input.hostEmail,
|
|
63
|
+
rangeStart: expanded.start,
|
|
64
|
+
rangeEnd: expanded.end,
|
|
65
|
+
});
|
|
66
|
+
const relevantBusy = input.excludeBookingUid
|
|
67
|
+
? busy.filter((b) => b.source !== `booking:${input.excludeBookingUid}`)
|
|
68
|
+
: busy;
|
|
69
|
+
if (hasConflict(expanded, relevantBusy)) {
|
|
70
|
+
throw new SlotConflictError();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
24
74
|
export interface GetSlotsInput {
|
|
25
75
|
eventType: EventType;
|
|
26
76
|
/** The user we're finding availability for — their schedule + calendars. */
|
|
@@ -134,29 +184,57 @@ export async function aggregateBusy(input: {
|
|
|
134
184
|
eq(schema.schedulingCredentials.invalid, false),
|
|
135
185
|
),
|
|
136
186
|
);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
credentialId
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
187
|
+
if (creds.length === 0) return busy;
|
|
188
|
+
|
|
189
|
+
const selected = await db
|
|
190
|
+
.select({
|
|
191
|
+
credentialId: schema.selectedCalendars.credentialId,
|
|
192
|
+
externalId: schema.selectedCalendars.externalId,
|
|
193
|
+
})
|
|
194
|
+
.from(schema.selectedCalendars)
|
|
195
|
+
.where(
|
|
196
|
+
inArray(
|
|
197
|
+
schema.selectedCalendars.credentialId,
|
|
198
|
+
creds.map((cred: { id: string }) => cred.id),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
const externalIdsByCredential = new Map<string, string[]>();
|
|
202
|
+
for (const calendar of selected) {
|
|
203
|
+
const externalIds =
|
|
204
|
+
externalIdsByCredential.get(calendar.credentialId) ?? [];
|
|
205
|
+
externalIds.push(calendar.externalId);
|
|
206
|
+
externalIdsByCredential.set(calendar.credentialId, externalIds);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const providerBusy: BusyInterval[][] = Array.from(
|
|
210
|
+
{ length: creds.length },
|
|
211
|
+
() => [],
|
|
212
|
+
);
|
|
213
|
+
let nextCredentialIndex = 0;
|
|
214
|
+
async function worker(): Promise<void> {
|
|
215
|
+
while (nextCredentialIndex < creds.length) {
|
|
216
|
+
const credentialIndex = nextCredentialIndex++;
|
|
217
|
+
const cred = creds[credentialIndex];
|
|
218
|
+
const externalIds = externalIdsByCredential.get(cred.id);
|
|
219
|
+
if (!externalIds?.length) continue;
|
|
220
|
+
const provider = getCalendarProvider(cred.type);
|
|
221
|
+
if (!provider) continue;
|
|
222
|
+
try {
|
|
223
|
+
providerBusy[credentialIndex] = await provider.getBusy({
|
|
224
|
+
credentialId: cred.id,
|
|
225
|
+
calendarExternalIds: externalIds,
|
|
226
|
+
start: input.rangeStart,
|
|
227
|
+
end: input.rangeEnd,
|
|
228
|
+
});
|
|
229
|
+
} catch {
|
|
230
|
+
// Silently degrade — booking UI shows "couldn't verify availability" banner
|
|
231
|
+
// Consumer logs via their own error handler
|
|
232
|
+
}
|
|
158
233
|
}
|
|
159
234
|
}
|
|
235
|
+
const workerCount = Math.min(MAX_CALENDAR_BUSY_CONCURRENCY, creds.length);
|
|
236
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
237
|
+
busy.push(...providerBusy.flat());
|
|
160
238
|
|
|
161
239
|
return busy;
|
|
162
240
|
}
|