@soulbatical/tetra-core 0.10.4 → 0.11.0

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.
Files changed (271) hide show
  1. package/README.md +78 -38
  2. package/dist/core/createApp.d.ts +1 -1
  3. package/dist/core/createApp.d.ts.map +1 -1
  4. package/dist/core/createApp.js +77 -2
  5. package/dist/core/createApp.js.map +1 -1
  6. package/dist/core/dualWriteProxy.d.ts +7 -2
  7. package/dist/core/dualWriteProxy.d.ts.map +1 -1
  8. package/dist/core/dualWriteProxy.js +16 -5
  9. package/dist/core/dualWriteProxy.js.map +1 -1
  10. package/dist/core/routeContext.d.ts +24 -0
  11. package/dist/core/routeContext.d.ts.map +1 -1
  12. package/dist/core/routeContext.js +31 -4
  13. package/dist/core/routeContext.js.map +1 -1
  14. package/dist/core/systemDb.d.ts +2 -2
  15. package/dist/core/systemDb.js +2 -2
  16. package/dist/generators/rls-checker.d.ts +1 -1
  17. package/dist/generators/rls-checker.js +1 -1
  18. package/dist/generators/rls-exec-sql.d.ts +1 -1
  19. package/dist/generators/rls-exec-sql.js +1 -1
  20. package/dist/generators/rpc/index.d.ts +1 -1
  21. package/dist/generators/rpc/index.js +1 -1
  22. package/dist/index.d.ts +3 -31
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -32
  25. package/dist/index.js.map +1 -1
  26. package/dist/middleware/securityMiddleware.d.ts +1 -1
  27. package/dist/middleware/securityMiddleware.d.ts.map +1 -1
  28. package/dist/middleware/validateBody.d.ts.map +1 -1
  29. package/dist/middleware/validateBody.js +51 -8
  30. package/dist/middleware/validateBody.js.map +1 -1
  31. package/dist/shared/rfc7807ErrorResponse.d.ts +7 -0
  32. package/dist/shared/rfc7807ErrorResponse.d.ts.map +1 -1
  33. package/dist/shared/rfc7807ErrorResponse.js +19 -5
  34. package/dist/shared/rfc7807ErrorResponse.js.map +1 -1
  35. package/dist/utils/logger.d.ts.map +1 -1
  36. package/dist/utils/logger.js +16 -1
  37. package/dist/utils/logger.js.map +1 -1
  38. package/package.json +33 -77
  39. package/dist/affiliate.d.ts +0 -11
  40. package/dist/affiliate.d.ts.map +0 -1
  41. package/dist/affiliate.js +0 -10
  42. package/dist/affiliate.js.map +0 -1
  43. package/dist/billing.d.ts +0 -8
  44. package/dist/billing.d.ts.map +0 -1
  45. package/dist/billing.js +0 -7
  46. package/dist/billing.js.map +0 -1
  47. package/dist/email.d.ts +0 -9
  48. package/dist/email.d.ts.map +0 -1
  49. package/dist/email.js +0 -8
  50. package/dist/email.js.map +0 -1
  51. package/dist/generators/rls-exec-sql.sql +0 -57
  52. package/dist/generators.d.ts +0 -15
  53. package/dist/generators.d.ts.map +0 -1
  54. package/dist/generators.js +0 -12
  55. package/dist/generators.js.map +0 -1
  56. package/dist/mcp.d.ts +0 -8
  57. package/dist/mcp.d.ts.map +0 -1
  58. package/dist/mcp.js +0 -7
  59. package/dist/mcp.js.map +0 -1
  60. package/dist/planner.d.ts +0 -8
  61. package/dist/planner.d.ts.map +0 -1
  62. package/dist/planner.js +0 -7
  63. package/dist/planner.js.map +0 -1
  64. package/dist/shared/affiliate/AffiliateAttributionService.d.ts +0 -47
  65. package/dist/shared/affiliate/AffiliateAttributionService.d.ts.map +0 -1
  66. package/dist/shared/affiliate/AffiliateAttributionService.js +0 -308
  67. package/dist/shared/affiliate/AffiliateAttributionService.js.map +0 -1
  68. package/dist/shared/affiliate/AffiliateClickService.d.ts +0 -35
  69. package/dist/shared/affiliate/AffiliateClickService.d.ts.map +0 -1
  70. package/dist/shared/affiliate/AffiliateClickService.js +0 -87
  71. package/dist/shared/affiliate/AffiliateClickService.js.map +0 -1
  72. package/dist/shared/affiliate/affiliateFeatureConfig.d.ts +0 -11
  73. package/dist/shared/affiliate/affiliateFeatureConfig.d.ts.map +0 -1
  74. package/dist/shared/affiliate/affiliateFeatureConfig.js +0 -242
  75. package/dist/shared/affiliate/affiliateFeatureConfig.js.map +0 -1
  76. package/dist/shared/affiliate/index.d.ts +0 -11
  77. package/dist/shared/affiliate/index.d.ts.map +0 -1
  78. package/dist/shared/affiliate/index.js +0 -13
  79. package/dist/shared/affiliate/index.js.map +0 -1
  80. package/dist/shared/affiliate/routes.d.ts +0 -87
  81. package/dist/shared/affiliate/routes.d.ts.map +0 -1
  82. package/dist/shared/affiliate/routes.js +0 -404
  83. package/dist/shared/affiliate/routes.js.map +0 -1
  84. package/dist/shared/affiliate/types.d.ts +0 -170
  85. package/dist/shared/affiliate/types.d.ts.map +0 -1
  86. package/dist/shared/affiliate/types.js +0 -11
  87. package/dist/shared/affiliate/types.js.map +0 -1
  88. package/dist/shared/billing/BillingService.d.ts +0 -56
  89. package/dist/shared/billing/BillingService.d.ts.map +0 -1
  90. package/dist/shared/billing/BillingService.js +0 -588
  91. package/dist/shared/billing/BillingService.js.map +0 -1
  92. package/dist/shared/billing/SeatBillingService.d.ts +0 -106
  93. package/dist/shared/billing/SeatBillingService.d.ts.map +0 -1
  94. package/dist/shared/billing/SeatBillingService.js +0 -292
  95. package/dist/shared/billing/SeatBillingService.js.map +0 -1
  96. package/dist/shared/billing/index.d.ts +0 -30
  97. package/dist/shared/billing/index.d.ts.map +0 -1
  98. package/dist/shared/billing/index.js +0 -27
  99. package/dist/shared/billing/index.js.map +0 -1
  100. package/dist/shared/billing/routes.d.ts +0 -45
  101. package/dist/shared/billing/routes.d.ts.map +0 -1
  102. package/dist/shared/billing/routes.js +0 -184
  103. package/dist/shared/billing/routes.js.map +0 -1
  104. package/dist/shared/billing/seat-pricing.d.ts +0 -53
  105. package/dist/shared/billing/seat-pricing.d.ts.map +0 -1
  106. package/dist/shared/billing/seat-pricing.js +0 -81
  107. package/dist/shared/billing/seat-pricing.js.map +0 -1
  108. package/dist/shared/billing/types.d.ts +0 -109
  109. package/dist/shared/billing/types.d.ts.map +0 -1
  110. package/dist/shared/billing/types.js +0 -8
  111. package/dist/shared/billing/types.js.map +0 -1
  112. package/dist/shared/email/EmailService.d.ts +0 -64
  113. package/dist/shared/email/EmailService.d.ts.map +0 -1
  114. package/dist/shared/email/EmailService.js +0 -300
  115. package/dist/shared/email/EmailService.js.map +0 -1
  116. package/dist/shared/email/adminRoutes.d.ts +0 -30
  117. package/dist/shared/email/adminRoutes.d.ts.map +0 -1
  118. package/dist/shared/email/adminRoutes.js +0 -227
  119. package/dist/shared/email/adminRoutes.js.map +0 -1
  120. package/dist/shared/email/gmail.d.ts +0 -208
  121. package/dist/shared/email/gmail.d.ts.map +0 -1
  122. package/dist/shared/email/gmail.js +0 -626
  123. package/dist/shared/email/gmail.js.map +0 -1
  124. package/dist/shared/email/index.d.ts +0 -15
  125. package/dist/shared/email/index.d.ts.map +0 -1
  126. package/dist/shared/email/index.js +0 -18
  127. package/dist/shared/email/index.js.map +0 -1
  128. package/dist/shared/email/mailgun.d.ts +0 -18
  129. package/dist/shared/email/mailgun.d.ts.map +0 -1
  130. package/dist/shared/email/mailgun.js +0 -76
  131. package/dist/shared/email/mailgun.js.map +0 -1
  132. package/dist/shared/email/sanitize.d.ts +0 -25
  133. package/dist/shared/email/sanitize.d.ts.map +0 -1
  134. package/dist/shared/email/sanitize.js +0 -39
  135. package/dist/shared/email/sanitize.js.map +0 -1
  136. package/dist/shared/email/smtp.d.ts +0 -20
  137. package/dist/shared/email/smtp.d.ts.map +0 -1
  138. package/dist/shared/email/smtp.js +0 -53
  139. package/dist/shared/email/smtp.js.map +0 -1
  140. package/dist/shared/email/types.d.ts +0 -113
  141. package/dist/shared/email/types.d.ts.map +0 -1
  142. package/dist/shared/email/types.js +0 -7
  143. package/dist/shared/email/types.js.map +0 -1
  144. package/dist/shared/email/webhookRoutes.d.ts +0 -29
  145. package/dist/shared/email/webhookRoutes.d.ts.map +0 -1
  146. package/dist/shared/email/webhookRoutes.js +0 -125
  147. package/dist/shared/email/webhookRoutes.js.map +0 -1
  148. package/dist/shared/mcp/index.d.ts +0 -51
  149. package/dist/shared/mcp/index.d.ts.map +0 -1
  150. package/dist/shared/mcp/index.js +0 -51
  151. package/dist/shared/mcp/index.js.map +0 -1
  152. package/dist/shared/mcp/mcp-auth-routes.d.ts +0 -26
  153. package/dist/shared/mcp/mcp-auth-routes.d.ts.map +0 -1
  154. package/dist/shared/mcp/mcp-auth-routes.js +0 -141
  155. package/dist/shared/mcp/mcp-auth-routes.js.map +0 -1
  156. package/dist/shared/mcp/mcp-db.d.ts +0 -99
  157. package/dist/shared/mcp/mcp-db.d.ts.map +0 -1
  158. package/dist/shared/mcp/mcp-db.js +0 -106
  159. package/dist/shared/mcp/mcp-db.js.map +0 -1
  160. package/dist/shared/mcp/mcp-routes.d.ts +0 -29
  161. package/dist/shared/mcp/mcp-routes.d.ts.map +0 -1
  162. package/dist/shared/mcp/mcp-routes.js +0 -171
  163. package/dist/shared/mcp/mcp-routes.js.map +0 -1
  164. package/dist/shared/mcp/mcp-tokens-routes.d.ts +0 -35
  165. package/dist/shared/mcp/mcp-tokens-routes.d.ts.map +0 -1
  166. package/dist/shared/mcp/mcp-tokens-routes.js +0 -94
  167. package/dist/shared/mcp/mcp-tokens-routes.js.map +0 -1
  168. package/dist/shared/mcp/mcp-usage-routes.d.ts +0 -17
  169. package/dist/shared/mcp/mcp-usage-routes.d.ts.map +0 -1
  170. package/dist/shared/mcp/mcp-usage-routes.js +0 -81
  171. package/dist/shared/mcp/mcp-usage-routes.js.map +0 -1
  172. package/dist/shared/mcp/tenant-context.d.ts +0 -59
  173. package/dist/shared/mcp/tenant-context.d.ts.map +0 -1
  174. package/dist/shared/mcp/tenant-context.js +0 -136
  175. package/dist/shared/mcp/tenant-context.js.map +0 -1
  176. package/dist/shared/mcp/types.d.ts +0 -74
  177. package/dist/shared/mcp/types.d.ts.map +0 -1
  178. package/dist/shared/mcp/types.js +0 -7
  179. package/dist/shared/mcp/types.js.map +0 -1
  180. package/dist/shared/planner/GoogleCalendarService.d.ts +0 -137
  181. package/dist/shared/planner/GoogleCalendarService.d.ts.map +0 -1
  182. package/dist/shared/planner/GoogleCalendarService.js +0 -525
  183. package/dist/shared/planner/GoogleCalendarService.js.map +0 -1
  184. package/dist/shared/planner/PlannerService.d.ts +0 -264
  185. package/dist/shared/planner/PlannerService.d.ts.map +0 -1
  186. package/dist/shared/planner/PlannerService.js +0 -1393
  187. package/dist/shared/planner/PlannerService.js.map +0 -1
  188. package/dist/shared/planner/index.d.ts +0 -37
  189. package/dist/shared/planner/index.d.ts.map +0 -1
  190. package/dist/shared/planner/index.js +0 -35
  191. package/dist/shared/planner/index.js.map +0 -1
  192. package/dist/shared/planner/intervals.d.ts +0 -60
  193. package/dist/shared/planner/intervals.d.ts.map +0 -1
  194. package/dist/shared/planner/intervals.js +0 -141
  195. package/dist/shared/planner/intervals.js.map +0 -1
  196. package/dist/shared/planner/routes.d.ts +0 -69
  197. package/dist/shared/planner/routes.d.ts.map +0 -1
  198. package/dist/shared/planner/routes.js +0 -770
  199. package/dist/shared/planner/routes.js.map +0 -1
  200. package/dist/shared/planner/types.d.ts +0 -328
  201. package/dist/shared/planner/types.d.ts.map +0 -1
  202. package/dist/shared/planner/types.js +0 -9
  203. package/dist/shared/planner/types.js.map +0 -1
  204. package/dist/shared/storage/ImageProcessingService.d.ts +0 -32
  205. package/dist/shared/storage/ImageProcessingService.d.ts.map +0 -1
  206. package/dist/shared/storage/ImageProcessingService.js +0 -127
  207. package/dist/shared/storage/ImageProcessingService.js.map +0 -1
  208. package/dist/shared/storage/StorageProxyService.d.ts +0 -47
  209. package/dist/shared/storage/StorageProxyService.d.ts.map +0 -1
  210. package/dist/shared/storage/StorageProxyService.js +0 -196
  211. package/dist/shared/storage/StorageProxyService.js.map +0 -1
  212. package/dist/shared/storage/StorageUploadService.d.ts +0 -126
  213. package/dist/shared/storage/StorageUploadService.d.ts.map +0 -1
  214. package/dist/shared/storage/StorageUploadService.js +0 -206
  215. package/dist/shared/storage/StorageUploadService.js.map +0 -1
  216. package/dist/shared/storage/creative-urls.d.ts +0 -14
  217. package/dist/shared/storage/creative-urls.d.ts.map +0 -1
  218. package/dist/shared/storage/creative-urls.js +0 -30
  219. package/dist/shared/storage/creative-urls.js.map +0 -1
  220. package/dist/shared/storage/index.d.ts +0 -28
  221. package/dist/shared/storage/index.d.ts.map +0 -1
  222. package/dist/shared/storage/index.js +0 -27
  223. package/dist/shared/storage/index.js.map +0 -1
  224. package/dist/shared/storage/routes.d.ts +0 -42
  225. package/dist/shared/storage/routes.d.ts.map +0 -1
  226. package/dist/shared/storage/routes.js +0 -160
  227. package/dist/shared/storage/routes.js.map +0 -1
  228. package/dist/shared/storage/types.d.ts +0 -53
  229. package/dist/shared/storage/types.d.ts.map +0 -1
  230. package/dist/shared/storage/types.js +0 -2
  231. package/dist/shared/storage/types.js.map +0 -1
  232. package/dist/shared/telegram/index.d.ts +0 -4
  233. package/dist/shared/telegram/index.d.ts.map +0 -1
  234. package/dist/shared/telegram/index.js +0 -3
  235. package/dist/shared/telegram/index.js.map +0 -1
  236. package/dist/shared/telegram/routes.d.ts +0 -43
  237. package/dist/shared/telegram/routes.d.ts.map +0 -1
  238. package/dist/shared/telegram/routes.js +0 -868
  239. package/dist/shared/telegram/routes.js.map +0 -1
  240. package/dist/shared/telegram/types.d.ts +0 -168
  241. package/dist/shared/telegram/types.d.ts.map +0 -1
  242. package/dist/shared/telegram/types.js +0 -7
  243. package/dist/shared/telegram/types.js.map +0 -1
  244. package/dist/shared/telegram/utils.d.ts +0 -44
  245. package/dist/shared/telegram/utils.d.ts.map +0 -1
  246. package/dist/shared/telegram/utils.js +0 -121
  247. package/dist/shared/telegram/utils.js.map +0 -1
  248. package/dist/storage.d.ts +0 -9
  249. package/dist/storage.d.ts.map +0 -1
  250. package/dist/storage.js +0 -8
  251. package/dist/storage.js.map +0 -1
  252. package/dist/telemetry.d.ts +0 -9
  253. package/dist/telemetry.d.ts.map +0 -1
  254. package/dist/telemetry.js +0 -8
  255. package/dist/telemetry.js.map +0 -1
  256. package/scripts/postinstall.js +0 -79
  257. package/src/shared/affiliate/migrations/001_create_affiliates.sql +0 -49
  258. package/src/shared/affiliate/migrations/002_create_affiliate_commissions.sql +0 -31
  259. package/src/shared/affiliate/migrations/003_create_affiliate_clicks.sql +0 -26
  260. package/src/shared/affiliate/migrations/004_create_affiliate_payments.sql +0 -34
  261. package/src/shared/affiliate/migrations/005_create_affiliate_tier_history.sql +0 -19
  262. package/src/shared/affiliate/migrations/006_create_affiliate_rpc_functions.sql +0 -209
  263. package/src/shared/affiliate/migrations/007_create_affiliate_rls_policies.sql +0 -123
  264. package/src/shared/billing/migrations/00000000000001_billing.sql +0 -114
  265. package/src/shared/email/migrations/000_create_email_logs.sql +0 -27
  266. package/src/shared/email/migrations/001_create_email_templates.sql +0 -27
  267. package/src/shared/email/migrations/002_add_rls_baseline_policies.sql +0 -37
  268. package/src/shared/email/migrations/003_create_gmail_accounts.sql +0 -82
  269. package/src/shared/email/migrations/004_add_email_logs_tracking_columns.sql +0 -15
  270. package/src/shared/mcp/migrations/001_mcp_api_tokens.sql +0 -21
  271. package/src/shared/mcp/migrations/002_mcp_audit_log.sql +0 -16
