@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.
- package/dist/core/dualWriteProxy.d.ts +11 -0
- package/dist/core/dualWriteProxy.d.ts.map +1 -1
- package/dist/core/dualWriteProxy.js +142 -198
- package/dist/core/dualWriteProxy.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/shared/email/EmailService.d.ts +9 -1
- package/dist/shared/email/EmailService.d.ts.map +1 -1
- package/dist/shared/email/EmailService.js +83 -7
- package/dist/shared/email/EmailService.js.map +1 -1
- package/dist/shared/email/adminRoutes.d.ts +30 -0
- package/dist/shared/email/adminRoutes.d.ts.map +1 -0
- package/dist/shared/email/adminRoutes.js +227 -0
- package/dist/shared/email/adminRoutes.js.map +1 -0
- package/dist/shared/email/index.d.ts +3 -1
- package/dist/shared/email/index.d.ts.map +1 -1
- package/dist/shared/email/index.js +3 -0
- package/dist/shared/email/index.js.map +1 -1
- package/dist/shared/email/mailgun.d.ts +4 -1
- package/dist/shared/email/mailgun.d.ts.map +1 -1
- package/dist/shared/email/mailgun.js +41 -10
- package/dist/shared/email/mailgun.js.map +1 -1
- package/dist/shared/email/smtp.d.ts +4 -1
- package/dist/shared/email/smtp.d.ts.map +1 -1
- package/dist/shared/email/smtp.js +14 -2
- package/dist/shared/email/smtp.js.map +1 -1
- package/dist/shared/email/types.d.ts +23 -1
- package/dist/shared/email/types.d.ts.map +1 -1
- package/dist/shared/email/webhookRoutes.d.ts +29 -0
- package/dist/shared/email/webhookRoutes.d.ts.map +1 -0
- package/dist/shared/email/webhookRoutes.js +125 -0
- package/dist/shared/email/webhookRoutes.js.map +1 -0
- package/dist/shared/planner/GoogleCalendarService.d.ts +103 -0
- package/dist/shared/planner/GoogleCalendarService.d.ts.map +1 -0
- package/dist/shared/planner/GoogleCalendarService.js +365 -0
- package/dist/shared/planner/GoogleCalendarService.js.map +1 -0
- package/dist/shared/planner/PlannerService.d.ts +170 -0
- package/dist/shared/planner/PlannerService.d.ts.map +1 -0
- package/dist/shared/planner/PlannerService.js +860 -0
- package/dist/shared/planner/PlannerService.js.map +1 -0
- package/dist/shared/planner/index.d.ts +35 -0
- package/dist/shared/planner/index.d.ts.map +1 -0
- package/dist/shared/planner/index.js +34 -0
- package/dist/shared/planner/index.js.map +1 -0
- package/dist/shared/planner/routes.d.ts +67 -0
- package/dist/shared/planner/routes.d.ts.map +1 -0
- package/dist/shared/planner/routes.js +524 -0
- package/dist/shared/planner/routes.js.map +1 -0
- package/dist/shared/planner/types.d.ts +262 -0
- package/dist/shared/planner/types.d.ts.map +1 -0
- package/dist/shared/planner/types.js +9 -0
- package/dist/shared/planner/types.js.map +1 -0
- package/package.json +1 -1
- 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
|