@soulbatical/tetra-core 0.1.39 → 0.1.41

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.

Potentially problematic release.


This version of @soulbatical/tetra-core might be problematic. Click here for more details.

Files changed (56) hide show
  1. package/dist/core/dualWriteProxy.d.ts +11 -0
  2. package/dist/core/dualWriteProxy.d.ts.map +1 -1
  3. package/dist/core/dualWriteProxy.js +142 -198
  4. package/dist/core/dualWriteProxy.js.map +1 -1
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/shared/email/EmailService.d.ts +9 -1
  10. package/dist/shared/email/EmailService.d.ts.map +1 -1
  11. package/dist/shared/email/EmailService.js +83 -7
  12. package/dist/shared/email/EmailService.js.map +1 -1
  13. package/dist/shared/email/adminRoutes.d.ts +30 -0
  14. package/dist/shared/email/adminRoutes.d.ts.map +1 -0
  15. package/dist/shared/email/adminRoutes.js +227 -0
  16. package/dist/shared/email/adminRoutes.js.map +1 -0
  17. package/dist/shared/email/index.d.ts +3 -1
  18. package/dist/shared/email/index.d.ts.map +1 -1
  19. package/dist/shared/email/index.js +3 -0
  20. package/dist/shared/email/index.js.map +1 -1
  21. package/dist/shared/email/mailgun.d.ts +4 -1
  22. package/dist/shared/email/mailgun.d.ts.map +1 -1
  23. package/dist/shared/email/mailgun.js +41 -10
  24. package/dist/shared/email/mailgun.js.map +1 -1
  25. package/dist/shared/email/smtp.d.ts +4 -1
  26. package/dist/shared/email/smtp.d.ts.map +1 -1
  27. package/dist/shared/email/smtp.js +14 -2
  28. package/dist/shared/email/smtp.js.map +1 -1
  29. package/dist/shared/email/types.d.ts +23 -1
  30. package/dist/shared/email/types.d.ts.map +1 -1
  31. package/dist/shared/email/webhookRoutes.d.ts +29 -0
  32. package/dist/shared/email/webhookRoutes.d.ts.map +1 -0
  33. package/dist/shared/email/webhookRoutes.js +125 -0
  34. package/dist/shared/email/webhookRoutes.js.map +1 -0
  35. package/dist/shared/planner/GoogleCalendarService.d.ts +103 -0
  36. package/dist/shared/planner/GoogleCalendarService.d.ts.map +1 -0
  37. package/dist/shared/planner/GoogleCalendarService.js +365 -0
  38. package/dist/shared/planner/GoogleCalendarService.js.map +1 -0
  39. package/dist/shared/planner/PlannerService.d.ts +170 -0
  40. package/dist/shared/planner/PlannerService.d.ts.map +1 -0
  41. package/dist/shared/planner/PlannerService.js +860 -0
  42. package/dist/shared/planner/PlannerService.js.map +1 -0
  43. package/dist/shared/planner/index.d.ts +35 -0
  44. package/dist/shared/planner/index.d.ts.map +1 -0
  45. package/dist/shared/planner/index.js +34 -0
  46. package/dist/shared/planner/index.js.map +1 -0
  47. package/dist/shared/planner/routes.d.ts +67 -0
  48. package/dist/shared/planner/routes.d.ts.map +1 -0
  49. package/dist/shared/planner/routes.js +524 -0
  50. package/dist/shared/planner/routes.js.map +1 -0
  51. package/dist/shared/planner/types.d.ts +262 -0
  52. package/dist/shared/planner/types.d.ts.map +1 -0
  53. package/dist/shared/planner/types.js +9 -0
  54. package/dist/shared/planner/types.js.map +1 -0
  55. package/package.json +1 -1
  56. package/src/shared/email/migrations/004_add_email_logs_tracking_columns.sql +15 -0
