includio-cms 0.36.9 → 0.37.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 (243) hide show
  1. package/API.md +178 -3
  2. package/CHANGELOG.md +54 -0
  3. package/DOCS.md +28 -3
  4. package/ROADMAP.md +18 -0
  5. package/dist/admin/client/booking/booking-dashboard.svelte +40 -0
  6. package/dist/admin/client/booking/booking-dashboard.svelte.d.ts +3 -0
  7. package/dist/admin/client/booking/customer-panel.svelte +102 -0
  8. package/dist/admin/client/booking/customer-panel.svelte.d.ts +11 -0
  9. package/dist/admin/client/booking/customer-view.d.ts +52 -0
  10. package/dist/admin/client/booking/customer-view.js +134 -0
  11. package/dist/admin/client/booking/participant-data-panel.svelte +330 -0
  12. package/dist/admin/client/booking/participant-data-panel.svelte.d.ts +22 -0
  13. package/dist/admin/client/booking/reservation-detail-page.svelte +637 -0
  14. package/dist/admin/client/booking/reservation-detail-page.svelte.d.ts +3 -0
  15. package/dist/admin/client/booking/reservations-page.svelte +221 -0
  16. package/dist/admin/client/booking/reservations-page.svelte.d.ts +3 -0
  17. package/dist/admin/client/collection/collection-entries.svelte +1 -1
  18. package/dist/admin/client/collection/entry-link.svelte +6 -4
  19. package/dist/admin/client/collection/entry-link.svelte.d.ts +3 -1
  20. package/dist/admin/client/collection/status-badge.svelte +39 -3
  21. package/dist/admin/client/collection/status-badge.svelte.d.ts +5 -3
  22. package/dist/admin/client/entry/entry.svelte +26 -9
  23. package/dist/admin/client/entry/hybrid/hybrid-preview.svelte +73 -10
  24. package/dist/admin/client/entry/hybrid/hybrid-preview.svelte.d.ts +6 -5
  25. package/dist/admin/client/entry/preview-views-context.svelte.d.ts +22 -0
  26. package/dist/admin/client/entry/preview-views-context.svelte.js +58 -0
  27. package/dist/admin/client/entry/preview-views.d.ts +27 -0
  28. package/dist/admin/client/entry/preview-views.js +33 -0
  29. package/dist/admin/client/index.d.ts +3 -0
  30. package/dist/admin/client/index.js +3 -0
  31. package/dist/admin/client/modules/dashboard-registry.d.ts +7 -0
  32. package/dist/admin/client/modules/dashboard-registry.js +11 -0
  33. package/dist/admin/client/modules/module-dashboard-page.svelte +26 -0
  34. package/dist/admin/client/modules/module-dashboard-page.svelte.d.ts +6 -0
  35. package/dist/admin/client/modules/stat-card.svelte +22 -0
  36. package/dist/admin/client/modules/stat-card.svelte.d.ts +10 -0
  37. package/dist/admin/client/shop/shop-dashboard.svelte +29 -0
  38. package/dist/admin/client/shop/shop-dashboard.svelte.d.ts +3 -0
  39. package/dist/admin/components/fields/relation-field.svelte +11 -2
  40. package/dist/admin/components/icons/icon-map.js +8 -0
  41. package/dist/admin/components/layout/app-sidebar.svelte +45 -14
  42. package/dist/admin/components/layout/context-switcher.svelte +90 -0
  43. package/dist/admin/components/layout/context-switcher.svelte.d.ts +3 -0
  44. package/dist/admin/components/layout/lang.d.ts +4 -0
  45. package/dist/admin/components/layout/lang.js +8 -0
  46. package/dist/admin/components/layout/layout-renderer.svelte +2 -1
  47. package/dist/admin/components/layout/layout-tabs.svelte +14 -0
  48. package/dist/admin/components/layout/layout-tabs.svelte.d.ts +2 -0
  49. package/dist/admin/components/layout/nav-collections.svelte +16 -9
  50. package/dist/admin/components/layout/nav-collections.svelte.d.ts +5 -1
  51. package/dist/admin/components/layout/nav-global.svelte +45 -0
  52. package/dist/admin/components/layout/nav-global.svelte.d.ts +3 -0
  53. package/dist/admin/components/layout/nav-main.svelte +8 -32
  54. package/dist/admin/components/layout/nav-main.svelte.d.ts +6 -1
  55. package/dist/admin/components/layout/nav-module.svelte +57 -0
  56. package/dist/admin/components/layout/nav-module.svelte.d.ts +7 -0
  57. package/dist/admin/components/layout/nav-section.svelte +1 -1
  58. package/dist/admin/components/layout/nav-section.svelte.d.ts +1 -1
  59. package/dist/admin/components/layout/nav-singletons.svelte +14 -7
  60. package/dist/admin/components/layout/nav-singletons.svelte.d.ts +5 -1
  61. package/dist/admin/remote/booking.remote.d.ts +244 -0
  62. package/dist/admin/remote/booking.remote.js +283 -0
  63. package/dist/admin/remote/index.d.ts +2 -0
  64. package/dist/admin/remote/index.js +2 -0
  65. package/dist/admin/remote/modules.remote.d.ts +10 -0
  66. package/dist/admin/remote/modules.remote.js +14 -0
  67. package/dist/admin/state/active-context-resolve.d.ts +37 -0
  68. package/dist/admin/state/active-context-resolve.js +66 -0
  69. package/dist/admin/state/active-context.svelte.d.ts +8 -0
  70. package/dist/admin/state/active-context.svelte.js +27 -0
  71. package/dist/booking/adjustments.d.ts +33 -0
  72. package/dist/booking/adjustments.js +26 -0
  73. package/dist/booking/capacity.d.ts +12 -0
  74. package/dist/booking/capacity.js +10 -0
  75. package/dist/booking/client/index.d.ts +39 -0
  76. package/dist/booking/client/index.js +44 -0
  77. package/dist/booking/client/use-booking.svelte.d.ts +25 -0
  78. package/dist/booking/client/use-booking.svelte.js +56 -0
  79. package/dist/booking/config.d.ts +156 -0
  80. package/dist/booking/config.js +36 -0
  81. package/dist/booking/draft.d.ts +77 -0
  82. package/dist/booking/draft.js +67 -0
  83. package/dist/booking/expiry-logic.d.ts +30 -0
  84. package/dist/booking/expiry-logic.js +33 -0
  85. package/dist/booking/index.d.ts +18 -0
  86. package/dist/booking/index.js +52 -0
  87. package/dist/booking/ledger.d.ts +46 -0
  88. package/dist/booking/ledger.js +60 -0
  89. package/dist/booking/number.d.ts +2 -0
  90. package/dist/booking/number.js +11 -0
  91. package/dist/booking/participant-fields/access.d.ts +10 -0
  92. package/dist/booking/participant-fields/access.js +14 -0
  93. package/dist/booking/participant-fields/age.d.ts +12 -0
  94. package/dist/booking/participant-fields/age.js +28 -0
  95. package/dist/booking/participant-fields/completeness.d.ts +8 -0
  96. package/dist/booking/participant-fields/completeness.js +13 -0
  97. package/dist/booking/participant-fields/group.d.ts +6 -0
  98. package/dist/booking/participant-fields/group.js +15 -0
  99. package/dist/booking/participant-fields/index.d.ts +6 -0
  100. package/dist/booking/participant-fields/index.js +6 -0
  101. package/dist/booking/participant-fields/types.d.ts +50 -0
  102. package/dist/booking/participant-fields/types.js +5 -0
  103. package/dist/booking/participant-fields/validate.d.ts +7 -0
  104. package/dist/booking/participant-fields/validate.js +44 -0
  105. package/dist/booking/participant-pricing.d.ts +93 -0
  106. package/dist/booking/participant-pricing.js +91 -0
  107. package/dist/booking/participants-view.d.ts +32 -0
  108. package/dist/booking/participants-view.js +22 -0
  109. package/dist/booking/payment-context.d.ts +19 -0
  110. package/dist/booking/payment-context.js +29 -0
  111. package/dist/booking/pricing-inputs.d.ts +28 -0
  112. package/dist/booking/pricing-inputs.js +35 -0
  113. package/dist/booking/pricing.d.ts +86 -0
  114. package/dist/booking/pricing.js +125 -0
  115. package/dist/booking/recompute.d.ts +22 -0
  116. package/dist/booking/recompute.js +31 -0
  117. package/dist/booking/reminders-schedule.d.ts +11 -0
  118. package/dist/booking/reminders-schedule.js +28 -0
  119. package/dist/booking/server/adjustments.d.ts +27 -0
  120. package/dist/booking/server/adjustments.js +136 -0
  121. package/dist/booking/server/bookings.d.ts +45 -0
  122. package/dist/booking/server/bookings.js +288 -0
  123. package/dist/booking/server/catalog.d.ts +9 -0
  124. package/dist/booking/server/catalog.js +20 -0
  125. package/dist/booking/server/db.d.ts +7 -0
  126. package/dist/booking/server/db.js +18 -0
  127. package/dist/booking/server/email.d.ts +23 -0
  128. package/dist/booking/server/email.js +59 -0
  129. package/dist/booking/server/emailTemplateRegistry.d.ts +29 -0
  130. package/dist/booking/server/emailTemplateRegistry.js +125 -0
  131. package/dist/booking/server/http/create-handler.d.ts +5 -0
  132. package/dist/booking/server/http/create-handler.js +68 -0
  133. package/dist/booking/server/http/index.d.ts +4 -0
  134. package/dist/booking/server/http/index.js +4 -0
  135. package/dist/booking/server/http/maintenance-handler.d.ts +9 -0
  136. package/dist/booking/server/http/maintenance-handler.js +36 -0
  137. package/dist/booking/server/http/portal-handler.d.ts +13 -0
  138. package/dist/booking/server/http/portal-handler.js +99 -0
  139. package/dist/booking/server/http/webhook-handler.d.ts +9 -0
  140. package/dist/booking/server/http/webhook-handler.js +39 -0
  141. package/dist/booking/server/index.d.ts +12 -0
  142. package/dist/booking/server/index.js +14 -0
  143. package/dist/booking/server/maintenance.d.ts +28 -0
  144. package/dist/booking/server/maintenance.js +134 -0
  145. package/dist/booking/server/participants.d.ts +26 -0
  146. package/dist/booking/server/participants.js +92 -0
  147. package/dist/booking/server/payments.d.ts +34 -0
  148. package/dist/booking/server/payments.js +207 -0
  149. package/dist/booking/server/portal-session.d.ts +16 -0
  150. package/dist/booking/server/portal-session.js +42 -0
  151. package/dist/booking/server/portal.d.ts +207 -0
  152. package/dist/booking/server/portal.js +234 -0
  153. package/dist/booking/server/refunds.d.ts +7 -0
  154. package/dist/booking/server/refunds.js +58 -0
  155. package/dist/booking/server/reminders.d.ts +9 -0
  156. package/dist/booking/server/reminders.js +24 -0
  157. package/dist/booking/server/validation.d.ts +17 -0
  158. package/dist/booking/server/validation.js +55 -0
  159. package/dist/booking/state-machine.d.ts +41 -0
  160. package/dist/booking/state-machine.js +73 -0
  161. package/dist/booking/templates/_partials/footer.en.html +4 -0
  162. package/dist/booking/templates/_partials/footer.pl.html +4 -0
  163. package/dist/booking/templates/_partials/header.en.html +4 -0
  164. package/dist/booking/templates/_partials/header.pl.html +4 -0
  165. package/dist/booking/templates/balance-due.en.html +10 -0
  166. package/dist/booking/templates/balance-due.pl.html +10 -0
  167. package/dist/booking/templates/balance-overdue-cancelled.en.html +5 -0
  168. package/dist/booking/templates/balance-overdue-cancelled.pl.html +5 -0
  169. package/dist/booking/templates/booking-received.en.html +9 -0
  170. package/dist/booking/templates/booking-received.pl.html +9 -0
  171. package/dist/booking/templates/deposit-paid.en.html +6 -0
  172. package/dist/booking/templates/deposit-paid.pl.html +6 -0
  173. package/dist/booking/templates/payment-link.en.html +5 -0
  174. package/dist/booking/templates/payment-link.pl.html +5 -0
  175. package/dist/booking/templates/payment-received.en.html +10 -0
  176. package/dist/booking/templates/payment-received.pl.html +10 -0
  177. package/dist/booking/totals.d.ts +38 -0
  178. package/dist/booking/totals.js +61 -0
  179. package/dist/booking/webhook-logic.d.ts +28 -0
  180. package/dist/booking/webhook-logic.js +50 -0
  181. package/dist/cli/create-user.d.ts +7 -1
  182. package/dist/cli/create-user.js +23 -15
  183. package/dist/cli/index.js +55 -7
  184. package/dist/cli/scaffold/admin.d.ts +6 -0
  185. package/dist/cli/scaffold/admin.js +5 -3
  186. package/dist/cli/scaffold/booking.d.ts +12 -0
  187. package/dist/cli/scaffold/booking.js +103 -0
  188. package/dist/core/cms.d.ts +24 -9
  189. package/dist/core/cms.js +72 -34
  190. package/dist/core/config/validatePreviews.d.ts +12 -0
  191. package/dist/core/config/validatePreviews.js +90 -0
  192. package/dist/core/fields/slugPath.d.ts +9 -1
  193. package/dist/core/fields/slugPath.js +16 -2
  194. package/dist/core/server/fields/resolveRelationFields.d.ts +11 -2
  195. package/dist/core/server/fields/resolveRelationFields.js +12 -3
  196. package/dist/core/server/generator/generator.js +7 -2
  197. package/dist/db-postgres/schema/booking/bookingAdjustments.d.ts +186 -0
  198. package/dist/db-postgres/schema/booking/bookingAdjustments.js +22 -0
  199. package/dist/db-postgres/schema/booking/bookingItemPersons.d.ts +117 -0
  200. package/dist/db-postgres/schema/booking/bookingItemPersons.js +22 -0
  201. package/dist/db-postgres/schema/booking/bookingItems.d.ts +181 -0
  202. package/dist/db-postgres/schema/booking/bookingItems.js +22 -0
  203. package/dist/db-postgres/schema/booking/bookingPayments.d.ts +204 -0
  204. package/dist/db-postgres/schema/booking/bookingPayments.js +25 -0
  205. package/dist/db-postgres/schema/booking/bookingPersons.d.ts +99 -0
  206. package/dist/db-postgres/schema/booking/bookingPersons.js +17 -0
  207. package/dist/db-postgres/schema/booking/bookingReminders.d.ts +131 -0
  208. package/dist/db-postgres/schema/booking/bookingReminders.js +18 -0
  209. package/dist/db-postgres/schema/booking/bookings.d.ts +307 -0
  210. package/dist/db-postgres/schema/booking/bookings.js +40 -0
  211. package/dist/db-postgres/schema/booking/index.d.ts +7 -0
  212. package/dist/db-postgres/schema/booking/index.js +7 -0
  213. package/dist/db-postgres/schema/index.d.ts +1 -0
  214. package/dist/db-postgres/schema/index.js +1 -0
  215. package/dist/db-postgres/schema-booking.d.ts +1 -0
  216. package/dist/db-postgres/schema-booking.js +1 -0
  217. package/dist/server/security/rate-limit-handler.d.ts +19 -0
  218. package/dist/server/security/rate-limit-handler.js +34 -0
  219. package/dist/shop/adapters/payu/client.js +3 -1
  220. package/dist/shop/adapters/payu/index.js +37 -17
  221. package/dist/shop/adapters/payu/payload.d.ts +6 -0
  222. package/dist/shop/adapters/payu/payload.js +1 -1
  223. package/dist/shop/adapters/stripe/index.js +36 -21
  224. package/dist/shop/index.d.ts +11 -2
  225. package/dist/shop/index.js +43 -1
  226. package/dist/shop/rate-limit.d.ts +1 -9
  227. package/dist/shop/rate-limit.js +3 -28
  228. package/dist/shop/types.d.ts +19 -0
  229. package/dist/sveltekit/config.d.ts +4 -0
  230. package/dist/sveltekit/config.js +6 -0
  231. package/dist/types/cms.d.ts +15 -1
  232. package/dist/types/cms.schema.d.ts +96 -2
  233. package/dist/types/cms.schema.js +84 -1
  234. package/dist/types/config.d.ts +33 -0
  235. package/dist/types/layout.d.ts +6 -0
  236. package/dist/types/modules.d.ts +133 -0
  237. package/dist/types/modules.js +27 -0
  238. package/dist/updates/0.37.0/index.d.ts +2 -0
  239. package/dist/updates/0.37.0/index.js +52 -0
  240. package/dist/updates/index.js +3 -1
  241. package/package.json +19 -1
  242. package/dist/admin/components/layout/nav-shop.svelte +0 -33
  243. package/dist/admin/components/layout/nav-shop.svelte.d.ts +0 -3