@@ -1,1393 +0,0 @@
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 { buildScheduleIntervals, buildAppointmentIntervals, buildVacationIntervals, unionIntervals, subtractIntervals, intervalsToSlots, splitIntoSlots, } from './intervals.js';
20
- import { createLogger } from '../../utils/logger.js';
21
- const logger = createLogger('planner:service');
22
- // ─── Time Helpers ───────────────────────────────────────────
23
- export function timeToMinutes(time) {
24
- const parts = time.split(':');
25
- return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
26
- }
27
- export function minutesToTime(minutes) {
28
- const hh = String(Math.floor(minutes / 60)).padStart(2, '0');
29
- const mm = String(minutes % 60).padStart(2, '0');
30
- return `${hh}:${mm}`;
31
- }
32
- /**
33
- * Compute bookable time slots from availability windows, minus existing appointments.
34
- *
35
- * Pure function — no DB access.
36
- *
37
- * Dedupes on start time: overlapping or adjacent availability windows
38
- * (e.g. 09:00–17:00 + 13:00–17:00 on the same day) previously produced
39
- * duplicate slots in the output, so the UI would render entries like
40
- * "16:00, 16:00". The returned list is chronologically sorted.
41
- */
42
- export function computeAvailableSlots(availabilitySlots, existingAppointments, date, slotDurationMinutes = 60) {
43
- const seenStarts = new Set();
44
- const bookableSlots = [];
45
- for (const slot of availabilitySlots) {
46
- if (!slot.is_available)
47
- continue;
48
- const slotStartMinutes = timeToMinutes(slot.start_time);
49
- const slotEndMinutes = timeToMinutes(slot.end_time);
50
- let cursor = slotStartMinutes;
51
- while (cursor + slotDurationMinutes <= slotEndMinutes) {
52
- const candidateStart = cursor;
53
- const candidateEnd = cursor + slotDurationMinutes;
54
- if (seenStarts.has(candidateStart)) {
55
- cursor += slotDurationMinutes;
56
- continue;
57
- }
58
- const isBlocked = existingAppointments.some((appt) => {
59
- const apptDate = appt.start_time.substring(0, 10);
60
- if (apptDate !== date)
61
- return false;
62
- const apptStartMinutes = timeToMinutes(appt.start_time.substring(11, 16));
63
- const apptEndMinutes = timeToMinutes(appt.end_time.substring(11, 16));
64
- return candidateStart < apptEndMinutes && candidateEnd > apptStartMinutes;
65
- });
66
- if (!isBlocked) {
67
- bookableSlots.push({
68
- startTime: minutesToTime(candidateStart),
69
- endTime: minutesToTime(candidateEnd),
70
- _start: candidateStart,
71
- });
72
- seenStarts.add(candidateStart);
73
- }
74
- cursor += slotDurationMinutes;
75
- }
76
- }
77
- return bookableSlots
78
- .sort((a, b) => a._start - b._start)
79
- .map(({ startTime, endTime }) => ({ startTime, endTime }));
80
- }
81
- // ─── Row Transformers ───────────────────────────────────────
82
- function transformAppointment(row, config) {
83
- const parentTable = config.parentResource?.table;
84
- return {
85
- id: row.id,
86
- organizationId: row.organization_id,
87
- parentId: row.parent_id,
88
- organizerId: row.organizer_id,
89
- title: row.title,
90
- description: row.description,
91
- startTime: row.start_time,
92
- endTime: row.end_time,
93
- location: row.location,
94
- type: row.type,
95
- status: row.status,
96
- attendeeIds: row.attendee_ids || [],
97
- googleCalendarEventId: row.google_calendar_event_id,
98
- outlookEventId: row.outlook_event_id,
99
- reminderSentAt: row.reminder_sent_at,
100
- createdAt: row.created_at,
101
- updatedAt: row.updated_at,
102
- organizer: row.organizer_name !== undefined
103
- ? { id: row.organizer_id, name: row.organizer_name, email: row.organizer_email }
104
- : (row.organizer ? { id: row.organizer_id, name: row.organizer?.name, email: row.organizer?.email } : null),
105
- parent: row.parent_title !== undefined
106
- ? { id: row.parent_id, title: row.parent_title }
107
- : (row.parent ? { id: row.parent_id, title: row.parent?.title } : null),
108
- };
109
- }
110
- // ─── Service Class ──────────────────────────────────────────
111
- export class PlannerService {
112
- config;
113
- constructor(config) {
114
- this.config = config;
115
- }
116
- get ownerRoles() {
117
- const r = this.config.ownerRole;
118
- return Array.isArray(r) ? r : [r];
119
- }
120
- get ownerTable() {
121
- return this.config.ownerTable || 'users_public';
122
- }
123
- get timezone() {
124
- return this.config.timezone || 'Europe/Amsterdam';
125
- }
126
- get appointmentTypes() {
127
- return this.config.appointmentTypes || ['online', 'phone', 'in_person'];
128
- }
129
- get appointmentStatuses() {
130
- return this.config.appointmentStatuses || ['scheduled', 'completed', 'cancelled'];
131
- }
132
- // ─── Availability ───────────────────────────────────────────
133
- /**
134
- * Get an owner's recurring availability slots.
135
- */
136
- async getAvailability(db, ownerId) {
137
- const { data, error } = await db
138
- .from('planner_availability_slots')
139
- .select('*')
140
- .eq('owner_id', ownerId)
141
- .order('day_of_week', { ascending: true })
142
- .order('start_time', { ascending: true });
143
- if (error) {
144
- logger.error({ error, ownerId }, 'Failed to get availability');
145
- throw new Error('Failed to retrieve availability');
146
- }
147
- return (data || []).map((row) => ({
148
- id: row.id,
149
- organizationId: row.organization_id,
150
- ownerId: row.owner_id,
151
- dayOfWeek: row.day_of_week,
152
- startTime: row.start_time,
153
- endTime: row.end_time,
154
- isAvailable: row.is_available,
155
- createdAt: row.created_at,
156
- updatedAt: row.updated_at,
157
- }));
158
- }
159
- /**
160
- * Replace all availability slots for an owner.
161
- */
162
- async setAvailability(db, ownerId, organizationId, slots) {
163
- // Validate
164
- for (let i = 0; i < slots.length; i++) {
165
- const slot = slots[i];
166
- if (slot.dayOfWeek < 0 || slot.dayOfWeek > 6) {
167
- throw new Error(`Slot ${i}: dayOfWeek must be 0-6`);
168
- }
169
- if (!slot.startTime || !slot.endTime) {
170
- throw new Error(`Slot ${i}: startTime and endTime are required`);
171
- }
172
- if (timeToMinutes(slot.startTime) >= timeToMinutes(slot.endTime)) {
173
- throw new Error(`Slot ${i}: endTime must be after startTime`);
174
- }
175
- }
176
- // Delete existing
177
- const { error: deleteError } = await db
178
- .from('planner_availability_slots')
179
- .delete()
180
- .eq('owner_id', ownerId);
181
- if (deleteError) {
182
- logger.error({ error: deleteError, ownerId }, 'Failed to clear availability');
183
- throw new Error('Failed to update availability');
184
- }
185
- if (slots.length === 0)
186
- return [];
187
- // Insert new
188
- const insertRows = slots.map((slot) => ({
189
- organization_id: organizationId,
190
- owner_id: ownerId,
191
- day_of_week: slot.dayOfWeek,
192
- start_time: slot.startTime,
193
- end_time: slot.endTime,
194
- is_available: slot.isAvailable !== undefined ? slot.isAvailable : true,
195
- }));
196
- const { data, error: insertError } = await db
197
- .from('planner_availability_slots')
198
- .insert(insertRows)
199
- .select();
200
- if (insertError) {
201
- logger.error({ error: insertError, ownerId }, 'Failed to insert availability');
202
- throw new Error('Failed to save availability');
203
- }
204
- logger.info({ ownerId, slotCount: slots.length }, 'Availability updated');
205
- return data || [];
206
- }
207
- // ─── Vacations ──────────────────────────────────────────────
208
- /**
209
- * Check if a date falls within any vacation for an owner.
210
- */
211
- async isOnVacation(db, ownerId, date) {
212
- const { data, error } = await db
213
- .from('planner_vacations')
214
- .select('id')
215
- .eq('owner_id', ownerId)
216
- .lte('start_date', date)
217
- .gte('end_date', date)
218
- .limit(1);
219
- if (error) {
220
- logger.error({ error, ownerId, date }, 'Failed to check vacation');
221
- return false;
222
- }
223
- return (data || []).length > 0;
224
- }
225
- /**
226
- * Get all vacations for an owner.
227
- */
228
- async getVacations(db, ownerId) {
229
- const { data, error } = await db
230
- .from('planner_vacations')
231
- .select('*')
232
- .eq('owner_id', ownerId)
233
- .order('start_date', { ascending: true });
234
- if (error) {
235
- logger.error({ error, ownerId }, 'Failed to get vacations');
236
- throw new Error('Failed to retrieve vacations');
237
- }
238
- return (data || []).map((row) => ({
239
- id: row.id,
240
- organizationId: row.organization_id,
241
- ownerId: row.owner_id,
242
- startDate: row.start_date,
243
- endDate: row.end_date,
244
- description: row.description,
245
- createdAt: row.created_at,
246
- updatedAt: row.updated_at,
247
- }));
248
- }
249
- /**
250
- * Create a vacation period.
251
- */
252
- async createVacation(db, ownerId, organizationId, startDate, endDate, description) {
253
- if (startDate > endDate) {
254
- throw new Error('endDate must be on or after startDate');
255
- }
256
- const { data, error } = await db
257
- .from('planner_vacations')
258
- .insert({
259
- organization_id: organizationId,
260
- owner_id: ownerId,
261
- start_date: startDate,
262
- end_date: endDate,
263
- description: description || null,
264
- })
265
- .select()
266
- .single();
267
- if (error) {
268
- logger.error({ error, ownerId }, 'Failed to create vacation');
269
- throw new Error('Failed to create vacation');
270
- }
271
- logger.info({ ownerId, startDate, endDate }, 'Vacation created');
272
- return data;
273
- }
274
- /**
275
- * Delete a vacation.
276
- */
277
- async deleteVacation(db, vacationId) {
278
- const { error } = await db
279
- .from('planner_vacations')
280
- .delete()
281
- .eq('id', vacationId);
282
- if (error) {
283
- logger.error({ error, vacationId }, 'Failed to delete vacation');
284
- throw new Error('Failed to delete vacation');
285
- }
286
- logger.info({ vacationId }, 'Vacation deleted');
287
- }
288
- // ─── Scheduler Settings ─────────────────────────────────────
289
- /**
290
- * Get scheduler settings for an owner.
291
- */
292
- async getSettings(db, ownerId) {
293
- const { data, error } = await db
294
- .from('planner_scheduler_settings')
295
- .select('*')
296
- .eq('owner_id', ownerId)
297
- .maybeSingle();
298
- if (error) {
299
- logger.error({ error, ownerId }, 'Failed to get scheduler settings');
300
- throw new Error('Failed to retrieve settings');
301
- }
302
- const defaults = this.config.defaults || {};
303
- if (!data) {
304
- return {
305
- eventName: 'Session',
306
- zoomLink: null,
307
- description: null,
308
- sessionDurationMinutes: defaults.sessionDurationMinutes ?? 60,
309
- maxDaysAhead: defaults.maxDaysAhead ?? 14,
310
- minLeadTimeHours: defaults.minLeadTimeHours ?? 24,
311
- timeIncrementMinutes: defaults.timeIncrementMinutes ?? 15,
312
- bufferBeforeMinutes: 0,
313
- bufferAfterMinutes: 0,
314
- maxSessionsPerDay: null,
315
- maxSessionsPerWeek: null,
316
- };
317
- }
318
- return {
319
- eventName: data.event_name ?? 'Session',
320
- zoomLink: data.zoom_link ?? null,
321
- description: data.description ?? null,
322
- sessionDurationMinutes: data.session_duration_minutes ?? defaults.sessionDurationMinutes ?? 60,
323
- maxDaysAhead: data.max_days_ahead ?? defaults.maxDaysAhead ?? 14,
324
- minLeadTimeHours: data.min_lead_time_hours ?? defaults.minLeadTimeHours ?? 24,
325
- timeIncrementMinutes: data.time_increment_minutes ?? defaults.timeIncrementMinutes ?? 15,
326
- bufferBeforeMinutes: data.buffer_before_minutes ?? 0,
327
- bufferAfterMinutes: data.buffer_after_minutes ?? 0,
328
- maxSessionsPerDay: data.max_sessions_per_day ?? null,
329
- maxSessionsPerWeek: data.max_sessions_per_week ?? null,
330
- };
331
- }
332
- /**
333
- * Upsert scheduler settings for an owner.
334
- */
335
- async updateSettings(db, ownerId, organizationId, settings) {
336
- const row = {
337
- owner_id: ownerId,
338
- organization_id: organizationId,
339
- updated_at: new Date().toISOString(),
340
- };
341
- if (settings.eventName !== undefined)
342
- row.event_name = settings.eventName;
343
- if (settings.zoomLink !== undefined)
344
- row.zoom_link = settings.zoomLink;
345
- if (settings.description !== undefined)
346
- row.description = settings.description;
347
- if (settings.sessionDurationMinutes !== undefined)
348
- row.session_duration_minutes = settings.sessionDurationMinutes;
349
- if (settings.maxDaysAhead !== undefined)
350
- row.max_days_ahead = settings.maxDaysAhead;
351
- if (settings.minLeadTimeHours !== undefined)
352
- row.min_lead_time_hours = settings.minLeadTimeHours;
353
- if (settings.timeIncrementMinutes !== undefined)
354
- row.time_increment_minutes = settings.timeIncrementMinutes;
355
- if (settings.bufferBeforeMinutes !== undefined)
356
- row.buffer_before_minutes = settings.bufferBeforeMinutes;
357
- if (settings.bufferAfterMinutes !== undefined)
358
- row.buffer_after_minutes = settings.bufferAfterMinutes;
359
- if (settings.maxSessionsPerDay !== undefined)
360
- row.max_sessions_per_day = settings.maxSessionsPerDay;
361
- if (settings.maxSessionsPerWeek !== undefined)
362
- row.max_sessions_per_week = settings.maxSessionsPerWeek;
363
- const { data, error } = await db
364
- .from('planner_scheduler_settings')
365
- .upsert(row, { onConflict: 'owner_id' })
366
- .select()
367
- .single();
368
- if (error) {
369
- logger.error({ error, ownerId }, 'Failed to update scheduler settings');
370
- throw new Error('Failed to save settings');
371
- }
372
- logger.info({ ownerId }, 'Scheduler settings updated');
373
- return data;
374
- }
375
- // ─── Available Slots ────────────────────────────────────────
376
- /**
377
- * Compute available booking slots for a specific date.
378
- * Checks: availability, vacations, existing appointments, booking window, lead time.
379
- */
380
- async getAvailableSlots(db, ownerId, date) {
381
- const parsedDate = new Date(date + 'T00:00:00Z');
382
- if (isNaN(parsedDate.getTime())) {
383
- throw new Error('Invalid date');
384
- }
385
- const dayOfWeek = parsedDate.getUTCDay();
386
- // 1. Check vacation
387
- const onVacation = await this.isOnVacation(db, ownerId, date);
388
- const settings = await this.getSettings(db, ownerId);
389
- if (onVacation) {
390
- return { date, dayOfWeek, ownerId, slots: [], sessionDurationMinutes: settings.sessionDurationMinutes };
391
- }
392
- // 2. Check booking window
393
- const now = new Date();
394
- const requestedDate = new Date(date + 'T23:59:59Z');
395
- const maxDate = new Date(now);
396
- maxDate.setDate(maxDate.getDate() + settings.maxDaysAhead);
397
- if (requestedDate > maxDate) {
398
- return { date, dayOfWeek, ownerId, slots: [], sessionDurationMinutes: settings.sessionDurationMinutes };
399
- }
400
- // 3. Get availability for this day of week
401
- const { data: availabilitySlots, error: availError } = await db
402
- .from('planner_availability_slots')
403
- .select('*')
404
- .eq('owner_id', ownerId)
405
- .eq('day_of_week', dayOfWeek)
406
- .eq('is_available', true)
407
- .order('start_time', { ascending: true });
408
- if (availError || !availabilitySlots?.length) {
409
- return { date, dayOfWeek, ownerId, slots: [], sessionDurationMinutes: settings.sessionDurationMinutes };
410
- }
411
- // 4. Get existing appointments for this date
412
- const existingAppointments = await this.getExistingAppointments(db, ownerId, date);
413
- // 4b. Get external busy slots (e.g. Google Calendar) — bidirectional sync
414
- if (this.config.getExternalBusySlots) {
415
- try {
416
- const externalBusy = await this.config.getExternalBusySlots(ownerId, date);
417
- if (externalBusy.length > 0) {
418
- const existingTimes = new Set(existingAppointments.map((a) => `${a.start_time}|${a.end_time}`));
419
- for (const busy of externalBusy) {
420
- const key = `${busy.start_time}|${busy.end_time}`;
421
- if (!existingTimes.has(key)) {
422
- existingAppointments.push(busy);
423
- existingTimes.add(key);
424
- }
425
- }
426
- logger.debug({ ownerId, date, externalCount: externalBusy.length }, 'External busy slots merged');
427
- }
428
- }
429
- catch (err) {
430
- logger.warn({ error: err, ownerId, date }, 'Failed to fetch external busy slots (continuing without)');
431
- }
432
- }
433
- // 5. Compute slots
434
- const allSlots = computeAvailableSlots(availabilitySlots, existingAppointments, date, settings.sessionDurationMinutes);
435
- // 6. Filter by minimum lead time
436
- const minBookingTime = new Date(now.getTime() + settings.minLeadTimeHours * 60 * 60 * 1000);
437
- const slots = allSlots.filter((slot) => {
438
- const slotStart = new Date(`${date}T${slot.startTime}:00Z`);
439
- return slotStart >= minBookingTime;
440
- });
441
- return { date, dayOfWeek, ownerId, slots, sessionDurationMinutes: settings.sessionDurationMinutes };
442
- }
443
- /**
444
- * Get existing scheduled appointments for an owner on a date.
445
- */
446
- async getExistingAppointments(db, ownerId, date) {
447
- const dayStart = `${date}T00:00:00Z`;
448
- const dayEnd = `${date}T23:59:59Z`;
449
- const appointments = [];
450
- // Appointments where owner is organizer
451
- const { data: organizerAppts } = await db
452
- .from('planner_appointments')
453
- .select('start_time, end_time')
454
- .eq('organizer_id', ownerId)
455
- .eq('status', 'scheduled')
456
- .gte('start_time', dayStart)
457
- .lte('start_time', dayEnd);
458
- if (organizerAppts) {
459
- appointments.push(...organizerAppts);
460
- }
461
- // If parent resource is configured, also check appointments through parent records
462
- if (this.config.parentResource) {
463
- const { table, foreignKey } = this.config.parentResource;
464
- const { data: ownerParents } = await db
465
- .from(table)
466
- .select('id')
467
- .or(`${foreignKey}.eq.${ownerId}`);
468
- const parentIds = (ownerParents || []).map((p) => p.id);
469
- if (parentIds.length > 0) {
470
- const { data: parentAppts } = await db
471
- .from('planner_appointments')
472
- .select('start_time, end_time')
473
- .in('parent_id', parentIds)
474
- .eq('status', 'scheduled')
475
- .gte('start_time', dayStart)
476
- .lte('start_time', dayEnd);
477
- if (parentAppts) {
478
- // Deduplicate
479
- const existingTimes = new Set(appointments.map((a) => `${a.start_time}|${a.end_time}`));
480
- for (const appt of parentAppts) {
481
- const key = `${appt.start_time}|${appt.end_time}`;
482
- if (!existingTimes.has(key)) {
483
- appointments.push(appt);
484
- existingTimes.add(key);
485
- }
486
- }
487
- }
488
- }
489
- }
490
- return appointments;
491
- }
492
- // ─── Team Availability ──────────────────────────────────────
493
- /**
494
- * Get availability for all owners in an organization for a specific date.
495
- */
496
- async getTeamAvailability(db, organizationId, date) {
497
- const parsedDate = new Date(date + 'T00:00:00Z');
498
- if (isNaN(parsedDate.getTime())) {
499
- throw new Error('Invalid date');
500
- }
501
- const dayOfWeek = parsedDate.getUTCDay();
502
- const ownerTable = this.ownerTable;
503
- const orgColumn = this.config.ownerOrgColumn || 'active_organization_id';
504
- // Get all owners (coaches/consultants) in this organization
505
- const roles = this.ownerRoles;
506
- let ownerQuery = db
507
- .from(ownerTable)
508
- .select('id, name, avatar_url')
509
- .eq(orgColumn, organizationId);
510
- if (roles.length === 1) {
511
- ownerQuery = ownerQuery.eq('role', roles[0]);
512
- }
513
- else {
514
- ownerQuery = ownerQuery.in('role', roles);
515
- }
516
- const { data: owners, error: ownersError } = await ownerQuery;
517
- if (ownersError || !owners?.length) {
518
- return { date, dayOfWeek, owners: [] };
519
- }
520
- // For each owner, compute their availability
521
- const ownerResults = await Promise.all(owners.map(async (owner) => {
522
- try {
523
- const dayAvailability = await this.getAvailableSlots(db, owner.id, date);
524
- const onVacation = await this.isOnVacation(db, owner.id, date);
525
- return {
526
- id: owner.id,
527
- name: owner.name,
528
- avatarUrl: owner.avatar_url,
529
- slots: dayAvailability.slots,
530
- vacationDay: onVacation,
531
- };
532
- }
533
- catch {
534
- return {
535
- id: owner.id,
536
- name: owner.name,
537
- avatarUrl: owner.avatar_url,
538
- slots: [],
539
- vacationDay: false,
540
- };
541
- }
542
- }));
543
- return { date, dayOfWeek, owners: ownerResults };
544
- }
545
- // ─── Appointments ───────────────────────────────────────────
546
- /**
547
- * List all appointments in the organization (for team/admin views).
548
- */
549
- async listTeamAppointments(db, organizationId, options) {
550
- let query = db
551
- .from('planner_appointments')
552
- .select('*')
553
- .eq('organization_id', organizationId)
554
- .order('start_time', { ascending: true });
555
- if (options?.status === 'all') {
556
- // No filter
557
- }
558
- else if (options?.status && this.appointmentStatuses.includes(options.status)) {
559
- query = query.eq('status', options.status);
560
- }
561
- else {
562
- query = query.neq('status', 'cancelled');
563
- }
564
- if (options?.dateFrom) {
565
- query = query.gte('start_time', `${options.dateFrom}T00:00:00Z`);
566
- }
567
- if (options?.dateTo) {
568
- query = query.lte('start_time', `${options.dateTo}T23:59:59Z`);
569
- }
570
- const { data, error } = await query;
571
- if (error) {
572
- logger.error({ error, organizationId }, 'Failed to list team appointments');
573
- throw new Error('Failed to retrieve team appointments');
574
- }
575
- return (data || []).map((row) => transformAppointment(row, this.config));
576
- }
577
- /**
578
- * List all appointments for a user (as organizer or via parent resources).
579
- */
580
- async listAppointments(db, userId, options) {
581
- let query = db
582
- .from('planner_appointments')
583
- .select('*')
584
- .order('start_time', { ascending: true });
585
- // Build OR filter: organizer OR involved via parent resource
586
- if (this.config.parentResource) {
587
- const { table, foreignKey, clientKey } = this.config.parentResource;
588
- // Find parent IDs where user is owner or client
589
- const orParts = [`${foreignKey}.eq.${userId}`];
590
- if (clientKey)
591
- orParts.push(`${clientKey}.eq.${userId}`);
592
- const { data: userParents } = await db
593
- .from(table)
594
- .select('id')
595
- .or(orParts.join(','));
596
- const parentIds = (userParents || []).map((p) => p.id);
597
- if (parentIds.length > 0) {
598
- query = query.or(`organizer_id.eq.${userId},parent_id.in.(${parentIds.join(',')})`);
599
- }
600
- else {
601
- query = query.eq('organizer_id', userId);
602
- }
603
- }
604
- else {
605
- query = query.eq('organizer_id', userId);
606
- }
607
- if (options?.status === 'all') {
608
- // No filter
609
- }
610
- else if (options?.status && this.appointmentStatuses.includes(options.status)) {
611
- query = query.eq('status', options.status);
612
- }
613
- else {
614
- query = query.neq('status', 'cancelled');
615
- }
616
- const { data, error } = await query;
617
- if (error) {
618
- logger.error({ error, userId }, 'Failed to list appointments');
619
- throw new Error('Failed to retrieve appointments');
620
- }
621
- return (data || []).map((row) => transformAppointment(row, this.config));
622
- }
623
- /**
624
- * Create an appointment.
625
- */
626
- async createAppointment(db, organizerId, organizationId, input) {
627
- const start = new Date(input.startTime);
628
- const end = new Date(input.endTime);
629
- if (isNaN(start.getTime()) || isNaN(end.getTime())) {
630
- throw new Error('Invalid date format for startTime or endTime');
631
- }
632
- if (end <= start) {
633
- throw new Error('endTime must be after startTime');
634
- }
635
- const appointmentType = input.type || 'online';
636
- if (!this.appointmentTypes.includes(appointmentType)) {
637
- throw new Error(`Invalid type. Must be one of: ${this.appointmentTypes.join(', ')}`);
638
- }
639
- const insertData = {
640
- organization_id: organizationId,
641
- organizer_id: organizerId,
642
- title: input.title,
643
- start_time: start.toISOString(),
644
- end_time: end.toISOString(),
645
- type: appointmentType,
646
- status: 'scheduled',
647
- };
648
- if (input.parentId)
649
- insertData.parent_id = input.parentId;
650
- if (input.description !== undefined)
651
- insertData.description = input.description;
652
- if (input.location !== undefined)
653
- insertData.location = input.location;
654
- if (Array.isArray(input.attendeeIds))
655
- insertData.attendee_ids = input.attendeeIds;
656
- const { data, error } = await db
657
- .from('planner_appointments')
658
- .insert(insertData)
659
- .select()
660
- .single();
661
- if (error) {
662
- logger.error({ error, organizerId }, 'Failed to create appointment');
663
- if (error.code === '23503') {
664
- throw new Error('Invalid parent resource ID');
665
- }
666
- throw new Error('Failed to create appointment');
667
- }
668
- logger.info({ appointmentId: data.id, organizerId }, 'Appointment created');
669
- const appointment = transformAppointment(data, this.config);
670
- // Fire callback (non-blocking)
671
- if (this.config.onAppointmentCreated) {
672
- const ctx = { ownerId: organizerId, organizationId };
673
- this.config.onAppointmentCreated(appointment, ctx).catch((err) => {
674
- logger.error({ error: err, appointmentId: data.id }, 'onAppointmentCreated callback failed');
675
- });
676
- }
677
- return appointment;
678
- }
679
- /**
680
- * Update an appointment.
681
- */
682
- async updateAppointment(db, appointmentId, updates) {
683
- const updateData = {
684
- updated_at: new Date().toISOString(),
685
- };
686
- if (updates.title !== undefined)
687
- updateData.title = updates.title;
688
- if (updates.description !== undefined)
689
- updateData.description = updates.description;
690
- if (updates.location !== undefined)
691
- updateData.location = updates.location;
692
- if (updates.startTime !== undefined) {
693
- const start = new Date(updates.startTime);
694
- if (isNaN(start.getTime()))
695
- throw new Error('Invalid startTime');
696
- updateData.start_time = start.toISOString();
697
- }
698
- if (updates.endTime !== undefined) {
699
- const end = new Date(updates.endTime);
700
- if (isNaN(end.getTime()))
701
- throw new Error('Invalid endTime');
702
- updateData.end_time = end.toISOString();
703
- }
704
- if (updates.type !== undefined) {
705
- if (!this.appointmentTypes.includes(updates.type)) {
706
- throw new Error(`Invalid type. Must be one of: ${this.appointmentTypes.join(', ')}`);
707
- }
708
- updateData.type = updates.type;
709
- }
710
- if (updateData.start_time && updateData.end_time) {
711
- if (new Date(updateData.end_time) <= new Date(updateData.start_time)) {
712
- throw new Error('endTime must be after startTime');
713
- }
714
- }
715
- const { data, error } = await db
716
- .from('planner_appointments')
717
- .update(updateData)
718
- .eq('id', appointmentId)
719
- .select()
720
- .single();
721
- if (error) {
722
- if (error.code === 'PGRST116')
723
- throw new Error('Appointment not found');
724
- logger.error({ error, appointmentId }, 'Failed to update appointment');
725
- throw new Error('Failed to update appointment');
726
- }
727
- logger.info({ appointmentId }, 'Appointment updated');
728
- return transformAppointment(data, this.config);
729
- }
730
- /**
731
- * Cancel an appointment (soft delete).
732
- */
733
- async cancelAppointment(db, appointmentId, organizationId) {
734
- const { data, error } = await db
735
- .from('planner_appointments')
736
- .update({ status: 'cancelled', updated_at: new Date().toISOString() })
737
- .eq('id', appointmentId)
738
- .neq('status', 'cancelled')
739
- .select()
740
- .single();
741
- if (error) {
742
- if (error.code === 'PGRST116')
743
- throw new Error('Appointment not found');
744
- logger.error({ error, appointmentId }, 'Failed to cancel appointment');
745
- throw new Error('Failed to cancel appointment');
746
- }
747
- logger.info({ appointmentId }, 'Appointment cancelled');
748
- const appointment = transformAppointment(data, this.config);
749
- if (this.config.onAppointmentCancelled) {
750
- const ctx = { ownerId: data.organizer_id, organizationId };
751
- this.config.onAppointmentCancelled(appointment, ctx).catch((err) => {
752
- logger.error({ error: err, appointmentId }, 'onAppointmentCancelled callback failed');
753
- });
754
- }
755
- return appointment;
756
- }
757
- /**
758
- * Complete an appointment.
759
- */
760
- async completeAppointment(db, appointmentId, organizationId) {
761
- const { data, error } = await db
762
- .from('planner_appointments')
763
- .update({ status: 'completed', updated_at: new Date().toISOString() })
764
- .eq('id', appointmentId)
765
- .eq('status', 'scheduled')
766
- .select()
767
- .single();
768
- if (error) {
769
- if (error.code === 'PGRST116')
770
- throw new Error('Appointment not found');
771
- logger.error({ error, appointmentId }, 'Failed to complete appointment');
772
- throw new Error('Failed to complete appointment');
773
- }
774
- logger.info({ appointmentId }, 'Appointment completed');
775
- const appointment = transformAppointment(data, this.config);
776
- if (this.config.onAppointmentCompleted) {
777
- const ctx = { ownerId: data.organizer_id, organizationId };
778
- this.config.onAppointmentCompleted(appointment, ctx).catch((err) => {
779
- logger.error({ error: err, appointmentId }, 'onAppointmentCompleted callback failed');
780
- });
781
- }
782
- return appointment;
783
- }
784
- // ─── Public Booking ─────────────────────────────────────────
785
- /**
786
- * Get public scheduler page data (no auth).
787
- */
788
- async getPublicSchedulerData(db, ownerId) {
789
- const ownerTable = this.ownerTable;
790
- const orgColumn = this.config.ownerOrgColumn || 'active_organization_id';
791
- const { data: ownerRow, error: ownerError } = await db
792
- .from(ownerTable)
793
- .select('*')
794
- .eq('id', ownerId)
795
- .single();
796
- if (ownerError || !ownerRow)
797
- return null;
798
- const owner = ownerRow;
799
- const orgId = owner[orgColumn];
800
- const { data: org } = await db
801
- .from('organizations')
802
- .select('name')
803
- .eq('id', orgId)
804
- .single();
805
- const settings = await this.getSettings(db, ownerId);
806
- const { data: availability } = await db
807
- .from('planner_availability_slots')
808
- .select('day_of_week')
809
- .eq('owner_id', ownerId)
810
- .eq('is_available', true);
811
- const availableDays = [...new Set((availability || []).map((s) => s.day_of_week))];
812
- return {
813
- owner: { id: owner.id, name: owner.name, avatarUrl: owner.avatar_url },
814
- organization: { name: org?.name ?? null },
815
- settings,
816
- availableDays,
817
- };
818
- }
819
- /**
820
- * Book a slot via public booking (no auth).
821
- */
822
- async publicBook(db, ownerId, input) {
823
- // Validate
824
- if (!input.name || !input.email || !input.date || !input.startTime || !input.endTime) {
825
- throw new Error('name, email, date, startTime, and endTime are required');
826
- }
827
- const parsedDate = new Date(input.date + 'T00:00:00Z');
828
- if (isNaN(parsedDate.getTime()))
829
- throw new Error('Invalid date');
830
- if (timeToMinutes(input.startTime) >= timeToMinutes(input.endTime)) {
831
- throw new Error('endTime must be after startTime');
832
- }
833
- // Check vacation
834
- const onVacation = await this.isOnVacation(db, ownerId, input.date);
835
- if (onVacation) {
836
- throw new Error('Owner is on vacation on this date');
837
- }
838
- // Check availability
839
- const dayOfWeek = parsedDate.getUTCDay();
840
- const { data: availSlots } = await db
841
- .from('planner_availability_slots')
842
- .select('*')
843
- .eq('owner_id', ownerId)
844
- .eq('day_of_week', dayOfWeek)
845
- .eq('is_available', true);
846
- const requestedStart = timeToMinutes(input.startTime);
847
- const requestedEnd = timeToMinutes(input.endTime);
848
- const isWithin = (availSlots || []).some((slot) => {
849
- const slotStart = timeToMinutes(slot.start_time);
850
- const slotEnd = timeToMinutes(slot.end_time);
851
- return requestedStart >= slotStart && requestedEnd <= slotEnd;
852
- });
853
- if (!isWithin) {
854
- throw new Error('Requested time is outside availability');
855
- }
856
- // Check conflicts
857
- const existingAppts = await this.getExistingAppointments(db, ownerId, input.date);
858
- const hasConflict = existingAppts.some((appt) => {
859
- const apptStart = timeToMinutes(appt.start_time.substring(11, 16));
860
- const apptEnd = timeToMinutes(appt.end_time.substring(11, 16));
861
- return requestedStart < apptEnd && requestedEnd > apptStart;
862
- });
863
- if (hasConflict) {
864
- throw new Error('Time slot is no longer available');
865
- }
866
- // Get owner info
867
- const ownerTable = this.ownerTable;
868
- const orgColumn = this.config.ownerOrgColumn || 'active_organization_id';
869
- const { data: ownerData } = await db
870
- .from(ownerTable)
871
- .select(`id, name, ${orgColumn}`)
872
- .eq('id', ownerId)
873
- .single();
874
- if (!ownerData)
875
- throw new Error('Owner not found');
876
- const organizationId = ownerData[orgColumn];
877
- const settings = await this.getSettings(db, ownerId);
878
- const zoomLink = settings.zoomLink;
879
- // Auto-link to parent resource if booker email matches
880
- let parentId = null;
881
- if (this.config.parentResource) {
882
- const { table, foreignKey, clientKey } = this.config.parentResource;
883
- if (clientKey) {
884
- const { data: bookerUser } = await db
885
- .from(ownerTable)
886
- .select('id')
887
- .eq('email', input.email.toLowerCase().trim())
888
- .eq(orgColumn, organizationId)
889
- .maybeSingle();
890
- if (bookerUser) {
891
- const { data: matchingParent } = await db
892
- .from(table)
893
- .select('id')
894
- .or(`${foreignKey}.eq.${ownerId}`)
895
- .eq(clientKey, bookerUser.id)
896
- .eq('status', 'active')
897
- .limit(1)
898
- .maybeSingle();
899
- if (matchingParent) {
900
- parentId = matchingParent.id;
901
- logger.info({ parentId, bookerEmail: input.email, ownerId }, 'Auto-linked public booking to parent');
902
- }
903
- }
904
- }
905
- }
906
- const startISO = `${input.date}T${input.startTime.length === 5 ? input.startTime + ':00' : input.startTime}Z`;
907
- const endISO = `${input.date}T${input.endTime.length === 5 ? input.endTime + ':00' : input.endTime}Z`;
908
- const { data: appointment, error: insertError } = await db
909
- .from('planner_appointments')
910
- .insert({
911
- organization_id: organizationId,
912
- parent_id: parentId,
913
- organizer_id: ownerId,
914
- title: `${settings.eventName} - ${input.name}`,
915
- description: [
916
- 'Booked via public scheduler',
917
- `Name: ${input.name}`,
918
- `Email: ${input.email}`,
919
- input.notes ? `Notes: ${input.notes}` : null,
920
- zoomLink ? `Zoom: ${zoomLink}` : null,
921
- ].filter(Boolean).join('\n'),
922
- start_time: startISO,
923
- end_time: endISO,
924
- type: 'online',
925
- status: 'scheduled',
926
- })
927
- .select()
928
- .single();
929
- if (insertError) {
930
- logger.error({ error: insertError, ownerId, name: input.name }, 'Failed to create public booking');
931
- throw new Error('Failed to create appointment');
932
- }
933
- logger.info({
934
- appointmentId: appointment.id,
935
- ownerId,
936
- visitorName: input.name,
937
- visitorEmail: input.email,
938
- }, 'Public booking created');
939
- const record = transformAppointment(appointment, this.config);
940
- // Fire callback
941
- if (this.config.onPublicBooking) {
942
- const ctx = { ownerId, organizationId };
943
- const visitor = { name: input.name, email: input.email, notes: input.notes };
944
- this.config.onPublicBooking(record, visitor, ctx).catch((err) => {
945
- logger.error({ error: err, appointmentId: appointment.id }, 'onPublicBooking callback failed');
946
- });
947
- }
948
- return record;
949
- }
950
- // ─── Computed Availability (Phase 2) ─────────────────────────
951
- /**
952
- * Compute truly available booking windows for an owner across a date range.
953
- *
954
- * Formula:
955
- * available = scheduleWindows
956
- * − googleCalendarBusy (all selected calendars)
957
- * − internalAppointments ± buffer
958
- * − activeReservations
959
- * − vacations
960
- * − minLeadTimeWindow (now → now + minLeadTimeHours)
961
- * − futureLimit (now + maxDaysAhead → ∞)
962
- *
963
- * Returns continuous free windows AND discrete bookable slots.
964
- */
965
- async getComputedAvailability(db, ownerId, dateFrom, dateTo) {
966
- if (!/^\d{4}-\d{2}-\d{2}$/.test(dateFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) {
967
- throw new Error('dateFrom and dateTo must be YYYY-MM-DD');
968
- }
969
- if (dateFrom > dateTo) {
970
- throw new Error('dateFrom must be on or before dateTo');
971
- }
972
- const settings = await this.getSettings(db, ownerId);
973
- // 1. Schedule windows from recurring availability slots
974
- const { data: rawSlots } = await db
975
- .from('planner_availability_slots')
976
- .select('day_of_week, start_time, end_time, is_available')
977
- .eq('owner_id', ownerId)
978
- .eq('is_available', true);
979
- const scheduleWindows = buildScheduleIntervals((rawSlots || []).map((s) => ({
980
- dayOfWeek: s.day_of_week,
981
- startTime: typeof s.start_time === 'string' ? s.start_time.substring(0, 5) : s.start_time,
982
- endTime: typeof s.end_time === 'string' ? s.end_time.substring(0, 5) : s.end_time,
983
- isAvailable: s.is_available,
984
- })), dateFrom, dateTo);
985
- // 2. Internal appointments (with buffer)
986
- const dayStart = `${dateFrom}T00:00:00Z`;
987
- const dayEnd = `${dateTo}T23:59:59Z`;
988
- const { data: internalAppts } = await db
989
- .from('planner_appointments')
990
- .select('start_time, end_time')
991
- .eq('organizer_id', ownerId)
992
- .eq('status', 'scheduled')
993
- .gte('start_time', dayStart)
994
- .lte('start_time', dayEnd);
995
- const bufferBeforeMs = (settings.bufferBeforeMinutes || 0) * 60 * 1000;
996
- const bufferAfterMs = (settings.bufferAfterMinutes || 0) * 60 * 1000;
997
- const appointmentBusy = buildAppointmentIntervals(internalAppts || [], bufferBeforeMs, bufferAfterMs);
998
- // 3. Active slot reservations (anti-double-book)
999
- const nowIso = new Date().toISOString();
1000
- const ownerCol = await this.detectReservedSlotsOwnerColumn(db);
1001
- const { data: reservations } = await db
1002
- .from('planner_reserved_slots')
1003
- .select('slot_start, slot_end, release_at')
1004
- .eq(ownerCol, ownerId)
1005
- .gte('slot_end', dayStart)
1006
- .lte('slot_start', dayEnd)
1007
- .gt('release_at', nowIso);
1008
- const reservationBusy = (reservations || []).map((r) => ({
1009
- start: new Date(r.slot_start).getTime(),
1010
- end: new Date(r.slot_end).getTime(),
1011
- }));
1012
- // 4. Vacations
1013
- const { data: vacations } = await db
1014
- .from('planner_vacations')
1015
- .select('start_date, end_date')
1016
- .eq('owner_id', ownerId)
1017
- .lte('start_date', dateTo)
1018
- .gte('end_date', dateFrom);
1019
- const vacationBusy = buildVacationIntervals(vacations || [], dateFrom, dateTo);
1020
- // 5. External (Google Calendar) busy slots — pass full range
1021
- let externalBusy = [];
1022
- if (this.config.getExternalBusySlots) {
1023
- try {
1024
- const slots = await this.config.getExternalBusySlots(ownerId, dateFrom, dateTo);
1025
- externalBusy = (slots || []).map((s) => ({
1026
- start: new Date(s.start_time).getTime(),
1027
- end: new Date(s.end_time).getTime(),
1028
- }));
1029
- }
1030
- catch (err) {
1031
- logger.warn({ error: err, ownerId, dateFrom, dateTo }, 'Failed to fetch external busy (continuing)');
1032
- }
1033
- }
1034
- // 6. Min lead time + future booking horizon — clip the schedule
1035
- const now = Date.now();
1036
- const minBookingMs = now + (settings.minLeadTimeHours || 0) * 60 * 60 * 1000;
1037
- const maxBookingDate = new Date(now + (settings.maxDaysAhead || 14) * 24 * 60 * 60 * 1000);
1038
- const maxBookingMs = maxBookingDate.getTime();
1039
- const clippedSchedule = scheduleWindows
1040
- .map((w) => ({
1041
- start: Math.max(w.start, minBookingMs),
1042
- end: Math.min(w.end, maxBookingMs),
1043
- }))
1044
- .filter((w) => w.end > w.start);
1045
- // 7. Subtract everything
1046
- const allBusy = unionIntervals([...appointmentBusy, ...reservationBusy, ...vacationBusy, ...externalBusy]);
1047
- const freeWindows = subtractIntervals(unionIntervals(clippedSchedule), allBusy);
1048
- // 8. Discrete bookable slots
1049
- const sessionMs = (settings.sessionDurationMinutes || 60) * 60 * 1000;
1050
- const incrementMs = (settings.timeIncrementMinutes || 15) * 60 * 1000;
1051
- const slotIntervals = splitIntoSlots(freeWindows, sessionMs, incrementMs);
1052
- // 9. Apply max sessions per day / week limits
1053
- let limitedSlots = slotIntervals;
1054
- if (settings.maxSessionsPerDay || settings.maxSessionsPerWeek) {
1055
- const dayCount = new Map();
1056
- const weekCount = new Map();
1057
- // Count existing appointments per day/week to know baseline
1058
- for (const a of internalAppts || []) {
1059
- const d = a.start_time.substring(0, 10);
1060
- dayCount.set(d, (dayCount.get(d) || 0) + 1);
1061
- const wk = isoWeekKey(new Date(a.start_time));
1062
- weekCount.set(wk, (weekCount.get(wk) || 0) + 1);
1063
- }
1064
- const dayLimit = settings.maxSessionsPerDay ?? Infinity;
1065
- const weekLimit = settings.maxSessionsPerWeek ?? Infinity;
1066
- limitedSlots = slotIntervals.filter((slot) => {
1067
- const slotDate = new Date(slot.start);
1068
- const dayKey = slotDate.toISOString().substring(0, 10);
1069
- const weekKey = isoWeekKey(slotDate);
1070
- const dCount = dayCount.get(dayKey) || 0;
1071
- const wCount = weekCount.get(weekKey) || 0;
1072
- if (dCount >= dayLimit)
1073
- return false;
1074
- if (wCount >= weekLimit)
1075
- return false;
1076
- // Reserve a slot in the count map so subsequent slots in same day/week
1077
- // also count toward the limit even though they're not booked yet —
1078
- // ensures we never SHOW more slots than the user can book.
1079
- return true;
1080
- });
1081
- }
1082
- return {
1083
- ownerId,
1084
- dateFrom,
1085
- dateTo,
1086
- windows: intervalsToSlots(freeWindows),
1087
- slots: intervalsToSlots(limitedSlots),
1088
- busySources: {
1089
- appointments: appointmentBusy.length,
1090
- externalCalendars: externalBusy.length,
1091
- reservations: reservationBusy.length,
1092
- vacations: vacationBusy.length,
1093
- },
1094
- };
1095
- }
1096
- // ─── Slot Reservations (Phase 3) ─────────────────────────────
1097
- /**
1098
- * Temporarily reserve a slot for `ttlSeconds` seconds.
1099
- * Throws if another active reservation already covers the slot.
1100
- */
1101
- async reserveSlot(db, ownerId, organizationId, slotStart, slotEnd, ttlSeconds = 300) {
1102
- if (new Date(slotStart) >= new Date(slotEnd)) {
1103
- throw new Error('slotEnd must be after slotStart');
1104
- }
1105
- const releaseAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
1106
- const bookingUid = `${ownerId}-${slotStart}-${Math.random().toString(36).slice(2, 10)}`;
1107
- // Detect column name once: legacy schemas use coach_id, new use owner_id
1108
- const ownerColumn = await this.detectReservedSlotsOwnerColumn(db);
1109
- // First clean any expired reservations on this exact slot to allow re-claim
1110
- await db
1111
- .from('planner_reserved_slots')
1112
- .delete()
1113
- .eq(ownerColumn, ownerId)
1114
- .eq('slot_start', slotStart)
1115
- .eq('slot_end', slotEnd)
1116
- .lt('release_at', new Date().toISOString());
1117
- const insertRow = {
1118
- organization_id: organizationId,
1119
- slot_start: slotStart,
1120
- slot_end: slotEnd,
1121
- booking_uid: bookingUid,
1122
- release_at: releaseAt,
1123
- };
1124
- insertRow[ownerColumn] = ownerId;
1125
- const { data, error } = await db
1126
- .from('planner_reserved_slots')
1127
- .insert(insertRow)
1128
- .select()
1129
- .single();
1130
- if (error) {
1131
- if (error.code === '23505') {
1132
- throw new Error('Slot is already reserved');
1133
- }
1134
- logger.error({ error, ownerId, slotStart }, 'Failed to reserve slot');
1135
- throw new Error('Failed to reserve slot');
1136
- }
1137
- return {
1138
- id: data.id,
1139
- organizationId: data.organization_id,
1140
- ownerId: data.owner_id ?? data.coach_id,
1141
- slotStart: data.slot_start,
1142
- slotEnd: data.slot_end,
1143
- bookingUid: data.booking_uid,
1144
- releaseAt: data.release_at,
1145
- createdAt: data.created_at,
1146
- };
1147
- }
1148
- _reservedSlotsOwnerColumn = null;
1149
- async detectReservedSlotsOwnerColumn(db) {
1150
- if (this._reservedSlotsOwnerColumn)
1151
- return this._reservedSlotsOwnerColumn;
1152
- // Try owner_id first
1153
- const tryOwner = await db.from('planner_reserved_slots').select('owner_id').limit(0);
1154
- if (!tryOwner.error) {
1155
- this._reservedSlotsOwnerColumn = 'owner_id';
1156
- return 'owner_id';
1157
- }
1158
- // Fallback to coach_id (legacy CoachHub schema)
1159
- this._reservedSlotsOwnerColumn = 'coach_id';
1160
- return 'coach_id';
1161
- }
1162
- async releaseSlot(db, reservationId) {
1163
- const { error } = await db.from('planner_reserved_slots').delete().eq('id', reservationId);
1164
- if (error) {
1165
- logger.error({ error, reservationId }, 'Failed to release slot');
1166
- throw new Error('Failed to release slot');
1167
- }
1168
- }
1169
- /**
1170
- * Delete reservations whose release_at is in the past. Run via cron.
1171
- * Returns number of cleaned reservations.
1172
- */
1173
- async cleanupExpiredReservations(db) {
1174
- const { data, error } = await db
1175
- .from('planner_reserved_slots')
1176
- .delete()
1177
- .lt('release_at', new Date().toISOString())
1178
- .select('id');
1179
- if (error) {
1180
- logger.error({ error }, 'Cleanup expired reservations failed');
1181
- return 0;
1182
- }
1183
- const count = data?.length || 0;
1184
- if (count > 0)
1185
- logger.info({ count }, 'Cleaned expired reservations');
1186
- return count;
1187
- }
1188
- // ─── Coach Auto-Assignment (Phase 3) ─────────────────────────
1189
- /**
1190
- * Get all coaches in an organization with their assignment configuration
1191
- * and current active-track count.
1192
- */
1193
- async getCoachAssignmentConfig(db, organizationId) {
1194
- const ownerTable = this.ownerTable;
1195
- const orgColumn = this.config.ownerOrgColumn || 'active_organization_id';
1196
- const roles = this.ownerRoles;
1197
- let coachQuery = db
1198
- .from(ownerTable)
1199
- .select('id, name, email, avatar_url')
1200
- .eq(orgColumn, organizationId);
1201
- coachQuery = roles.length === 1 ? coachQuery.eq('role', roles[0]) : coachQuery.in('role', roles);
1202
- const { data: coaches } = await coachQuery;
1203
- if (!coaches?.length)
1204
- return [];
1205
- const { data: configs } = await db
1206
- .from('planner_coach_assignment_config')
1207
- .select('*')
1208
- .eq('organization_id', organizationId);
1209
- const configMap = new Map((configs || []).map((c) => [c.coach_id, c]));
1210
- // Active track counts (only if a parent resource is configured)
1211
- let trackCounts = new Map();
1212
- if (this.config.parentResource) {
1213
- const { table, foreignKey } = this.config.parentResource;
1214
- const { data: tracks } = await db
1215
- .from(table)
1216
- .select(`${foreignKey}`)
1217
- .eq('status', 'active');
1218
- for (const t of tracks || []) {
1219
- const cid = t[foreignKey];
1220
- if (cid)
1221
- trackCounts.set(cid, (trackCounts.get(cid) || 0) + 1);
1222
- }
1223
- }
1224
- return coaches.map((c) => {
1225
- const cfg = configMap.get(c.id);
1226
- return {
1227
- coachId: c.id,
1228
- name: c.name,
1229
- email: c.email,
1230
- avatarUrl: c.avatar_url,
1231
- priority: cfg?.priority ?? 2,
1232
- weight: cfg?.weight ?? 100,
1233
- maxActiveTracks: cfg?.max_active_tracks ?? null,
1234
- isAvailableForAssignment: cfg?.is_available_for_assignment ?? true,
1235
- activeTracks: trackCounts.get(c.id) || 0,
1236
- lastAssignedAt: cfg?.last_assigned_at ?? null,
1237
- };
1238
- });
1239
- }
1240
- /**
1241
- * Upsert assignment config for one coach.
1242
- */
1243
- async updateCoachAssignmentConfig(db, organizationId, coachId, updates) {
1244
- const row = {
1245
- organization_id: organizationId,
1246
- coach_id: coachId,
1247
- updated_at: new Date().toISOString(),
1248
- };
1249
- if (updates.priority !== undefined)
1250
- row.priority = updates.priority;
1251
- if (updates.weight !== undefined)
1252
- row.weight = updates.weight;
1253
- if (updates.maxActiveTracks !== undefined)
1254
- row.max_active_tracks = updates.maxActiveTracks;
1255
- if (updates.isAvailableForAssignment !== undefined)
1256
- row.is_available_for_assignment = updates.isAvailableForAssignment;
1257
- const { error } = await db
1258
- .from('planner_coach_assignment_config')
1259
- .upsert(row, { onConflict: 'organization_id,coach_id' });
1260
- if (error) {
1261
- logger.error({ error, organizationId, coachId }, 'Failed to update coach assignment config');
1262
- throw new Error('Failed to update assignment config');
1263
- }
1264
- }
1265
- /**
1266
- * Cal.com-inspired 3-layer auto-assignment.
1267
- * Layer 1: highest-priority coaches only.
1268
- * Layer 2: weighted booking shortfall (target = total × weight/totalWeight).
1269
- * Layer 3: tiebreak by least-recently-assigned.
1270
- */
1271
- async findBestCoach(db, organizationId) {
1272
- const all = await this.getCoachAssignmentConfig(db, organizationId);
1273
- let candidates = all.filter((c) => c.isAvailableForAssignment);
1274
- if (candidates.length === 0) {
1275
- throw new Error('No available coaches found');
1276
- }
1277
- // Filter by max_active_tracks
1278
- candidates = candidates.filter((c) => c.maxActiveTracks == null || c.activeTracks < c.maxActiveTracks);
1279
- if (candidates.length === 0) {
1280
- throw new Error('All coaches at maximum capacity');
1281
- }
1282
- // Layer 1: keep only highest priority
1283
- const maxPriority = Math.max(...candidates.map((c) => c.priority));
1284
- candidates = candidates.filter((c) => c.priority === maxPriority);
1285
- // Layer 2: weighted shortfall
1286
- const totalActive = candidates.reduce((sum, c) => sum + c.activeTracks, 0);
1287
- const totalWeight = candidates.reduce((sum, c) => sum + c.weight, 0);
1288
- let best = null;
1289
- let bestShortfall = -Infinity;
1290
- let bestLast = Infinity;
1291
- for (const c of candidates) {
1292
- const target = totalWeight > 0 ? totalActive * (c.weight / totalWeight) : 0;
1293
- const shortfall = target - c.activeTracks;
1294
- const lastTs = c.lastAssignedAt ? new Date(c.lastAssignedAt).getTime() : 0;
1295
- if (shortfall > bestShortfall ||
1296
- (shortfall === bestShortfall && lastTs < bestLast)) {
1297
- best = c;
1298
- bestShortfall = shortfall;
1299
- bestLast = lastTs;
1300
- }
1301
- }
1302
- if (!best)
1303
- throw new Error('No best coach could be determined');
1304
- // Stamp last_assigned_at
1305
- await db
1306
- .from('planner_coach_assignment_config')
1307
- .upsert({
1308
- organization_id: organizationId,
1309
- coach_id: best.coachId,
1310
- last_assigned_at: new Date().toISOString(),
1311
- updated_at: new Date().toISOString(),
1312
- }, { onConflict: 'organization_id,coach_id' });
1313
- return {
1314
- coachId: best.coachId,
1315
- coachName: best.name,
1316
- reason: `Lowest workload (${best.activeTracks} active tracks, weight ${best.weight}, priority ${best.priority})`,
1317
- shortfall: bestShortfall,
1318
- activeTracks: best.activeTracks,
1319
- weight: best.weight,
1320
- priority: best.priority,
1321
- };
1322
- }
1323
- // ─── Calendar Cache (Phase 4) ────────────────────────────────
1324
- /**
1325
- * Upsert events into the cache for a user/calendar.
1326
- * Replaces all existing cache entries for that calendar in the given window.
1327
- */
1328
- async upsertCalendarCache(db, userId, organizationId, externalCalendarId, events, window) {
1329
- if (window) {
1330
- await db
1331
- .from('planner_calendar_cache')
1332
- .delete()
1333
- .eq('user_id', userId)
1334
- .eq('external_calendar_id', externalCalendarId)
1335
- .gte('event_start', window.from)
1336
- .lte('event_end', window.to);
1337
- }
1338
- if (events.length === 0)
1339
- return 0;
1340
- const rows = events.map((e) => ({
1341
- user_id: userId,
1342
- organization_id: organizationId,
1343
- external_calendar_id: externalCalendarId,
1344
- external_event_id: e.externalEventId,
1345
- event_start: e.eventStart,
1346
- event_end: e.eventEnd,
1347
- is_all_day: e.isAllDay ?? false,
1348
- status: e.status ?? 'confirmed',
1349
- summary: e.summary ?? null,
1350
- synced_at: new Date().toISOString(),
1351
- }));
1352
- const { error } = await db
1353
- .from('planner_calendar_cache')
1354
- .upsert(rows, { onConflict: 'user_id,external_calendar_id,external_event_id' });
1355
- if (error) {
1356
- logger.error({ error, userId, externalCalendarId }, 'Failed to upsert calendar cache');
1357
- throw new Error('Failed to cache calendar events');
1358
- }
1359
- return rows.length;
1360
- }
1361
- /**
1362
- * Read cached events for a user across selected calendars.
1363
- */
1364
- async getCachedBusySlots(db, userId, dateFrom, dateTo) {
1365
- const { data, error } = await db
1366
- .from('planner_calendar_cache')
1367
- .select('event_start, event_end, summary, status')
1368
- .eq('user_id', userId)
1369
- .neq('status', 'cancelled')
1370
- .gte('event_end', `${dateFrom}T00:00:00Z`)
1371
- .lte('event_start', `${dateTo}T23:59:59Z`);
1372
- if (error) {
1373
- logger.warn({ error, userId }, 'Failed to read calendar cache');
1374
- return [];
1375
- }
1376
- return (data || []).map((r) => ({
1377
- start_time: r.event_start,
1378
- end_time: r.event_end,
1379
- summary: r.summary,
1380
- }));
1381
- }
1382
- }
1383
- // ─── Helpers ─────────────────────────────────────────────────
1384
- function isoWeekKey(date) {
1385
- // ISO week year + week number
1386
- const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
1387
- const dayNum = d.getUTCDay() || 7;
1388
- d.setUTCDate(d.getUTCDate() + 4 - dayNum);
1389
- const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
1390
- const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
1391
- return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
1392
- }
1393
- //# sourceMappingURL=PlannerService.js.map