@@ -0,0 +1,860 @@
1
+ /**
2
+ * PlannerService — Core scheduling logic
3
+ *
4
+ * Handles:
5
+ * - Availability CRUD (multiple blocks per day)
6
+ * - Appointment CRUD with conflict detection
7
+ * - Vacation-aware slot computation
8
+ * - Team availability aggregation
9
+ * - Scheduler settings management
10
+ *
11
+ * Usage:
12
+ * ```typescript
13
+ * import { PlannerService } from '@soulbatical/tetra-core';
14
+ *
15
+ * const planner = new PlannerService(config);
16
+ * const slots = await planner.getAvailableSlots(db, ownerId, '2026-03-20');
17
+ * ```
18
+ */
19
+ import { createLogger } from '../../utils/logger.js';
20
+ const logger = createLogger('planner:service');
21
+ // ─── Time Helpers ───────────────────────────────────────────
22
+ export function timeToMinutes(time) {
23
+ const parts = time.split(':');
24
+ return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
25
+ }
26
+ export function minutesToTime(minutes) {
27
+ const hh = String(Math.floor(minutes / 60)).padStart(2, '0');
28
+ const mm = String(minutes % 60).padStart(2, '0');
29
+ return `${hh}:${mm}`;
30
+ }
31
+ /**
32
+ * Compute bookable time slots from availability windows, minus existing appointments.
33
+ * Pure function — no DB access.
34
+ */
35
+ export function computeAvailableSlots(availabilitySlots, existingAppointments, date, slotDurationMinutes = 60) {
36
+ const bookableSlots = [];
37
+ for (const slot of availabilitySlots) {
38
+ if (!slot.is_available)
39
+ continue;
40
+ const slotStartMinutes = timeToMinutes(slot.start_time);
41
+ const slotEndMinutes = timeToMinutes(slot.end_time);
42
+ let cursor = slotStartMinutes;
43
+ while (cursor + slotDurationMinutes <= slotEndMinutes) {
44
+ const candidateStart = cursor;
45
+ const candidateEnd = cursor + slotDurationMinutes;
46
+ const isBlocked = existingAppointments.some((appt) => {
47
+ const apptDate = appt.start_time.substring(0, 10);
48
+ if (apptDate !== date)
49
+ return false;
50
+ const apptStartMinutes = timeToMinutes(appt.start_time.substring(11, 16));
51
+ const apptEndMinutes = timeToMinutes(appt.end_time.substring(11, 16));
52
+ return candidateStart < apptEndMinutes && candidateEnd > apptStartMinutes;
53
+ });
54
+ if (!isBlocked) {
55
+ bookableSlots.push({
56
+ startTime: minutesToTime(candidateStart),
57
+ endTime: minutesToTime(candidateEnd),
58
+ });
59
+ }
60
+ cursor += slotDurationMinutes;
61
+ }
62
+ }
63
+ return bookableSlots;
64
+ }
65
+ // ─── Row Transformers ───────────────────────────────────────
66
+ function transformAppointment(row, config) {
67
+ const parentTable = config.parentResource?.table;
68
+ return {
69
+ id: row.id,
70
+ organizationId: row.organization_id,
71
+ parentId: row.parent_id,
72
+ organizerId: row.organizer_id,
73
+ title: row.title,
74
+ description: row.description,
75
+ startTime: row.start_time,
76
+ endTime: row.end_time,
77
+ location: row.location,
78
+ type: row.type,
79
+ status: row.status,
80
+ attendeeIds: row.attendee_ids || [],
81
+ googleCalendarEventId: row.google_calendar_event_id,
82
+ outlookEventId: row.outlook_event_id,
83
+ reminderSentAt: row.reminder_sent_at,
84
+ createdAt: row.created_at,
85
+ updatedAt: row.updated_at,
86
+ organizer: row.organizer_name !== undefined
87
+ ? { id: row.organizer_id, name: row.organizer_name, email: row.organizer_email }
88
+ : (row.organizer ? { id: row.organizer_id, name: row.organizer?.name, email: row.organizer?.email } : null),
89
+ parent: row.parent_title !== undefined
90
+ ? { id: row.parent_id, title: row.parent_title }
91
+ : (row.parent ? { id: row.parent_id, title: row.parent?.title } : null),
92
+ };
93
+ }
94
+ // ─── Service Class ──────────────────────────────────────────
95
+ export class PlannerService {
96
+ config;
97
+ constructor(config) {
98
+ this.config = config;
99
+ }
100
+ get ownerRole() {
101
+ return this.config.ownerRole;
102
+ }
103
+ get ownerTable() {
104
+ return this.config.ownerTable || 'users_public';
105
+ }
106
+ get timezone() {
107
+ return this.config.timezone || 'Europe/Amsterdam';
108
+ }
109
+ get appointmentTypes() {
110
+ return this.config.appointmentTypes || ['online', 'phone', 'in_person'];
111
+ }
112
+ get appointmentStatuses() {
113
+ return this.config.appointmentStatuses || ['scheduled', 'completed', 'cancelled'];
114
+ }
115
+ // ─── Availability ───────────────────────────────────────────
116
+ /**
117
+ * Get an owner's recurring availability slots.
118
+ */
119
+ async getAvailability(db, ownerId) {
120
+ const { data, error } = await db
121
+ .from('planner_availability_slots')
122
+ .select('*')
123
+ .eq('owner_id', ownerId)
124
+ .order('day_of_week', { ascending: true })
125
+ .order('start_time', { ascending: true });
126
+ if (error) {
127
+ logger.error({ error, ownerId }, 'Failed to get availability');
128
+ throw new Error('Failed to retrieve availability');
129
+ }
130
+ return (data || []).map((row) => ({
131
+ id: row.id,
132
+ organizationId: row.organization_id,
133
+ ownerId: row.owner_id,
134
+ dayOfWeek: row.day_of_week,
135
+ startTime: row.start_time,
136
+ endTime: row.end_time,
137
+ isAvailable: row.is_available,
138
+ createdAt: row.created_at,
139
+ updatedAt: row.updated_at,
140
+ }));
141
+ }
142
+ /**
143
+ * Replace all availability slots for an owner.
144
+ */
145
+ async setAvailability(db, ownerId, organizationId, slots) {
146
+ // Validate
147
+ for (let i = 0; i < slots.length; i++) {
148
+ const slot = slots[i];
149
+ if (slot.dayOfWeek < 0 || slot.dayOfWeek > 6) {
150
+ throw new Error(`Slot ${i}: dayOfWeek must be 0-6`);
151
+ }
152
+ if (!slot.startTime || !slot.endTime) {
153
+ throw new Error(`Slot ${i}: startTime and endTime are required`);
154
+ }
155
+ if (timeToMinutes(slot.startTime) >= timeToMinutes(slot.endTime)) {
156
+ throw new Error(`Slot ${i}: endTime must be after startTime`);
157
+ }
158
+ }
159
+ // Delete existing
160
+ const { error: deleteError } = await db
161
+ .from('planner_availability_slots')
162
+ .delete()
163
+ .eq('owner_id', ownerId);
164
+ if (deleteError) {
165
+ logger.error({ error: deleteError, ownerId }, 'Failed to clear availability');
166
+ throw new Error('Failed to update availability');
167
+ }
168
+ if (slots.length === 0)
169
+ return [];
170
+ // Insert new
171
+ const insertRows = slots.map((slot) => ({
172
+ organization_id: organizationId,
173
+ owner_id: ownerId,
174
+ day_of_week: slot.dayOfWeek,
175
+ start_time: slot.startTime,
176
+ end_time: slot.endTime,
177
+ is_available: slot.isAvailable !== undefined ? slot.isAvailable : true,
178
+ }));
179
+ const { data, error: insertError } = await db
180
+ .from('planner_availability_slots')
181
+ .insert(insertRows)
182
+ .select();
183
+ if (insertError) {
184
+ logger.error({ error: insertError, ownerId }, 'Failed to insert availability');
185
+ throw new Error('Failed to save availability');
186
+ }
187
+ logger.info({ ownerId, slotCount: slots.length }, 'Availability updated');
188
+ return data || [];
189
+ }
190
+ // ─── Vacations ──────────────────────────────────────────────
191
+ /**
192
+ * Check if a date falls within any vacation for an owner.
193
+ */
194
+ async isOnVacation(db, ownerId, date) {
195
+ const { data, error } = await db
196
+ .from('planner_vacations')
197
+ .select('id')
198
+ .eq('owner_id', ownerId)
199
+ .lte('start_date', date)
200
+ .gte('end_date', date)
201
+ .limit(1);
202
+ if (error) {
203
+ logger.error({ error, ownerId, date }, 'Failed to check vacation');
204
+ return false;
205
+ }
206
+ return (data || []).length > 0;
207
+ }
208
+ /**
209
+ * Get all vacations for an owner.
210
+ */
211
+ async getVacations(db, ownerId) {
212
+ const { data, error } = await db
213
+ .from('planner_vacations')
214
+ .select('*')
215
+ .eq('owner_id', ownerId)
216
+ .order('start_date', { ascending: true });
217
+ if (error) {
218
+ logger.error({ error, ownerId }, 'Failed to get vacations');
219
+ throw new Error('Failed to retrieve vacations');
220
+ }
221
+ return (data || []).map((row) => ({
222
+ id: row.id,
223
+ organizationId: row.organization_id,
224
+ ownerId: row.owner_id,
225
+ startDate: row.start_date,
226
+ endDate: row.end_date,
227
+ description: row.description,
228
+ createdAt: row.created_at,
229
+ updatedAt: row.updated_at,
230
+ }));
231
+ }
232
+ /**
233
+ * Create a vacation period.
234
+ */
235
+ async createVacation(db, ownerId, organizationId, startDate, endDate, description) {
236
+ if (startDate > endDate) {
237
+ throw new Error('endDate must be on or after startDate');
238
+ }
239
+ const { data, error } = await db
240
+ .from('planner_vacations')
241
+ .insert({
242
+ organization_id: organizationId,
243
+ owner_id: ownerId,
244
+ start_date: startDate,
245
+ end_date: endDate,
246
+ description: description || null,
247
+ })
248
+ .select()
249
+ .single();
250
+ if (error) {
251
+ logger.error({ error, ownerId }, 'Failed to create vacation');
252
+ throw new Error('Failed to create vacation');
253
+ }
254
+ logger.info({ ownerId, startDate, endDate }, 'Vacation created');
255
+ return data;
256
+ }
257
+ /**
258
+ * Delete a vacation.
259
+ */
260
+ async deleteVacation(db, vacationId) {
261
+ const { error } = await db
262
+ .from('planner_vacations')
263
+ .delete()
264
+ .eq('id', vacationId);
265
+ if (error) {
266
+ logger.error({ error, vacationId }, 'Failed to delete vacation');
267
+ throw new Error('Failed to delete vacation');
268
+ }
269
+ logger.info({ vacationId }, 'Vacation deleted');
270
+ }
271
+ // ─── Scheduler Settings ─────────────────────────────────────
272
+ /**
273
+ * Get scheduler settings for an owner.
274
+ */
275
+ async getSettings(db, ownerId) {
276
+ const { data, error } = await db
277
+ .from('planner_scheduler_settings')
278
+ .select('*')
279
+ .eq('owner_id', ownerId)
280
+ .maybeSingle();
281
+ if (error) {
282
+ logger.error({ error, ownerId }, 'Failed to get scheduler settings');
283
+ throw new Error('Failed to retrieve settings');
284
+ }
285
+ const defaults = this.config.defaults || {};
286
+ if (!data) {
287
+ return {
288
+ eventName: 'Session',
289
+ zoomLink: null,
290
+ description: null,
291
+ sessionDurationMinutes: defaults.sessionDurationMinutes ?? 60,
292
+ maxDaysAhead: defaults.maxDaysAhead ?? 14,
293
+ minLeadTimeHours: defaults.minLeadTimeHours ?? 24,
294
+ timeIncrementMinutes: defaults.timeIncrementMinutes ?? 15,
295
+ };
296
+ }
297
+ return {
298
+ eventName: data.event_name ?? 'Session',
299
+ zoomLink: data.zoom_link ?? null,
300
+ description: data.description ?? null,
301
+ sessionDurationMinutes: data.session_duration_minutes ?? defaults.sessionDurationMinutes ?? 60,
302
+ maxDaysAhead: data.max_days_ahead ?? defaults.maxDaysAhead ?? 14,
303
+ minLeadTimeHours: data.min_lead_time_hours ?? defaults.minLeadTimeHours ?? 24,
304
+ timeIncrementMinutes: data.time_increment_minutes ?? defaults.timeIncrementMinutes ?? 15,
305
+ };
306
+ }
307
+ /**
308
+ * Upsert scheduler settings for an owner.
309
+ */
310
+ async updateSettings(db, ownerId, organizationId, settings) {
311
+ const row = {
312
+ owner_id: ownerId,
313
+ organization_id: organizationId,
314
+ updated_at: new Date().toISOString(),
315
+ };
316
+ if (settings.eventName !== undefined)
317
+ row.event_name = settings.eventName;
318
+ if (settings.zoomLink !== undefined)
319
+ row.zoom_link = settings.zoomLink;
320
+ if (settings.description !== undefined)
321
+ row.description = settings.description;
322
+ if (settings.sessionDurationMinutes !== undefined)
323
+ row.session_duration_minutes = settings.sessionDurationMinutes;
324
+ if (settings.maxDaysAhead !== undefined)
325
+ row.max_days_ahead = settings.maxDaysAhead;
326
+ if (settings.minLeadTimeHours !== undefined)
327
+ row.min_lead_time_hours = settings.minLeadTimeHours;
328
+ if (settings.timeIncrementMinutes !== undefined)
329
+ row.time_increment_minutes = settings.timeIncrementMinutes;
330
+ const { data, error } = await db
331
+ .from('planner_scheduler_settings')
332
+ .upsert(row, { onConflict: 'owner_id' })
333
+ .select()
334
+ .single();
335
+ if (error) {
336
+ logger.error({ error, ownerId }, 'Failed to update scheduler settings');
337
+ throw new Error('Failed to save settings');
338
+ }
339
+ logger.info({ ownerId }, 'Scheduler settings updated');
340
+ return data;
341
+ }
342
+ // ─── Available Slots ────────────────────────────────────────
343
+ /**
344
+ * Compute available booking slots for a specific date.
345
+ * Checks: availability, vacations, existing appointments, booking window, lead time.
346
+ */
347
+ async getAvailableSlots(db, ownerId, date) {
348
+ const parsedDate = new Date(date + 'T00:00:00Z');
349
+ if (isNaN(parsedDate.getTime())) {
350
+ throw new Error('Invalid date');
351
+ }
352
+ const dayOfWeek = parsedDate.getUTCDay();
353
+ // 1. Check vacation
354
+ const onVacation = await this.isOnVacation(db, ownerId, date);
355
+ const settings = await this.getSettings(db, ownerId);
356
+ if (onVacation) {
357
+ return { date, dayOfWeek, ownerId, slots: [], sessionDurationMinutes: settings.sessionDurationMinutes };
358
+ }
359
+ // 2. Check booking window
360
+ const now = new Date();
361
+ const requestedDate = new Date(date + 'T23:59:59Z');
362
+ const maxDate = new Date(now);
363
+ maxDate.setDate(maxDate.getDate() + settings.maxDaysAhead);
364
+ if (requestedDate > maxDate) {
365
+ return { date, dayOfWeek, ownerId, slots: [], sessionDurationMinutes: settings.sessionDurationMinutes };
366
+ }
367
+ // 3. Get availability for this day of week
368
+ const { data: availabilitySlots, error: availError } = await db
369
+ .from('planner_availability_slots')
370
+ .select('*')
371
+ .eq('owner_id', ownerId)
372
+ .eq('day_of_week', dayOfWeek)
373
+ .eq('is_available', true)
374
+ .order('start_time', { ascending: true });
375
+ if (availError || !availabilitySlots?.length) {
376
+ return { date, dayOfWeek, ownerId, slots: [], sessionDurationMinutes: settings.sessionDurationMinutes };
377
+ }
378
+ // 4. Get existing appointments for this date
379
+ const existingAppointments = await this.getExistingAppointments(db, ownerId, date);
380
+ // 5. Compute slots
381
+ const allSlots = computeAvailableSlots(availabilitySlots, existingAppointments, date, settings.sessionDurationMinutes);
382
+ // 6. Filter by minimum lead time
383
+ const minBookingTime = new Date(now.getTime() + settings.minLeadTimeHours * 60 * 60 * 1000);
384
+ const slots = allSlots.filter((slot) => {
385
+ const slotStart = new Date(`${date}T${slot.startTime}:00Z`);
386
+ return slotStart >= minBookingTime;
387
+ });
388
+ return { date, dayOfWeek, ownerId, slots, sessionDurationMinutes: settings.sessionDurationMinutes };
389
+ }
390
+ /**
391
+ * Get existing scheduled appointments for an owner on a date.
392
+ */
393
+ async getExistingAppointments(db, ownerId, date) {
394
+ const dayStart = `${date}T00:00:00Z`;
395
+ const dayEnd = `${date}T23:59:59Z`;
396
+ const appointments = [];
397
+ // Appointments where owner is organizer
398
+ const { data: organizerAppts } = await db
399
+ .from('planner_appointments')
400
+ .select('start_time, end_time')
401
+ .eq('organizer_id', ownerId)
402
+ .eq('status', 'scheduled')
403
+ .gte('start_time', dayStart)
404
+ .lte('start_time', dayEnd);
405
+ if (organizerAppts) {
406
+ appointments.push(...organizerAppts);
407
+ }
408
+ // If parent resource is configured, also check appointments through parent records
409
+ if (this.config.parentResource) {
410
+ const { table, foreignKey } = this.config.parentResource;
411
+ const { data: ownerParents } = await db
412
+ .from(table)
413
+ .select('id')
414
+ .or(`${foreignKey}.eq.${ownerId}`);
415
+ const parentIds = (ownerParents || []).map((p) => p.id);
416
+ if (parentIds.length > 0) {
417
+ const { data: parentAppts } = await db
418
+ .from('planner_appointments')
419
+ .select('start_time, end_time')
420
+ .in('parent_id', parentIds)
421
+ .eq('status', 'scheduled')
422
+ .gte('start_time', dayStart)
423
+ .lte('start_time', dayEnd);
424
+ if (parentAppts) {
425
+ // Deduplicate
426
+ const existingTimes = new Set(appointments.map((a) => `${a.start_time}|${a.end_time}`));
427
+ for (const appt of parentAppts) {
428
+ const key = `${appt.start_time}|${appt.end_time}`;
429
+ if (!existingTimes.has(key)) {
430
+ appointments.push(appt);
431
+ existingTimes.add(key);
432
+ }
433
+ }
434
+ }
435
+ }
436
+ }
437
+ return appointments;
438
+ }
439
+ // ─── Team Availability ──────────────────────────────────────
440
+ /**
441
+ * Get availability for all owners in an organization for a specific date.
442
+ */
443
+ async getTeamAvailability(db, organizationId, date) {
444
+ const parsedDate = new Date(date + 'T00:00:00Z');
445
+ if (isNaN(parsedDate.getTime())) {
446
+ throw new Error('Invalid date');
447
+ }
448
+ const dayOfWeek = parsedDate.getUTCDay();
449
+ const ownerTable = this.ownerTable;
450
+ const orgColumn = this.config.ownerOrgColumn || 'active_organization_id';
451
+ // Get all owners (coaches/consultants) in this organization
452
+ const { data: owners, error: ownersError } = await db
453
+ .from(ownerTable)
454
+ .select('id, name, avatar_url')
455
+ .eq(orgColumn, organizationId)
456
+ .eq('role', this.ownerRole);
457
+ if (ownersError || !owners?.length) {
458
+ return { date, dayOfWeek, owners: [] };
459
+ }
460
+ // For each owner, compute their availability
461
+ const ownerResults = await Promise.all(owners.map(async (owner) => {
462
+ try {
463
+ const dayAvailability = await this.getAvailableSlots(db, owner.id, date);
464
+ const onVacation = await this.isOnVacation(db, owner.id, date);
465
+ return {
466
+ id: owner.id,
467
+ name: owner.name,
468
+ avatarUrl: owner.avatar_url,
469
+ slots: dayAvailability.slots,
470
+ vacationDay: onVacation,
471
+ };
472
+ }
473
+ catch {
474
+ return {
475
+ id: owner.id,
476
+ name: owner.name,
477
+ avatarUrl: owner.avatar_url,
478
+ slots: [],
479
+ vacationDay: false,
480
+ };
481
+ }
482
+ }));
483
+ return { date, dayOfWeek, owners: ownerResults };
484
+ }
485
+ // ─── Appointments ───────────────────────────────────────────
486
+ /**
487
+ * List all appointments for a user (as organizer or via parent resources).
488
+ */
489
+ async listAppointments(db, userId, options) {
490
+ let query = db
491
+ .from('planner_appointments')
492
+ .select('*')
493
+ .order('start_time', { ascending: true });
494
+ // Build OR filter: organizer OR involved via parent resource
495
+ if (this.config.parentResource) {
496
+ const { table, foreignKey, clientKey } = this.config.parentResource;
497
+ // Find parent IDs where user is owner or client
498
+ const orParts = [`${foreignKey}.eq.${userId}`];
499
+ if (clientKey)
500
+ orParts.push(`${clientKey}.eq.${userId}`);
501
+ const { data: userParents } = await db
502
+ .from(table)
503
+ .select('id')
504
+ .or(orParts.join(','));
505
+ const parentIds = (userParents || []).map((p) => p.id);
506
+ if (parentIds.length > 0) {
507
+ query = query.or(`organizer_id.eq.${userId},parent_id.in.(${parentIds.join(',')})`);
508
+ }
509
+ else {
510
+ query = query.eq('organizer_id', userId);
511
+ }
512
+ }
513
+ else {
514
+ query = query.eq('organizer_id', userId);
515
+ }
516
+ if (options?.status === 'all') {
517
+ // No filter
518
+ }
519
+ else if (options?.status && this.appointmentStatuses.includes(options.status)) {
520
+ query = query.eq('status', options.status);
521
+ }
522
+ else {
523
+ query = query.neq('status', 'cancelled');
524
+ }
525
+ const { data, error } = await query;
526
+ if (error) {
527
+ logger.error({ error, userId }, 'Failed to list appointments');
528
+ throw new Error('Failed to retrieve appointments');
529
+ }
530
+ return (data || []).map((row) => transformAppointment(row, this.config));
531
+ }
532
+ /**
533
+ * Create an appointment.
534
+ */
535
+ async createAppointment(db, organizerId, organizationId, input) {
536
+ const start = new Date(input.startTime);
537
+ const end = new Date(input.endTime);
538
+ if (isNaN(start.getTime()) || isNaN(end.getTime())) {
539
+ throw new Error('Invalid date format for startTime or endTime');
540
+ }
541
+ if (end <= start) {
542
+ throw new Error('endTime must be after startTime');
543
+ }
544
+ const appointmentType = input.type || 'online';
545
+ if (!this.appointmentTypes.includes(appointmentType)) {
546
+ throw new Error(`Invalid type. Must be one of: ${this.appointmentTypes.join(', ')}`);
547
+ }
548
+ const insertData = {
549
+ organization_id: organizationId,
550
+ organizer_id: organizerId,
551
+ title: input.title,
552
+ start_time: start.toISOString(),
553
+ end_time: end.toISOString(),
554
+ type: appointmentType,
555
+ status: 'scheduled',
556
+ };
557
+ if (input.parentId)
558
+ insertData.parent_id = input.parentId;
559
+ if (input.description !== undefined)
560
+ insertData.description = input.description;
561
+ if (input.location !== undefined)
562
+ insertData.location = input.location;
563
+ if (Array.isArray(input.attendeeIds))
564
+ insertData.attendee_ids = input.attendeeIds;
565
+ const { data, error } = await db
566
+ .from('planner_appointments')
567
+ .insert(insertData)
568
+ .select()
569
+ .single();
570
+ if (error) {
571
+ logger.error({ error, organizerId }, 'Failed to create appointment');
572
+ if (error.code === '23503') {
573
+ throw new Error('Invalid parent resource ID');
574
+ }
575
+ throw new Error('Failed to create appointment');
576
+ }
577
+ logger.info({ appointmentId: data.id, organizerId }, 'Appointment created');
578
+ const appointment = transformAppointment(data, this.config);
579
+ // Fire callback (non-blocking)
580
+ if (this.config.onAppointmentCreated) {
581
+ const ctx = { ownerId: organizerId, organizationId };
582
+ this.config.onAppointmentCreated(appointment, ctx).catch((err) => {
583
+ logger.error({ error: err, appointmentId: data.id }, 'onAppointmentCreated callback failed');
584
+ });
585
+ }
586
+ return appointment;
587
+ }
588
+ /**
589
+ * Update an appointment.
590
+ */
591
+ async updateAppointment(db, appointmentId, updates) {
592
+ const updateData = {
593
+ updated_at: new Date().toISOString(),
594
+ };
595
+ if (updates.title !== undefined)
596
+ updateData.title = updates.title;
597
+ if (updates.description !== undefined)
598
+ updateData.description = updates.description;
599
+ if (updates.location !== undefined)
600
+ updateData.location = updates.location;
601
+ if (updates.startTime !== undefined) {
602
+ const start = new Date(updates.startTime);
603
+ if (isNaN(start.getTime()))
604
+ throw new Error('Invalid startTime');
605
+ updateData.start_time = start.toISOString();
606
+ }
607
+ if (updates.endTime !== undefined) {
608
+ const end = new Date(updates.endTime);
609
+ if (isNaN(end.getTime()))
610
+ throw new Error('Invalid endTime');
611
+ updateData.end_time = end.toISOString();
612
+ }
613
+ if (updates.type !== undefined) {
614
+ if (!this.appointmentTypes.includes(updates.type)) {
615
+ throw new Error(`Invalid type. Must be one of: ${this.appointmentTypes.join(', ')}`);
616
+ }
617
+ updateData.type = updates.type;
618
+ }
619
+ if (updateData.start_time && updateData.end_time) {
620
+ if (new Date(updateData.end_time) <= new Date(updateData.start_time)) {
621
+ throw new Error('endTime must be after startTime');
622
+ }
623
+ }
624
+ const { data, error } = await db
625
+ .from('planner_appointments')
626
+ .update(updateData)
627
+ .eq('id', appointmentId)
628
+ .select()
629
+ .single();
630
+ if (error) {
631
+ if (error.code === 'PGRST116')
632
+ throw new Error('Appointment not found');
633
+ logger.error({ error, appointmentId }, 'Failed to update appointment');
634
+ throw new Error('Failed to update appointment');
635
+ }
636
+ logger.info({ appointmentId }, 'Appointment updated');
637
+ return transformAppointment(data, this.config);
638
+ }
639
+ /**
640
+ * Cancel an appointment (soft delete).
641
+ */
642
+ async cancelAppointment(db, appointmentId, organizationId) {
643
+ const { data, error } = await db
644
+ .from('planner_appointments')
645
+ .update({ status: 'cancelled', updated_at: new Date().toISOString() })
646
+ .eq('id', appointmentId)
647
+ .neq('status', 'cancelled')
648
+ .select()
649
+ .single();
650
+ if (error) {
651
+ if (error.code === 'PGRST116')
652
+ throw new Error('Appointment not found');
653
+ logger.error({ error, appointmentId }, 'Failed to cancel appointment');
654
+ throw new Error('Failed to cancel appointment');
655
+ }
656
+ logger.info({ appointmentId }, 'Appointment cancelled');
657
+ const appointment = transformAppointment(data, this.config);
658
+ if (this.config.onAppointmentCancelled) {
659
+ const ctx = { ownerId: data.organizer_id, organizationId };
660
+ this.config.onAppointmentCancelled(appointment, ctx).catch((err) => {
661
+ logger.error({ error: err, appointmentId }, 'onAppointmentCancelled callback failed');
662
+ });
663
+ }
664
+ return appointment;
665
+ }
666
+ /**
667
+ * Complete an appointment.
668
+ */
669
+ async completeAppointment(db, appointmentId, organizationId) {
670
+ const { data, error } = await db
671
+ .from('planner_appointments')
672
+ .update({ status: 'completed', updated_at: new Date().toISOString() })
673
+ .eq('id', appointmentId)
674
+ .eq('status', 'scheduled')
675
+ .select()
676
+ .single();
677
+ if (error) {
678
+ if (error.code === 'PGRST116')
679
+ throw new Error('Appointment not found');
680
+ logger.error({ error, appointmentId }, 'Failed to complete appointment');
681
+ throw new Error('Failed to complete appointment');
682
+ }
683
+ logger.info({ appointmentId }, 'Appointment completed');
684
+ const appointment = transformAppointment(data, this.config);
685
+ if (this.config.onAppointmentCompleted) {
686
+ const ctx = { ownerId: data.organizer_id, organizationId };
687
+ this.config.onAppointmentCompleted(appointment, ctx).catch((err) => {
688
+ logger.error({ error: err, appointmentId }, 'onAppointmentCompleted callback failed');
689
+ });
690
+ }
691
+ return appointment;
692
+ }
693
+ // ─── Public Booking ─────────────────────────────────────────
694
+ /**
695
+ * Get public scheduler page data (no auth).
696
+ */
697
+ async getPublicSchedulerData(db, ownerId) {
698
+ const ownerTable = this.ownerTable;
699
+ const orgColumn = this.config.ownerOrgColumn || 'active_organization_id';
700
+ const { data: ownerRow, error: ownerError } = await db
701
+ .from(ownerTable)
702
+ .select('*')
703
+ .eq('id', ownerId)
704
+ .single();
705
+ if (ownerError || !ownerRow)
706
+ return null;
707
+ const owner = ownerRow;
708
+ const orgId = owner[orgColumn];
709
+ const { data: org } = await db
710
+ .from('organizations')
711
+ .select('name')
712
+ .eq('id', orgId)
713
+ .single();
714
+ const settings = await this.getSettings(db, ownerId);
715
+ const { data: availability } = await db
716
+ .from('planner_availability_slots')
717
+ .select('day_of_week')
718
+ .eq('owner_id', ownerId)
719
+ .eq('is_available', true);
720
+ const availableDays = [...new Set((availability || []).map((s) => s.day_of_week))];
721
+ return {
722
+ owner: { id: owner.id, name: owner.name, avatarUrl: owner.avatar_url },
723
+ organization: { name: org?.name ?? null },
724
+ settings,
725
+ availableDays,
726
+ };
727
+ }
728
+ /**
729
+ * Book a slot via public booking (no auth).
730
+ */
731
+ async publicBook(db, ownerId, input) {
732
+ // Validate
733
+ if (!input.name || !input.email || !input.date || !input.startTime || !input.endTime) {
734
+ throw new Error('name, email, date, startTime, and endTime are required');
735
+ }
736
+ const parsedDate = new Date(input.date + 'T00:00:00Z');
737
+ if (isNaN(parsedDate.getTime()))
738
+ throw new Error('Invalid date');
739
+ if (timeToMinutes(input.startTime) >= timeToMinutes(input.endTime)) {
740
+ throw new Error('endTime must be after startTime');
741
+ }
742
+ // Check vacation
743
+ const onVacation = await this.isOnVacation(db, ownerId, input.date);
744
+ if (onVacation) {
745
+ throw new Error('Owner is on vacation on this date');
746
+ }
747
+ // Check availability
748
+ const dayOfWeek = parsedDate.getUTCDay();
749
+ const { data: availSlots } = await db
750
+ .from('planner_availability_slots')
751
+ .select('*')
752
+ .eq('owner_id', ownerId)
753
+ .eq('day_of_week', dayOfWeek)
754
+ .eq('is_available', true);
755
+ const requestedStart = timeToMinutes(input.startTime);
756
+ const requestedEnd = timeToMinutes(input.endTime);
757
+ const isWithin = (availSlots || []).some((slot) => {
758
+ const slotStart = timeToMinutes(slot.start_time);
759
+ const slotEnd = timeToMinutes(slot.end_time);
760
+ return requestedStart >= slotStart && requestedEnd <= slotEnd;
761
+ });
762
+ if (!isWithin) {
763
+ throw new Error('Requested time is outside availability');
764
+ }
765
+ // Check conflicts
766
+ const existingAppts = await this.getExistingAppointments(db, ownerId, input.date);
767
+ const hasConflict = existingAppts.some((appt) => {
768
+ const apptStart = timeToMinutes(appt.start_time.substring(11, 16));
769
+ const apptEnd = timeToMinutes(appt.end_time.substring(11, 16));
770
+ return requestedStart < apptEnd && requestedEnd > apptStart;
771
+ });
772
+ if (hasConflict) {
773
+ throw new Error('Time slot is no longer available');
774
+ }
775
+ // Get owner info
776
+ const ownerTable = this.ownerTable;
777
+ const orgColumn = this.config.ownerOrgColumn || 'active_organization_id';
778
+ const { data: ownerData } = await db
779
+ .from(ownerTable)
780
+ .select(`id, name, ${orgColumn}`)
781
+ .eq('id', ownerId)
782
+ .single();
783
+ if (!ownerData)
784
+ throw new Error('Owner not found');
785
+ const organizationId = ownerData[orgColumn];
786
+ const settings = await this.getSettings(db, ownerId);
787
+ const zoomLink = settings.zoomLink;
788
+ // Auto-link to parent resource if booker email matches
789
+ let parentId = null;
790
+ if (this.config.parentResource) {
791
+ const { table, foreignKey, clientKey } = this.config.parentResource;
792
+ if (clientKey) {
793
+ const { data: bookerUser } = await db
794
+ .from(ownerTable)
795
+ .select('id')
796
+ .eq('email', input.email.toLowerCase().trim())
797
+ .eq(orgColumn, organizationId)
798
+ .maybeSingle();
799
+ if (bookerUser) {
800
+ const { data: matchingParent } = await db
801
+ .from(table)
802
+ .select('id')
803
+ .or(`${foreignKey}.eq.${ownerId}`)
804
+ .eq(clientKey, bookerUser.id)
805
+ .eq('status', 'active')
806
+ .limit(1)
807
+ .maybeSingle();
808
+ if (matchingParent) {
809
+ parentId = matchingParent.id;
810
+ logger.info({ parentId, bookerEmail: input.email, ownerId }, 'Auto-linked public booking to parent');
811
+ }
812
+ }
813
+ }
814
+ }
815
+ const startISO = `${input.date}T${input.startTime.length === 5 ? input.startTime + ':00' : input.startTime}Z`;
816
+ const endISO = `${input.date}T${input.endTime.length === 5 ? input.endTime + ':00' : input.endTime}Z`;
817
+ const { data: appointment, error: insertError } = await db
818
+ .from('planner_appointments')
819
+ .insert({
820
+ organization_id: organizationId,
821
+ parent_id: parentId,
822
+ organizer_id: ownerId,
823
+ title: `${settings.eventName} - ${input.name}`,
824
+ description: [
825
+ 'Booked via public scheduler',
826
+ `Name: ${input.name}`,
827
+ `Email: ${input.email}`,
828
+ input.notes ? `Notes: ${input.notes}` : null,
829
+ zoomLink ? `Zoom: ${zoomLink}` : null,
830
+ ].filter(Boolean).join('\n'),
831
+ start_time: startISO,
832
+ end_time: endISO,
833
+ type: 'online',
834
+ status: 'scheduled',
835
+ })
836
+ .select()
837
+ .single();
838
+ if (insertError) {
839
+ logger.error({ error: insertError, ownerId, name: input.name }, 'Failed to create public booking');
840
+ throw new Error('Failed to create appointment');
841
+ }
842
+ logger.info({
843
+ appointmentId: appointment.id,
844
+ ownerId,
845
+ visitorName: input.name,
846
+ visitorEmail: input.email,
847
+ }, 'Public booking created');
848
+ const record = transformAppointment(appointment, this.config);
849
+ // Fire callback
850
+ if (this.config.onPublicBooking) {
851
+ const ctx = { ownerId, organizationId };
852
+ const visitor = { name: input.name, email: input.email, notes: input.notes };
853
+ this.config.onPublicBooking(record, visitor, ctx).catch((err) => {
854
+ logger.error({ error: err, appointmentId: appointment.id }, 'onPublicBooking callback failed');
855
+ });
856
+ }
857
+ return record;
858
+ }
859
+ }
860
+ //# sourceMappingURL=PlannerService.js.map