@@ -0,0 +1,288 @@
1
+ import { and, eq, inArray, isNull } from 'drizzle-orm';
2
+ import { getBookingDb, requireBookingConfig } from './db.js';
3
+ import { bookingsTable, bookingItemsTable, bookingPersonsTable, bookingItemPersonsTable, bookingPaymentsTable, bookingRemindersTable, bookingAdjustmentsTable } from '../../db-postgres/schema/booking/index.js';
4
+ import { buildBookingDraft, pricingInputsFromCreate } from '../draft.js';
5
+ import { buildPricingInputs } from '../pricing-inputs.js';
6
+ import { priceByParticipant } from '../participant-pricing.js';
7
+ import { grossByTripRef, netOf } from '../totals.js';
8
+ import { sumAdjustments } from '../adjustments.js';
9
+ import { resolveItemSchema } from './participants.js';
10
+ import { generateBookingNumber } from '../number.js';
11
+ import { recomputeTotals, assertEditAllowed, assertPortalEditAllowed } from '../recompute.js';
12
+ import { deriveStatus } from '../state-machine.js';
13
+ import { runCapacityCheck, runValidateBooking } from './validation.js';
14
+ import { rescheduleReminders } from './reminders.js';
15
+ /** Resolve personRef → DB person id; throws on a dangling assignment reference. */
16
+ function personIdLookup(map, ref) {
17
+ const id = map.get(ref);
18
+ if (!id)
19
+ throw new Error(`Assignment references unknown personRef "${ref}"`);
20
+ return id;
21
+ }
22
+ /** Parse the arrival-date anchor from a customer field value (Date or ISO string). */
23
+ function parseAnchor(raw) {
24
+ if (raw instanceof Date)
25
+ return raw;
26
+ if (typeof raw === 'string' && !Number.isNaN(Date.parse(raw)))
27
+ return new Date(raw);
28
+ return null;
29
+ }
30
+ /**
31
+ * Resolve the per-call pricing context: the editable participant-pricing config
32
+ * (from the client resolver, e.g. globalSettings), the arrival-date anchor from
33
+ * the customer, and the identity field schema (to locate the dob key).
34
+ */
35
+ async function buildPricingContext(config, customer, tripRefForSchema) {
36
+ const participantPricing = config.participantPricing
37
+ ? await config.participantPricing()
38
+ : { ageBands: [], ladder: [], earlyBird: null };
39
+ const anchor = parseAnchor(customer[config.arrivalDateField]);
40
+ const identityFields = tripRefForSchema ? await resolveItemSchema(tripRefForSchema) : [];
41
+ return { participantPricing, anchor, identityFields };
42
+ }
43
+ /**
44
+ * Create a booking (public self-serve or staff). Validates catalog capacity +
45
+ * the client seam, prices the draft per-participant, and persists booking +
46
+ * people + items + assignments + reminders in one transaction. Status starts
47
+ * `draft`; the deposit payment (and its webhook) drives the status forward.
48
+ */
49
+ export async function createBooking(input, opts = {}) {
50
+ const config = requireBookingConfig();
51
+ const db = getBookingDb();
52
+ const now = opts.now ?? new Date();
53
+ const validationItems = input.items.map((it) => ({
54
+ tripRef: it.tripRef,
55
+ departureDate: it.departureDate,
56
+ participantsCount: it.assignments.length
57
+ }));
58
+ await runCapacityCheck(validationItems);
59
+ await runValidateBooking({
60
+ operation: 'create',
61
+ bookingId: null,
62
+ items: validationItems,
63
+ customer: input.customer
64
+ });
65
+ const ctx = await buildPricingContext(config, input.customer, input.items[0]?.tripRef);
66
+ const draft = buildBookingDraft(input, config, ctx, now);
67
+ // Unique booking number with collision retry (~25 bits → rare).
68
+ let number = generateBookingNumber();
69
+ for (let i = 0; i < 5; i++) {
70
+ const hit = await db
71
+ .select({ id: bookingsTable.id })
72
+ .from(bookingsTable)
73
+ .where(eq(bookingsTable.number, number));
74
+ if (hit.length === 0)
75
+ break;
76
+ number = generateBookingNumber();
77
+ }
78
+ return db.transaction(async (tx) => {
79
+ const [booking] = await tx
80
+ .insert(bookingsTable)
81
+ .values({
82
+ number,
83
+ status: 'draft',
84
+ currency: config.currency,
85
+ customer: input.customer,
86
+ totals: draft.totals,
87
+ balanceDueAt: draft.balanceDueAt,
88
+ holdExpiresAt: draft.holdExpiresAt,
89
+ language: input.language ?? null
90
+ })
91
+ .returning();
92
+ const personIdByRef = new Map();
93
+ for (const p of draft.persons) {
94
+ const [person] = await tx
95
+ .insert(bookingPersonsTable)
96
+ .values({ bookingId: booking.id, data: p.identity })
97
+ .returning();
98
+ personIdByRef.set(p.ref, person.id);
99
+ }
100
+ for (const it of draft.items) {
101
+ const [item] = await tx
102
+ .insert(bookingItemsTable)
103
+ .values({
104
+ bookingId: booking.id,
105
+ tripRef: it.tripRef,
106
+ departureDate: it.departureDate,
107
+ participantsCount: it.participantsCount,
108
+ unitPriceSnapshot: it.unitPriceSnapshot,
109
+ subtotalSnapshot: it.subtotalSnapshot,
110
+ appliedDiscounts: it.appliedDiscounts
111
+ })
112
+ .returning();
113
+ if (it.assignments.length > 0) {
114
+ await tx.insert(bookingItemPersonsTable).values(it.assignments.map((a) => ({
115
+ itemId: item.id,
116
+ personId: personIdLookup(personIdByRef, a.personRef),
117
+ data: a.data ?? {}
118
+ })));
119
+ }
120
+ }
121
+ if (draft.reminders.length > 0) {
122
+ await tx.insert(bookingRemindersTable).values(draft.reminders.map((r) => ({
123
+ bookingId: booking.id,
124
+ type: r.type,
125
+ templateKey: r.templateKey,
126
+ scheduledFor: r.scheduledFor
127
+ })));
128
+ }
129
+ return { booking, accessToken: booking.accessToken };
130
+ });
131
+ }
132
+ /**
133
+ * Edit a booking (staff or portal). Recomputes totals per-participant + derived
134
+ * status, enforces the actor's guards (`portal` blocks dropping below paid),
135
+ * re-runs capacity + validate seam when items change, replaces people/items/
136
+ * assignments, and reschedules reminders on a departure change. One transaction.
137
+ */
138
+ export async function editBooking(bookingId, changes, opts) {
139
+ const config = requireBookingConfig();
140
+ const db = getBookingDb();
141
+ const now = opts.now ?? new Date();
142
+ return db.transaction(async (tx) => {
143
+ const [booking] = await tx
144
+ .select()
145
+ .from(bookingsTable)
146
+ .where(eq(bookingsTable.id, bookingId))
147
+ .for('update');
148
+ if (!booking)
149
+ throw new Error(`Booking ${bookingId} not found`);
150
+ const existingItems = await tx
151
+ .select()
152
+ .from(bookingItemsTable)
153
+ .where(eq(bookingItemsTable.bookingId, bookingId));
154
+ const customerForCtx = changes.customer ?? booking.customer;
155
+ const tripRefForSchema = changes.items?.[0]?.tripRef ?? existingItems[0]?.tripRef;
156
+ const ctx = await buildPricingContext(config, customerForCtx, tripRefForSchema);
157
+ // Pricing inputs: from the edit when items change, else from existing rows.
158
+ let participants;
159
+ let trips;
160
+ if (changes.items) {
161
+ const pi = pricingInputsFromCreate({ customer: customerForCtx, persons: changes.persons ?? [], items: changes.items }, ctx.identityFields);
162
+ participants = pi.participants;
163
+ trips = pi.trips;
164
+ }
165
+ else {
166
+ const existingItemIds = existingItems.map((i) => i.id);
167
+ const persons = await tx
168
+ .select()
169
+ .from(bookingPersonsTable)
170
+ .where(eq(bookingPersonsTable.bookingId, bookingId));
171
+ const assignments = existingItemIds.length
172
+ ? await tx
173
+ .select()
174
+ .from(bookingItemPersonsTable)
175
+ .where(inArray(bookingItemPersonsTable.itemId, existingItemIds))
176
+ : [];
177
+ const pi = buildPricingInputs(persons.map((p) => ({ id: p.id, data: p.data })), existingItems.map((i) => ({ id: i.id, tripRef: i.tripRef, unitPrice: i.unitPriceSnapshot })), assignments.map((a) => ({ itemId: a.itemId, personId: a.personId })), ctx.identityFields);
178
+ participants = pi.participants;
179
+ trips = pi.trips;
180
+ }
181
+ const result = priceByParticipant(participants, trips, ctx.anchor, now, ctx.participantPricing);
182
+ const dates = (changes.items ?? existingItems).map((it) => it.departureDate);
183
+ const payments = (await tx
184
+ .select()
185
+ .from(bookingPaymentsTable)
186
+ .where(eq(bookingPaymentsTable.bookingId, bookingId))).map((p) => ({ amount: p.amount, status: p.status, kind: p.kind }));
187
+ const adjustments = await tx
188
+ .select()
189
+ .from(bookingAdjustmentsTable)
190
+ .where(and(eq(bookingAdjustmentsTable.bookingId, bookingId), isNull(bookingAdjustmentsTable.voidedAt)));
191
+ const sums = sumAdjustments(adjustments);
192
+ const totals = recomputeTotals(result, sums, payments);
193
+ if (opts.actor === 'portal')
194
+ assertPortalEditAllowed(booking.status, netOf(totals), payments);
195
+ else
196
+ assertEditAllowed(booking.status);
197
+ if (changes.items) {
198
+ const validationItems = changes.items.map((it) => ({
199
+ tripRef: it.tripRef,
200
+ departureDate: it.departureDate,
201
+ participantsCount: it.assignments.length
202
+ }));
203
+ await runCapacityCheck(validationItems, bookingId);
204
+ await runValidateBooking({
205
+ operation: 'edit',
206
+ bookingId,
207
+ items: validationItems,
208
+ customer: customerForCtx
209
+ });
210
+ const subtotalByRef = grossByTripRef(result);
211
+ // Replace people + items + assignments (item delete cascades assignments).
212
+ await tx.delete(bookingItemsTable).where(eq(bookingItemsTable.bookingId, bookingId));
213
+ await tx.delete(bookingPersonsTable).where(eq(bookingPersonsTable.bookingId, bookingId));
214
+ const personIdByRef = new Map();
215
+ for (const p of changes.persons ?? []) {
216
+ const [person] = await tx
217
+ .insert(bookingPersonsTable)
218
+ .values({ bookingId, data: p.identity })
219
+ .returning();
220
+ personIdByRef.set(p.ref, person.id);
221
+ }
222
+ for (const it of changes.items) {
223
+ const [item] = await tx
224
+ .insert(bookingItemsTable)
225
+ .values({
226
+ bookingId,
227
+ tripRef: it.tripRef,
228
+ departureDate: it.departureDate,
229
+ participantsCount: it.assignments.length,
230
+ unitPriceSnapshot: it.unitPrice,
231
+ subtotalSnapshot: subtotalByRef.get(it.tripRef) ?? null,
232
+ appliedDiscounts: totals.appliedRules.filter((r) => r.targetRef === it.tripRef)
233
+ })
234
+ .returning();
235
+ if (it.assignments.length > 0) {
236
+ await tx.insert(bookingItemPersonsTable).values(it.assignments.map((a) => ({
237
+ itemId: item.id,
238
+ personId: personIdLookup(personIdByRef, a.personRef),
239
+ data: a.data ?? {}
240
+ })));
241
+ }
242
+ }
243
+ }
244
+ const status = deriveStatus({
245
+ gross: netOf(totals),
246
+ paid: totals.paid,
247
+ depositRequired: config.depositAmount,
248
+ current: booking.status
249
+ });
250
+ const [updated] = await tx
251
+ .update(bookingsTable)
252
+ .set({ totals, status, customer: customerForCtx, updatedAt: now })
253
+ .where(eq(bookingsTable.id, bookingId))
254
+ .returning();
255
+ if (changes.items)
256
+ await rescheduleReminders(tx, bookingId, dates);
257
+ return updated;
258
+ });
259
+ }
260
+ /**
261
+ * Price a booking from its CURRENTLY persisted people/items/assignments (no
262
+ * edit). Shared by adjustment recompute — mirrors the "no items change" branch
263
+ * of {@link editBooking}. Must run inside a transaction that has locked the
264
+ * booking row.
265
+ */
266
+ export async function priceFromExistingRows(tx, bookingId, config, now) {
267
+ const [booking] = await tx.select().from(bookingsTable).where(eq(bookingsTable.id, bookingId));
268
+ if (!booking)
269
+ throw new Error(`Booking ${bookingId} not found`);
270
+ const existingItems = await tx
271
+ .select()
272
+ .from(bookingItemsTable)
273
+ .where(eq(bookingItemsTable.bookingId, bookingId));
274
+ const ctx = await buildPricingContext(config, booking.customer, existingItems[0]?.tripRef);
275
+ const persons = await tx
276
+ .select()
277
+ .from(bookingPersonsTable)
278
+ .where(eq(bookingPersonsTable.bookingId, bookingId));
279
+ const itemIds = existingItems.map((i) => i.id);
280
+ const assignments = itemIds.length
281
+ ? await tx
282
+ .select()
283
+ .from(bookingItemPersonsTable)
284
+ .where(inArray(bookingItemPersonsTable.itemId, itemIds))
285
+ : [];
286
+ const pi = buildPricingInputs(persons.map((p) => ({ id: p.id, data: p.data })), existingItems.map((i) => ({ id: i.id, tripRef: i.tripRef, unitPrice: i.unitPriceSnapshot })), assignments.map((a) => ({ itemId: a.itemId, personId: a.personId })), ctx.identityFields);
287
+ return priceByParticipant(pi.participants, pi.trips, ctx.anchor, now, ctx.participantPricing);
288
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Read a trip's seat capacity from the catalog collection.
3
+ *
4
+ * Contract: `tripRef` is the catalog **entry id** (`Entry._id`). The client
5
+ * storefront stores the resolved trip's `_id` on each booking item. Returns
6
+ * `null` (treated as unlimited) when no `capacityField` is configured, the entry
7
+ * is missing/unpublished, or the field is absent/non-numeric.
8
+ */
9
+ export declare function readTripCapacity(tripRef: string): Promise<number | null>;
@@ -0,0 +1,20 @@
1
+ import { resolveEntry } from '../../core/server/entries/operations/resolveEntry.js';
2
+ import { requireBookingConfig } from './db.js';
3
+ /**
4
+ * Read a trip's seat capacity from the catalog collection.
5
+ *
6
+ * Contract: `tripRef` is the catalog **entry id** (`Entry._id`). The client
7
+ * storefront stores the resolved trip's `_id` on each booking item. Returns
8
+ * `null` (treated as unlimited) when no `capacityField` is configured, the entry
9
+ * is missing/unpublished, or the field is absent/non-numeric.
10
+ */
11
+ export async function readTripCapacity(tripRef) {
12
+ const config = requireBookingConfig();
13
+ if (!config.capacityField)
14
+ return null;
15
+ const entry = await resolveEntry({ id: tripRef, status: 'published' });
16
+ if (!entry)
17
+ return null;
18
+ const raw = entry[config.capacityField];
19
+ return typeof raw === 'number' ? raw : null;
20
+ }
@@ -0,0 +1,7 @@
1
+ import type { ResolvedBookingConfig } from '../index.js';
2
+ /** Returns the Drizzle handle, asserting the postgres adapter is in use. */
3
+ export declare function getBookingDb(): import("drizzle-orm/postgres-js/driver", { with: { "resolution-mode": "require" } }).PostgresJsDatabase<typeof import("../../db-postgres/schema/index.js")> & {
4
+ $client: import("postgres").Sql<{}>;
5
+ };
6
+ /** Returns the resolved booking config, throwing when the module is not registered. */
7
+ export declare function requireBookingConfig(): ResolvedBookingConfig;
@@ -0,0 +1,18 @@
1
+ import { getCMS } from '../../core/cms.js';
2
+ /** Returns the Drizzle handle, asserting the postgres adapter is in use. */
3
+ export function getBookingDb() {
4
+ const cms = getCMS();
5
+ const adapter = cms.databaseAdapter;
6
+ if (!adapter._drizzle) {
7
+ throw new Error('Booking requires the db-postgres adapter (Drizzle instance missing).');
8
+ }
9
+ return adapter._drizzle;
10
+ }
11
+ /** Returns the resolved booking config, throwing when the module is not registered. */
12
+ export function requireBookingConfig() {
13
+ const cfg = getCMS().getModuleConfig('booking');
14
+ if (!cfg) {
15
+ throw new Error('Booking is not configured. Add `defineBooking({...})` to `modules` in your CMS config.');
16
+ }
17
+ return cfg;
18
+ }
@@ -0,0 +1,23 @@
1
+ /** The transactional emails the booking module can send. */
2
+ export type BookingEmailKind = 'booking-received' | 'deposit-paid' | 'payment-received' | 'payment-link' | 'balance-due' | 'balance-overdue-cancelled';
3
+ /** Template file name for a kind (1:1 — kept as a function for future indirection). */
4
+ export declare function pickEmailTemplate(kind: BookingEmailKind): string;
5
+ /** Localised subject for a kind, falling back to English. */
6
+ export declare function subjectFor(kind: BookingEmailKind, lang: string): string;
7
+ /** Render a booking email's HTML body for the given language + context. */
8
+ export declare function renderBookingEmail(kind: BookingEmailKind, lang: string, context: Record<string, unknown>): Promise<string>;
9
+ /** Pure render-context builder for a booking email. @internal */
10
+ export declare function buildEmailContext(booking: {
11
+ accessToken: string;
12
+ customer: Record<string, unknown>;
13
+ } & Record<string, unknown>, config: {
14
+ currency: string;
15
+ brand: string | null;
16
+ portalUrlTemplate: string | null;
17
+ }, now: Date, extra?: Record<string, unknown>): Record<string, unknown>;
18
+ /**
19
+ * Send a transactional booking email to the booker. No-op when no email adapter
20
+ * is configured, the booking is missing, or it has no customer email. `extra` is
21
+ * merged into the render context (e.g. `{ amount, paymentLink }` for payment-link).
22
+ */
23
+ export declare function sendBookingEmail(bookingId: string, kind: BookingEmailKind, extra?: Record<string, unknown>): Promise<void>;
@@ -0,0 +1,59 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { getCMS } from '../../core/cms.js';
3
+ import { getBookingDb, requireBookingConfig } from './db.js';
4
+ import { bookingsTable } from '../../db-postgres/schema/booking/index.js';
5
+ import { renderEmailTemplate } from './emailTemplateRegistry.js';
6
+ /** Template file name for a kind (1:1 — kept as a function for future indirection). */
7
+ export function pickEmailTemplate(kind) {
8
+ return kind;
9
+ }
10
+ const SUBJECTS = {
11
+ 'booking-received': { pl: 'Rezerwacja przyjęta', en: 'Booking received' },
12
+ 'deposit-paid': { pl: 'Zadatek zaksięgowany', en: 'Deposit received' },
13
+ 'payment-received': { pl: 'Płatność zaksięgowana', en: 'Payment received' },
14
+ 'payment-link': { pl: 'Link do płatności', en: 'Payment link' },
15
+ 'balance-due': { pl: 'Przypomnienie o dopłacie', en: 'Balance reminder' },
16
+ 'balance-overdue-cancelled': { pl: 'Rezerwacja anulowana', en: 'Booking cancelled' }
17
+ };
18
+ /** Localised subject for a kind, falling back to English. */
19
+ export function subjectFor(kind, lang) {
20
+ return SUBJECTS[kind][lang] ?? SUBJECTS[kind].en;
21
+ }
22
+ /** Render a booking email's HTML body for the given language + context. */
23
+ export async function renderBookingEmail(kind, lang, context) {
24
+ return renderEmailTemplate(pickEmailTemplate(kind), lang, context);
25
+ }
26
+ /** Pure render-context builder for a booking email. @internal */
27
+ export function buildEmailContext(booking, config, now, extra = {}) {
28
+ const portalUrl = config.portalUrlTemplate
29
+ ? config.portalUrlTemplate.replace('{token}', booking.accessToken)
30
+ : undefined;
31
+ return { booking, currency: config.currency, brand: config.brand, portalUrl, now, ...extra };
32
+ }
33
+ /**
34
+ * Send a transactional booking email to the booker. No-op when no email adapter
35
+ * is configured, the booking is missing, or it has no customer email. `extra` is
36
+ * merged into the render context (e.g. `{ amount, paymentLink }` for payment-link).
37
+ */
38
+ export async function sendBookingEmail(bookingId, kind, extra = {}) {
39
+ const cms = getCMS();
40
+ const email = cms.emailAdapter;
41
+ if (!email)
42
+ return;
43
+ const config = requireBookingConfig();
44
+ const db = getBookingDb();
45
+ const [booking] = await db.select().from(bookingsTable).where(eq(bookingsTable.id, bookingId));
46
+ if (!booking)
47
+ return;
48
+ const lang = booking.language ?? cms.languages[0] ?? 'pl';
49
+ const to = String(booking.customer.email ?? '');
50
+ if (!to)
51
+ return;
52
+ const context = buildEmailContext(booking, {
53
+ currency: config.currency,
54
+ brand: config.brand,
55
+ portalUrlTemplate: config.portalUrlTemplate
56
+ }, new Date(), extra);
57
+ const html = await renderBookingEmail(kind, lang, context);
58
+ await email.sendMail({ to, subject: subjectFor(kind, lang), html });
59
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Booking email template registry — file-based Handlebars with project override
3
+ * → package default → fallback-lang resolution. Self-contained (no shop / no
4
+ * CMS-singleton auto-fetch) to keep booking independent of the shop module.
5
+ *
6
+ * @internal — exported for testing; not part of the public API.
7
+ */
8
+ type Lang = string;
9
+ /** Long pl-PL (or given locale) date; '' for null/invalid. @internal */
10
+ export declare function formatBookingDate(value: unknown, locale?: string): string;
11
+ /** Whole days from `now` to `value`, floored at 0; '' for invalid. @internal */
12
+ export declare function daysUntilDate(value: unknown, now: unknown): string;
13
+ /**
14
+ * Register `header`/`footer` partials for a render. Re-registers on every call
15
+ * (cheap — source is cached in prod) so booking's partials always win over the
16
+ * shop module's identically-named partials right before a booking render.
17
+ * @internal
18
+ */
19
+ export declare function registerBookingPartials(projectDir: string, lang: Lang, fallbackLang: Lang): void;
20
+ /**
21
+ * Render a booking email template by name + language. Project overrides live in
22
+ * `<projectDir>/<name>.<lang>.html` (default `src/emails/booking`); package
23
+ * defaults ship under `booking/templates`.
24
+ */
25
+ export declare function renderEmailTemplate(name: string, lang: Lang, context: Record<string, unknown>, opts?: {
26
+ projectDir?: string;
27
+ fallbackLang?: Lang;
28
+ }): string;
29
+ export {};
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Booking email template registry — file-based Handlebars with project override
3
+ * → package default → fallback-lang resolution. Self-contained (no shop / no
4
+ * CMS-singleton auto-fetch) to keep booking independent of the shop module.
5
+ *
6
+ * @internal — exported for testing; not part of the public API.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import Handlebars from 'handlebars';
12
+ const PACKAGE_TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
13
+ const cache = new Map();
14
+ let helpersRegistered = false;
15
+ /** Long pl-PL (or given locale) date; '' for null/invalid. @internal */
16
+ export function formatBookingDate(value, locale = 'pl-PL') {
17
+ const d = value instanceof Date
18
+ ? value
19
+ : typeof value === 'string' || typeof value === 'number'
20
+ ? new Date(value)
21
+ : null;
22
+ if (!d || Number.isNaN(d.getTime()))
23
+ return '';
24
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'Europe/Warsaw' }).format(d);
25
+ }
26
+ /** Whole days from `now` to `value`, floored at 0; '' for invalid. @internal */
27
+ export function daysUntilDate(value, now) {
28
+ const parse = (v) => v instanceof Date ? v : typeof v === 'string' || typeof v === 'number' ? new Date(v) : null;
29
+ const d = parse(value);
30
+ const n = parse(now);
31
+ if (!d || Number.isNaN(d.getTime()) || !n || Number.isNaN(n.getTime()))
32
+ return '';
33
+ return String(Math.max(0, Math.ceil((d.getTime() - n.getTime()) / 86_400_000)));
34
+ }
35
+ function registerHelpers() {
36
+ if (helpersRegistered)
37
+ return;
38
+ Handlebars.registerHelper('currency', (value, currency) => {
39
+ const num = typeof value === 'number' ? value : Number(value);
40
+ const cur = typeof currency === 'string' ? currency : 'PLN';
41
+ if (!Number.isFinite(num))
42
+ return '';
43
+ // Amounts are minor units (grosze) → convert to major for display.
44
+ return new Intl.NumberFormat('pl-PL', { style: 'currency', currency: cur }).format(num / 100);
45
+ });
46
+ Handlebars.registerHelper('date', (value, locale) => formatBookingDate(value, typeof locale === 'string' ? locale : 'pl-PL'));
47
+ Handlebars.registerHelper('daysUntil', (value, now) => daysUntilDate(value, now));
48
+ helpersRegistered = true;
49
+ }
50
+ function resolveTemplateSource(name, lang, fallbackLang, projectDir) {
51
+ const candidates = [
52
+ path.join(projectDir, `${name}.${lang}.html`),
53
+ path.join(projectDir, `${name}.html`),
54
+ path.join(PACKAGE_TEMPLATES_DIR, `${name}.${lang}.html`),
55
+ path.join(PACKAGE_TEMPLATES_DIR, `${name}.${fallbackLang}.html`)
56
+ ];
57
+ for (const filePath of candidates) {
58
+ try {
59
+ return fs.readFileSync(filePath, 'utf8');
60
+ }
61
+ catch {
62
+ // try next candidate
63
+ }
64
+ }
65
+ throw new Error(`[booking] No email template for "${name}" (lang=${lang}, fallback=${fallbackLang}). ` +
66
+ `Looked in "${projectDir}" and package defaults.`);
67
+ }
68
+ const partialSourceCache = new Map();
69
+ function readPartialSource(projectDir, name, lang, fallbackLang) {
70
+ const key = `${projectDir}|${name}|${lang}|${fallbackLang}`;
71
+ if (process.env.NODE_ENV === 'production' && partialSourceCache.has(key)) {
72
+ return partialSourceCache.get(key);
73
+ }
74
+ const candidates = [
75
+ path.join(projectDir, '_partials', `${name}.${lang}.html`),
76
+ path.join(projectDir, '_partials', `${name}.${fallbackLang}.html`),
77
+ path.join(PACKAGE_TEMPLATES_DIR, '_partials', `${name}.${lang}.html`),
78
+ path.join(PACKAGE_TEMPLATES_DIR, '_partials', `${name}.${fallbackLang}.html`)
79
+ ];
80
+ for (const filePath of candidates) {
81
+ try {
82
+ const source = fs.readFileSync(filePath, 'utf8');
83
+ partialSourceCache.set(key, source);
84
+ return source;
85
+ }
86
+ catch {
87
+ // try next candidate
88
+ }
89
+ }
90
+ partialSourceCache.set(key, null);
91
+ return null;
92
+ }
93
+ /**
94
+ * Register `header`/`footer` partials for a render. Re-registers on every call
95
+ * (cheap — source is cached in prod) so booking's partials always win over the
96
+ * shop module's identically-named partials right before a booking render.
97
+ * @internal
98
+ */
99
+ export function registerBookingPartials(projectDir, lang, fallbackLang) {
100
+ for (const name of ['header', 'footer']) {
101
+ const source = readPartialSource(projectDir, name, lang, fallbackLang);
102
+ if (source != null)
103
+ Handlebars.registerPartial(name, source);
104
+ }
105
+ }
106
+ /**
107
+ * Render a booking email template by name + language. Project overrides live in
108
+ * `<projectDir>/<name>.<lang>.html` (default `src/emails/booking`); package
109
+ * defaults ship under `booking/templates`.
110
+ */
111
+ export function renderEmailTemplate(name, lang, context, opts = {}) {
112
+ registerHelpers();
113
+ const fallbackLang = opts.fallbackLang ?? 'pl';
114
+ const projectDir = opts.projectDir ?? path.join(process.cwd(), 'src/emails/booking');
115
+ registerBookingPartials(projectDir, lang, fallbackLang);
116
+ const key = `${projectDir}|${name}|${lang}|${fallbackLang}`;
117
+ let compiled = cache.get(key);
118
+ if (!compiled) {
119
+ const source = resolveTemplateSource(name, lang, fallbackLang, projectDir);
120
+ compiled = Handlebars.compile(source, { noEscape: false });
121
+ if (process.env.NODE_ENV === 'production')
122
+ cache.set(key, compiled);
123
+ }
124
+ return compiled(context);
125
+ }
@@ -0,0 +1,5 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ /** Public self-serve booking creation. Mount at `POST /api/booking`. */
3
+ export declare function createBookingHandler(): {
4
+ POST: RequestHandler;
5
+ };
@@ -0,0 +1,68 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { getCMS } from '../../../core/cms.js';
3
+ import { checkRateLimit, clientKey } from '../../../server/security/rate-limit-handler.js';
4
+ import { createBooking } from '../bookings.js';
5
+ import { createDepositPayment } from '../payments.js';
6
+ function bookingEnabled() {
7
+ try {
8
+ return getCMS().hasModule('booking');
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /** Public self-serve booking creation. Mount at `POST /api/booking`. */
15
+ export function createBookingHandler() {
16
+ return {
17
+ POST: async ({ request }) => {
18
+ if (!bookingEnabled())
19
+ return json({ error: 'Booking not enabled' }, { status: 404 });
20
+ const rl = checkRateLimit(clientKey(request, 'booking-create'), { limit: 5, windowSec: 60 });
21
+ if (!rl.allowed)
22
+ return json({ error: 'Rate limit exceeded' }, { status: 429 });
23
+ const body = (await request.json().catch(() => null));
24
+ if (!body || !Array.isArray(body.items) || body.items.length === 0) {
25
+ return json({ error: 'items required' }, { status: 400 });
26
+ }
27
+ try {
28
+ const persons = Array.isArray(body.persons)
29
+ ? body.persons.map((p) => ({
30
+ ref: String(p.ref),
31
+ identity: p.identity && typeof p.identity === 'object' ? p.identity : {}
32
+ }))
33
+ : [];
34
+ const items = body.items.map((it) => ({
35
+ tripRef: String(it.tripRef),
36
+ unitPrice: Number(it.unitPrice) || 0,
37
+ departureDate: it.departureDate ? new Date(it.departureDate) : null,
38
+ assignments: Array.isArray(it.assignments)
39
+ ? it.assignments.map((a) => ({
40
+ personRef: String(a.personRef),
41
+ data: a.data && typeof a.data === 'object' ? a.data : undefined
42
+ }))
43
+ : []
44
+ }));
45
+ const { booking, accessToken } = await createBooking({
46
+ customer: body.customer ?? {},
47
+ persons,
48
+ items,
49
+ language: body.language
50
+ });
51
+ const payment = await createDepositPayment(booking.id);
52
+ return json({
53
+ bookingId: booking.id,
54
+ number: booking.number,
55
+ accessToken,
56
+ status: booking.status,
57
+ totals: booking.totals,
58
+ paymentStatus: payment.status,
59
+ requiresPaymentRedirect: payment.status === 'redirect',
60
+ redirectUrl: payment.redirectUrl
61
+ });
62
+ }
63
+ catch (err) {
64
+ return json({ error: err instanceof Error ? err.message : 'Booking failed' }, { status: 400 });
65
+ }
66
+ }
67
+ };
68
+ }
@@ -0,0 +1,4 @@
1
+ export { createBookingHandler } from './create-handler.js';
2
+ export { createWebhookHandler } from './webhook-handler.js';
3
+ export { createPortalHandler } from './portal-handler.js';
4
+ export { createMaintenanceHandler } from './maintenance-handler.js';