@voyantjs/bookings 0.1.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 (42) hide show
  1. package/LICENSE +109 -0
  2. package/README.md +42 -0
  3. package/dist/availability-ref.d.ts +418 -0
  4. package/dist/availability-ref.d.ts.map +1 -0
  5. package/dist/availability-ref.js +28 -0
  6. package/dist/extensions/suppliers.d.ts +3 -0
  7. package/dist/extensions/suppliers.d.ts.map +1 -0
  8. package/dist/extensions/suppliers.js +103 -0
  9. package/dist/index.d.ts +20 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +25 -0
  12. package/dist/pii.d.ts +29 -0
  13. package/dist/pii.d.ts.map +1 -0
  14. package/dist/pii.js +131 -0
  15. package/dist/products-ref.d.ts +1043 -0
  16. package/dist/products-ref.d.ts.map +1 -0
  17. package/dist/products-ref.js +76 -0
  18. package/dist/routes.d.ts +2171 -0
  19. package/dist/routes.d.ts.map +1 -0
  20. package/dist/routes.js +659 -0
  21. package/dist/schema/travel-details.d.ts +179 -0
  22. package/dist/schema/travel-details.d.ts.map +1 -0
  23. package/dist/schema/travel-details.js +46 -0
  24. package/dist/schema.d.ts +3180 -0
  25. package/dist/schema.d.ts.map +1 -0
  26. package/dist/schema.js +509 -0
  27. package/dist/service.d.ts +5000 -0
  28. package/dist/service.d.ts.map +1 -0
  29. package/dist/service.js +2016 -0
  30. package/dist/tasks/expire-stale-holds.d.ts +12 -0
  31. package/dist/tasks/expire-stale-holds.d.ts.map +1 -0
  32. package/dist/tasks/expire-stale-holds.js +7 -0
  33. package/dist/tasks/index.d.ts +2 -0
  34. package/dist/tasks/index.d.ts.map +1 -0
  35. package/dist/tasks/index.js +1 -0
  36. package/dist/transactions-ref.d.ts +2223 -0
  37. package/dist/transactions-ref.d.ts.map +1 -0
  38. package/dist/transactions-ref.js +147 -0
  39. package/dist/validation.d.ts +643 -0
  40. package/dist/validation.d.ts.map +1 -0
  41. package/dist/validation.js +355 -0
  42. package/package.json +68 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qCAi3BtB,CAAA;AAEJ,MAAM,MAAM,aAAa,GAAG,OAAO,aAAa,CAAA"}
package/dist/routes.js ADDED
@@ -0,0 +1,659 @@
1
+ import { Hono } from "hono";
2
+ import { createKmsProviderFromEnv } from "@voyantjs/utils";
3
+ import { createBookingPiiService } from "./pii.js";
4
+ import { bookingsService } from "./service.js";
5
+ import { bookingPiiAccessLog } from "./schema.js";
6
+ import { bookingListQuerySchema, cancelBookingSchema, confirmBookingSchema, createBookingSchema, convertProductSchema, expireBookingSchema, expireStaleBookingsSchema, extendBookingHoldSchema, reserveBookingFromTransactionSchema, insertBookingFulfillmentSchema, insertBookingDocumentSchema, insertBookingItemParticipantSchema, insertBookingItemSchema, insertBookingNoteSchema, insertParticipantSchema, insertPassengerSchema, insertSupplierStatusSchema, recordBookingRedemptionSchema, reserveBookingSchema, upsertParticipantTravelDetailsSchema, updateBookingFulfillmentSchema, updateSupplierStatusSchema, updateBookingItemSchema, updateBookingSchema, updateBookingStatusSchema, updateParticipantSchema, updatePassengerSchema, } from "./validation.js";
7
+ function getRuntimeEnv(c) {
8
+ const processEnv = globalThis.process?.env ?? {};
9
+ return {
10
+ ...processEnv,
11
+ ...(c.env ?? {}),
12
+ };
13
+ }
14
+ function hasPiiScope(scopes, action) {
15
+ if (!scopes || scopes.length === 0) {
16
+ return false;
17
+ }
18
+ return (scopes.includes("*") ||
19
+ scopes.includes("bookings-pii:*") ||
20
+ scopes.includes(`bookings-pii:${action}`));
21
+ }
22
+ async function logBookingPiiAccess(c, input) {
23
+ await c.get("db").insert(bookingPiiAccessLog).values({
24
+ bookingId: input.bookingId ?? null,
25
+ participantId: input.participantId ?? null,
26
+ actorId: c.get("userId") ?? null,
27
+ actorType: c.get("actor") ?? null,
28
+ callerType: c.get("callerType") ?? null,
29
+ action: input.action,
30
+ outcome: input.outcome,
31
+ reason: input.reason ?? null,
32
+ metadata: input.metadata ?? null,
33
+ });
34
+ }
35
+ async function authorizeBookingPiiAccess(c, input) {
36
+ if (c.get("isInternalRequest")) {
37
+ return { allowed: true };
38
+ }
39
+ const userId = c.get("userId");
40
+ if (!userId) {
41
+ await logBookingPiiAccess(c, {
42
+ ...input,
43
+ outcome: "denied",
44
+ reason: "missing_user",
45
+ });
46
+ return { allowed: false, response: c.json({ error: "Unauthorized" }, 401) };
47
+ }
48
+ const customAuthorizer = c.get("authorizeBookingPii");
49
+ if (customAuthorizer) {
50
+ const allowed = await customAuthorizer({
51
+ db: c.get("db"),
52
+ userId,
53
+ actor: c.get("actor"),
54
+ callerType: c.get("callerType"),
55
+ scopes: c.get("scopes"),
56
+ isInternalRequest: c.get("isInternalRequest"),
57
+ ...input,
58
+ });
59
+ if (!allowed) {
60
+ await logBookingPiiAccess(c, {
61
+ ...input,
62
+ outcome: "denied",
63
+ reason: "custom_policy_denied",
64
+ });
65
+ return { allowed: false, response: c.json({ error: "Forbidden" }, 403) };
66
+ }
67
+ return { allowed: true };
68
+ }
69
+ const actor = c.get("actor");
70
+ const scopes = c.get("scopes");
71
+ const allowed = hasPiiScope(scopes, input.action) || actor === "staff";
72
+ if (!allowed) {
73
+ await logBookingPiiAccess(c, {
74
+ ...input,
75
+ outcome: "denied",
76
+ reason: "insufficient_scope",
77
+ metadata: { actor: actor ?? null },
78
+ });
79
+ return { allowed: false, response: c.json({ error: "Forbidden" }, 403) };
80
+ }
81
+ return { allowed: true };
82
+ }
83
+ function handleKmsConfigError(c, error) {
84
+ if (error instanceof Error) {
85
+ return c.json({
86
+ error: "Booking PII encryption is not configured",
87
+ details: error.message,
88
+ }, 500);
89
+ }
90
+ return c.json({ error: "Booking PII encryption is not configured" }, 500);
91
+ }
92
+ // ==========================================================================
93
+ // Bookings — method-chained for Hono RPC type inference
94
+ // ==========================================================================
95
+ export const bookingRoutes = new Hono()
96
+ // ==========================================================================
97
+ // Bookings CRUD
98
+ // ==========================================================================
99
+ // 1. GET / — List bookings
100
+ .get("/", async (c) => {
101
+ const query = bookingListQuerySchema.parse(Object.fromEntries(new URL(c.req.url).searchParams));
102
+ return c.json(await bookingsService.listBookings(c.get("db"), query));
103
+ })
104
+ // 2. GET /:id — Get single booking
105
+ .get("/:id", async (c) => {
106
+ const row = await bookingsService.getBookingById(c.get("db"), c.req.param("id"));
107
+ if (!row) {
108
+ return c.json({ error: "Booking not found" }, 404);
109
+ }
110
+ return c.json({ data: row });
111
+ })
112
+ // 3. POST /reserve — Reserve inventory and create on-hold booking
113
+ .post("/reserve", async (c) => {
114
+ const result = await bookingsService.reserveBooking(c.get("db"), reserveBookingSchema.parse(await c.req.json()), c.get("userId"));
115
+ if ("booking" in result) {
116
+ return c.json({ data: result.booking }, 201);
117
+ }
118
+ if (result.status === "slot_not_found") {
119
+ return c.json({ error: "Availability slot not found" }, 404);
120
+ }
121
+ if (result.status === "insufficient_capacity") {
122
+ return c.json({ error: "Insufficient slot capacity" }, 409);
123
+ }
124
+ if (result.status === "slot_unavailable") {
125
+ return c.json({ error: "Availability slot is not bookable" }, 409);
126
+ }
127
+ if (result.status === "slot_product_mismatch" || result.status === "slot_option_mismatch") {
128
+ return c.json({ error: "Reservation item does not match availability slot" }, 409);
129
+ }
130
+ return c.json({ error: "Unable to reserve booking" }, 400);
131
+ })
132
+ // 3a. POST /from-product — Create booking draft from product definition
133
+ .post("/from-product", async (c) => {
134
+ const row = await bookingsService.createBookingFromProduct(c.get("db"), convertProductSchema.parse(await c.req.json()), c.get("userId"));
135
+ if (!row) {
136
+ return c.json({ error: "Product or option not found" }, 404);
137
+ }
138
+ return c.json({ data: row }, 201);
139
+ })
140
+ // 3b. POST /from-offer/:offerId/reserve — Reserve booking from transaction offer
141
+ .post("/from-offer/:offerId/reserve", async (c) => {
142
+ const result = await bookingsService.reserveBookingFromOffer(c.get("db"), c.req.param("offerId"), reserveBookingFromTransactionSchema.parse(await c.req.json()), c.get("userId"));
143
+ if (result.status === "not_found") {
144
+ return c.json({ error: "Offer not found" }, 404);
145
+ }
146
+ if (result.status === "slot_not_found") {
147
+ return c.json({ error: "Availability slot not found" }, 404);
148
+ }
149
+ if (result.status === "insufficient_capacity") {
150
+ return c.json({ error: "Insufficient slot capacity" }, 409);
151
+ }
152
+ if (result.status === "slot_unavailable") {
153
+ return c.json({ error: "Availability slot is not bookable" }, 409);
154
+ }
155
+ if (result.status === "slot_product_mismatch" || result.status === "slot_option_mismatch") {
156
+ return c.json({ error: "Reservation item does not match availability slot" }, 409);
157
+ }
158
+ if ("booking" in result) {
159
+ return c.json({ data: result.booking }, 201);
160
+ }
161
+ return c.json({ error: "Unable to reserve booking from offer" }, 400);
162
+ })
163
+ // 3c. POST /from-order/:orderId/reserve — Reserve booking from transaction order
164
+ .post("/from-order/:orderId/reserve", async (c) => {
165
+ const result = await bookingsService.reserveBookingFromOrder(c.get("db"), c.req.param("orderId"), reserveBookingFromTransactionSchema.parse(await c.req.json()), c.get("userId"));
166
+ if (result.status === "not_found") {
167
+ return c.json({ error: "Order not found" }, 404);
168
+ }
169
+ if (result.status === "slot_not_found") {
170
+ return c.json({ error: "Availability slot not found" }, 404);
171
+ }
172
+ if (result.status === "insufficient_capacity") {
173
+ return c.json({ error: "Insufficient slot capacity" }, 409);
174
+ }
175
+ if (result.status === "slot_unavailable") {
176
+ return c.json({ error: "Availability slot is not bookable" }, 409);
177
+ }
178
+ if (result.status === "slot_product_mismatch" || result.status === "slot_option_mismatch") {
179
+ return c.json({ error: "Reservation item does not match availability slot" }, 409);
180
+ }
181
+ if ("booking" in result) {
182
+ return c.json({ data: result.booking }, 201);
183
+ }
184
+ return c.json({ error: "Unable to reserve booking from order" }, 400);
185
+ })
186
+ // 4. POST / — Create booking (manual/backoffice only)
187
+ .post("/", async (c) => {
188
+ const payload = await c.req.json();
189
+ const parsed = createBookingSchema.safeParse(payload);
190
+ if (!parsed.success) {
191
+ return c.json({
192
+ error: parsed.error.issues[0]?.message ?? "Invalid booking create payload",
193
+ details: parsed.error.flatten(),
194
+ }, 400);
195
+ }
196
+ return c.json({
197
+ data: await bookingsService.createBooking(c.get("db"), parsed.data, c.get("userId")),
198
+ }, 201);
199
+ })
200
+ // 5. PATCH /:id — Update booking
201
+ .patch("/:id", async (c) => {
202
+ const row = await bookingsService.updateBooking(c.get("db"), c.req.param("id"), updateBookingSchema.parse(await c.req.json()));
203
+ if (!row) {
204
+ return c.json({ error: "Booking not found" }, 404);
205
+ }
206
+ return c.json({ data: row });
207
+ })
208
+ // 6. DELETE /:id — Delete booking
209
+ .delete("/:id", async (c) => {
210
+ const row = await bookingsService.deleteBooking(c.get("db"), c.req.param("id"));
211
+ if (!row) {
212
+ return c.json({ error: "Booking not found" }, 404);
213
+ }
214
+ return c.json({ success: true }, 200);
215
+ })
216
+ // ==========================================================================
217
+ // Status
218
+ // ==========================================================================
219
+ // 7. PATCH /:id/status — Change booking status
220
+ .patch("/:id/status", async (c) => {
221
+ const result = await bookingsService.updateBookingStatus(c.get("db"), c.req.param("id"), updateBookingStatusSchema.parse(await c.req.json()), c.get("userId"));
222
+ if (result.status === "not_found") {
223
+ return c.json({ error: "Booking not found" }, 404);
224
+ }
225
+ if (result.status === "invalid_transition") {
226
+ return c.json({ error: "Invalid booking status transition" }, 409);
227
+ }
228
+ if ("booking" in result) {
229
+ return c.json({ data: result.booking });
230
+ }
231
+ return c.json({ error: "Unable to update booking status" }, 400);
232
+ })
233
+ // 8. POST /:id/confirm — Confirm an on-hold booking
234
+ .post("/:id/confirm", async (c) => {
235
+ const result = await bookingsService.confirmBooking(c.get("db"), c.req.param("id"), confirmBookingSchema.parse(await c.req.json()), c.get("userId"));
236
+ if (result.status === "not_found") {
237
+ return c.json({ error: "Booking not found" }, 404);
238
+ }
239
+ if (result.status === "hold_expired") {
240
+ return c.json({ error: "Booking hold has expired" }, 409);
241
+ }
242
+ if (result.status === "invalid_transition") {
243
+ return c.json({ error: "Booking is not in an on-hold state" }, 409);
244
+ }
245
+ if ("booking" in result) {
246
+ return c.json({ data: result.booking });
247
+ }
248
+ return c.json({ error: "Unable to confirm booking" }, 400);
249
+ })
250
+ // 9. POST /:id/extend-hold — Extend booking hold expiry
251
+ .post("/:id/extend-hold", async (c) => {
252
+ const result = await bookingsService.extendBookingHold(c.get("db"), c.req.param("id"), extendBookingHoldSchema.parse(await c.req.json()), c.get("userId"));
253
+ if (result.status === "not_found") {
254
+ return c.json({ error: "Booking not found" }, 404);
255
+ }
256
+ if (result.status === "hold_expired") {
257
+ return c.json({ error: "Booking hold has expired" }, 409);
258
+ }
259
+ if (result.status === "invalid_transition") {
260
+ return c.json({ error: "Booking is not in an on-hold state" }, 409);
261
+ }
262
+ if ("booking" in result) {
263
+ return c.json({ data: result.booking });
264
+ }
265
+ return c.json({ error: "Unable to extend booking hold" }, 400);
266
+ })
267
+ // 10. POST /:id/expire — Expire an on-hold booking
268
+ .post("/:id/expire", async (c) => {
269
+ const result = await bookingsService.expireBooking(c.get("db"), c.req.param("id"), expireBookingSchema.parse(await c.req.json()), c.get("userId"));
270
+ if (result.status === "not_found") {
271
+ return c.json({ error: "Booking not found" }, 404);
272
+ }
273
+ if (result.status === "invalid_transition") {
274
+ return c.json({ error: "Booking is not in an on-hold state" }, 409);
275
+ }
276
+ if ("booking" in result) {
277
+ return c.json({ data: result.booking });
278
+ }
279
+ return c.json({ error: "Unable to expire booking" }, 400);
280
+ })
281
+ // 10b. POST /expire-stale — Expire all stale on-hold bookings up to a cutoff
282
+ .post("/expire-stale", async (c) => {
283
+ return c.json(await bookingsService.expireStaleBookings(c.get("db"), expireStaleBookingsSchema.parse(await c.req.json()), c.get("userId")));
284
+ })
285
+ // 11. POST /:id/cancel — Cancel a booking and release allocations
286
+ .post("/:id/cancel", async (c) => {
287
+ const result = await bookingsService.cancelBooking(c.get("db"), c.req.param("id"), cancelBookingSchema.parse(await c.req.json()), c.get("userId"));
288
+ if (result.status === "not_found") {
289
+ return c.json({ error: "Booking not found" }, 404);
290
+ }
291
+ if (result.status === "invalid_transition") {
292
+ return c.json({ error: "Booking cannot be cancelled from its current state" }, 409);
293
+ }
294
+ if ("booking" in result) {
295
+ return c.json({ data: result.booking });
296
+ }
297
+ return c.json({ error: "Unable to cancel booking" }, 400);
298
+ })
299
+ // ==========================================================================
300
+ // Participants
301
+ // ==========================================================================
302
+ // 12. GET /:id/allocations — List booking allocations
303
+ .get("/:id/allocations", async (c) => {
304
+ return c.json({ data: await bookingsService.listAllocations(c.get("db"), c.req.param("id")) });
305
+ })
306
+ // 13. GET /:id/participants — List participants
307
+ .get("/:id/participants", async (c) => {
308
+ return c.json({ data: await bookingsService.listParticipants(c.get("db"), c.req.param("id")) });
309
+ })
310
+ // 13a. GET /:id/participants/:participantId/travel-details — Read encrypted travel details
311
+ .get("/:id/participants/:participantId/travel-details", async (c) => {
312
+ const auth = await authorizeBookingPiiAccess(c, {
313
+ bookingId: c.req.param("id"),
314
+ participantId: c.req.param("participantId"),
315
+ action: "read",
316
+ });
317
+ if (!auth.allowed) {
318
+ return auth.response;
319
+ }
320
+ const participant = await bookingsService.getParticipantById(c.get("db"), c.req.param("id"), c.req.param("participantId"));
321
+ if (!participant) {
322
+ await logBookingPiiAccess(c, {
323
+ bookingId: c.req.param("id"),
324
+ participantId: c.req.param("participantId"),
325
+ action: "read",
326
+ outcome: "denied",
327
+ reason: "participant_not_found",
328
+ });
329
+ return c.json({ error: "Participant not found" }, 404);
330
+ }
331
+ try {
332
+ const pii = createBookingPiiService({
333
+ kms: createKmsProviderFromEnv(getRuntimeEnv(c)),
334
+ onAudit: async (event) => {
335
+ await logBookingPiiAccess(c, {
336
+ bookingId: participant.bookingId,
337
+ participantId: event.participantId,
338
+ action: event.action === "encrypt" ? "update" : event.action === "decrypt" ? "read" : event.action,
339
+ outcome: "allowed",
340
+ });
341
+ },
342
+ });
343
+ const details = await pii.getParticipantTravelDetails(c.get("db"), participant.id, c.get("userId"));
344
+ if (!details) {
345
+ await logBookingPiiAccess(c, {
346
+ bookingId: participant.bookingId,
347
+ participantId: participant.id,
348
+ action: "read",
349
+ outcome: "denied",
350
+ reason: "travel_details_not_found",
351
+ });
352
+ return c.json({ error: "Participant travel details not found" }, 404);
353
+ }
354
+ return c.json({ data: details });
355
+ }
356
+ catch (error) {
357
+ return handleKmsConfigError(c, error);
358
+ }
359
+ })
360
+ // 9. POST /:id/participants — Add participant
361
+ .post("/:id/participants", async (c) => {
362
+ const row = await bookingsService.createParticipant(c.get("db"), c.req.param("id"), insertParticipantSchema.parse(await c.req.json()), c.get("userId"));
363
+ if (!row) {
364
+ return c.json({ error: "Booking not found" }, 404);
365
+ }
366
+ return c.json({ data: row }, 201);
367
+ })
368
+ // 9a. PATCH /:id/participants/:participantId/travel-details — Upsert encrypted travel details
369
+ .patch("/:id/participants/:participantId/travel-details", async (c) => {
370
+ const auth = await authorizeBookingPiiAccess(c, {
371
+ bookingId: c.req.param("id"),
372
+ participantId: c.req.param("participantId"),
373
+ action: "update",
374
+ });
375
+ if (!auth.allowed) {
376
+ return auth.response;
377
+ }
378
+ const participant = await bookingsService.getParticipantById(c.get("db"), c.req.param("id"), c.req.param("participantId"));
379
+ if (!participant) {
380
+ await logBookingPiiAccess(c, {
381
+ bookingId: c.req.param("id"),
382
+ participantId: c.req.param("participantId"),
383
+ action: "update",
384
+ outcome: "denied",
385
+ reason: "participant_not_found",
386
+ });
387
+ return c.json({ error: "Participant not found" }, 404);
388
+ }
389
+ try {
390
+ const pii = createBookingPiiService({
391
+ kms: createKmsProviderFromEnv(getRuntimeEnv(c)),
392
+ onAudit: async (event) => {
393
+ await logBookingPiiAccess(c, {
394
+ bookingId: participant.bookingId,
395
+ participantId: event.participantId,
396
+ action: event.action === "encrypt" ? "update" : event.action === "decrypt" ? "read" : event.action,
397
+ outcome: "allowed",
398
+ });
399
+ },
400
+ });
401
+ const row = await pii.upsertParticipantTravelDetails(c.get("db"), participant.id, upsertParticipantTravelDetailsSchema.parse(await c.req.json()), c.get("userId"));
402
+ if (!row) {
403
+ return c.json({ error: "Participant not found" }, 404);
404
+ }
405
+ return c.json({ data: row });
406
+ }
407
+ catch (error) {
408
+ return handleKmsConfigError(c, error);
409
+ }
410
+ })
411
+ // 10. PATCH /:id/participants/:participantId — Update participant
412
+ .patch("/:id/participants/:participantId", async (c) => {
413
+ const row = await bookingsService.updateParticipant(c.get("db"), c.req.param("participantId"), updateParticipantSchema.parse(await c.req.json()));
414
+ if (!row) {
415
+ return c.json({ error: "Participant not found" }, 404);
416
+ }
417
+ return c.json({ data: row });
418
+ })
419
+ // 10a. DELETE /:id/participants/:participantId/travel-details — Delete encrypted travel details
420
+ .delete("/:id/participants/:participantId/travel-details", async (c) => {
421
+ const auth = await authorizeBookingPiiAccess(c, {
422
+ bookingId: c.req.param("id"),
423
+ participantId: c.req.param("participantId"),
424
+ action: "delete",
425
+ });
426
+ if (!auth.allowed) {
427
+ return auth.response;
428
+ }
429
+ const participant = await bookingsService.getParticipantById(c.get("db"), c.req.param("id"), c.req.param("participantId"));
430
+ if (!participant) {
431
+ await logBookingPiiAccess(c, {
432
+ bookingId: c.req.param("id"),
433
+ participantId: c.req.param("participantId"),
434
+ action: "delete",
435
+ outcome: "denied",
436
+ reason: "participant_not_found",
437
+ });
438
+ return c.json({ error: "Participant not found" }, 404);
439
+ }
440
+ try {
441
+ const pii = createBookingPiiService({
442
+ kms: createKmsProviderFromEnv(getRuntimeEnv(c)),
443
+ onAudit: async (event) => {
444
+ await logBookingPiiAccess(c, {
445
+ bookingId: participant.bookingId,
446
+ participantId: event.participantId,
447
+ action: event.action === "encrypt" ? "update" : event.action === "decrypt" ? "read" : event.action,
448
+ outcome: "allowed",
449
+ });
450
+ },
451
+ });
452
+ const row = await pii.deleteParticipantTravelDetails(c.get("db"), participant.id, c.get("userId"));
453
+ if (!row) {
454
+ return c.json({ error: "Participant travel details not found" }, 404);
455
+ }
456
+ return c.json({ success: true }, 200);
457
+ }
458
+ catch (error) {
459
+ return handleKmsConfigError(c, error);
460
+ }
461
+ })
462
+ // 11. DELETE /:id/participants/:participantId — Delete participant
463
+ .delete("/:id/participants/:participantId", async (c) => {
464
+ const row = await bookingsService.deleteParticipant(c.get("db"), c.req.param("participantId"));
465
+ if (!row) {
466
+ return c.json({ error: "Participant not found" }, 404);
467
+ }
468
+ return c.json({ success: true }, 200);
469
+ })
470
+ // ==========================================================================
471
+ // Passengers (legacy compatibility)
472
+ // ==========================================================================
473
+ // 12. GET /:id/passengers — List passengers
474
+ .get("/:id/passengers", async (c) => {
475
+ return c.json({ data: await bookingsService.listPassengers(c.get("db"), c.req.param("id")) });
476
+ })
477
+ // 13. POST /:id/passengers — Add passenger
478
+ .post("/:id/passengers", async (c) => {
479
+ const row = await bookingsService.createPassenger(c.get("db"), c.req.param("id"), insertPassengerSchema.parse(await c.req.json()), c.get("userId"));
480
+ if (!row) {
481
+ return c.json({ error: "Booking not found" }, 404);
482
+ }
483
+ return c.json({ data: row }, 201);
484
+ })
485
+ // 14. PATCH /:id/passengers/:passengerId — Update passenger
486
+ .patch("/:id/passengers/:passengerId", async (c) => {
487
+ const row = await bookingsService.updatePassenger(c.get("db"), c.req.param("passengerId"), updatePassengerSchema.parse(await c.req.json()));
488
+ if (!row) {
489
+ return c.json({ error: "Passenger not found" }, 404);
490
+ }
491
+ return c.json({ data: row });
492
+ })
493
+ // 15. DELETE /:id/passengers/:passengerId — Delete passenger
494
+ .delete("/:id/passengers/:passengerId", async (c) => {
495
+ const row = await bookingsService.deletePassenger(c.get("db"), c.req.param("passengerId"));
496
+ if (!row) {
497
+ return c.json({ error: "Passenger not found" }, 404);
498
+ }
499
+ return c.json({ success: true }, 200);
500
+ })
501
+ // ==========================================================================
502
+ // Items
503
+ // ==========================================================================
504
+ // 16. GET /:id/items — List booking items
505
+ .get("/:id/items", async (c) => {
506
+ return c.json({ data: await bookingsService.listItems(c.get("db"), c.req.param("id")) });
507
+ })
508
+ // 17. POST /:id/items — Add booking item
509
+ .post("/:id/items", async (c) => {
510
+ const row = await bookingsService.createItem(c.get("db"), c.req.param("id"), insertBookingItemSchema.parse(await c.req.json()), c.get("userId"));
511
+ if (!row) {
512
+ return c.json({ error: "Booking not found" }, 404);
513
+ }
514
+ return c.json({ data: row }, 201);
515
+ })
516
+ // 18. PATCH /:id/items/:itemId — Update booking item
517
+ .patch("/:id/items/:itemId", async (c) => {
518
+ const row = await bookingsService.updateItem(c.get("db"), c.req.param("itemId"), updateBookingItemSchema.parse(await c.req.json()));
519
+ if (!row) {
520
+ return c.json({ error: "Booking item not found" }, 404);
521
+ }
522
+ return c.json({ data: row });
523
+ })
524
+ // 19. DELETE /:id/items/:itemId — Delete booking item
525
+ .delete("/:id/items/:itemId", async (c) => {
526
+ const row = await bookingsService.deleteItem(c.get("db"), c.req.param("itemId"));
527
+ if (!row) {
528
+ return c.json({ error: "Booking item not found" }, 404);
529
+ }
530
+ return c.json({ success: true }, 200);
531
+ })
532
+ // 20. GET /:id/items/:itemId/participants — List item participants
533
+ .get("/:id/items/:itemId/participants", async (c) => {
534
+ return c.json({
535
+ data: await bookingsService.listItemParticipants(c.get("db"), c.req.param("itemId")),
536
+ });
537
+ })
538
+ // 21. POST /:id/items/:itemId/participants — Link participant to item
539
+ .post("/:id/items/:itemId/participants", async (c) => {
540
+ const row = await bookingsService.addItemParticipant(c.get("db"), c.req.param("itemId"), insertBookingItemParticipantSchema.parse(await c.req.json()));
541
+ if (!row) {
542
+ return c.json({ error: "Booking item or participant not found" }, 404);
543
+ }
544
+ return c.json({ data: row }, 201);
545
+ })
546
+ // 22. DELETE /:id/items/:itemId/participants/:linkId — Unlink participant from item
547
+ .delete("/:id/items/:itemId/participants/:linkId", async (c) => {
548
+ const row = await bookingsService.removeItemParticipant(c.get("db"), c.req.param("linkId"));
549
+ if (!row) {
550
+ return c.json({ error: "Booking item participant link not found" }, 404);
551
+ }
552
+ return c.json({ success: true }, 200);
553
+ })
554
+ // ==========================================================================
555
+ // Supplier Statuses
556
+ // ==========================================================================
557
+ .get("/:id/supplier-statuses", async (c) => {
558
+ return c.json({
559
+ data: await bookingsService.listSupplierStatuses(c.get("db"), c.req.param("id")),
560
+ });
561
+ })
562
+ .post("/:id/supplier-statuses", async (c) => {
563
+ const row = await bookingsService.createSupplierStatus(c.get("db"), c.req.param("id"), insertSupplierStatusSchema.parse(await c.req.json()), c.get("userId"));
564
+ if (!row) {
565
+ return c.json({ error: "Booking not found" }, 404);
566
+ }
567
+ return c.json({ data: row }, 201);
568
+ })
569
+ .patch("/:id/supplier-statuses/:statusId", async (c) => {
570
+ const row = await bookingsService.updateSupplierStatus(c.get("db"), c.req.param("id"), c.req.param("statusId"), updateSupplierStatusSchema.parse(await c.req.json()), c.get("userId"));
571
+ if (!row) {
572
+ return c.json({ error: "Supplier status not found" }, 404);
573
+ }
574
+ return c.json({ data: row });
575
+ })
576
+ // ==========================================================================
577
+ // Fulfillment
578
+ // ==========================================================================
579
+ .get("/:id/fulfillments", async (c) => {
580
+ return c.json({ data: await bookingsService.listFulfillments(c.get("db"), c.req.param("id")) });
581
+ })
582
+ .post("/:id/fulfillments", async (c) => {
583
+ const row = await bookingsService.issueFulfillment(c.get("db"), c.req.param("id"), insertBookingFulfillmentSchema.parse(await c.req.json()), c.get("userId"));
584
+ if (!row) {
585
+ return c.json({ error: "Booking, item, or participant not found" }, 404);
586
+ }
587
+ return c.json({ data: row }, 201);
588
+ })
589
+ .patch("/:id/fulfillments/:fulfillmentId", async (c) => {
590
+ const row = await bookingsService.updateFulfillment(c.get("db"), c.req.param("id"), c.req.param("fulfillmentId"), updateBookingFulfillmentSchema.parse(await c.req.json()), c.get("userId"));
591
+ if (!row) {
592
+ return c.json({ error: "Fulfillment, item, or participant not found" }, 404);
593
+ }
594
+ return c.json({ data: row });
595
+ })
596
+ // ==========================================================================
597
+ // Redemption
598
+ // ==========================================================================
599
+ .get("/:id/redemptions", async (c) => {
600
+ return c.json({
601
+ data: await bookingsService.listRedemptionEvents(c.get("db"), c.req.param("id")),
602
+ });
603
+ })
604
+ .post("/:id/redemptions", async (c) => {
605
+ const row = await bookingsService.recordRedemption(c.get("db"), c.req.param("id"), recordBookingRedemptionSchema.parse(await c.req.json()), c.get("userId"));
606
+ if (!row) {
607
+ return c.json({ error: "Booking, item, or participant not found" }, 404);
608
+ }
609
+ return c.json({ data: row }, 201);
610
+ })
611
+ // ==========================================================================
612
+ // Activity Log
613
+ // ==========================================================================
614
+ // 26. GET /:id/activity — List activity log
615
+ .get("/:id/activity", async (c) => {
616
+ return c.json({ data: await bookingsService.listActivity(c.get("db"), c.req.param("id")) });
617
+ })
618
+ // ==========================================================================
619
+ // Notes
620
+ // ==========================================================================
621
+ // 27. GET /:id/notes — List notes
622
+ .get("/:id/notes", async (c) => {
623
+ return c.json({ data: await bookingsService.listNotes(c.get("db"), c.req.param("id")) });
624
+ })
625
+ // 28. POST /:id/notes — Add note
626
+ .post("/:id/notes", async (c) => {
627
+ const userId = c.get("userId");
628
+ if (!userId) {
629
+ return c.json({ error: "User ID required to create notes" }, 400);
630
+ }
631
+ const row = await bookingsService.createNote(c.get("db"), c.req.param("id"), userId, insertBookingNoteSchema.parse(await c.req.json()));
632
+ if (!row) {
633
+ return c.json({ error: "Booking not found" }, 404);
634
+ }
635
+ return c.json({ data: row }, 201);
636
+ })
637
+ // ==========================================================================
638
+ // Documents
639
+ // ==========================================================================
640
+ // 29. GET /:id/documents — List documents for booking
641
+ .get("/:id/documents", async (c) => {
642
+ return c.json({ data: await bookingsService.listDocuments(c.get("db"), c.req.param("id")) });
643
+ })
644
+ // 30. POST /:id/documents — Add document to booking
645
+ .post("/:id/documents", async (c) => {
646
+ const row = await bookingsService.createDocument(c.get("db"), c.req.param("id"), insertBookingDocumentSchema.parse(await c.req.json()));
647
+ if (!row) {
648
+ return c.json({ error: "Booking not found" }, 404);
649
+ }
650
+ return c.json({ data: row }, 201);
651
+ })
652
+ // 31. DELETE /:id/documents/:documentId — Delete document
653
+ .delete("/:id/documents/:documentId", async (c) => {
654
+ const row = await bookingsService.deleteDocument(c.get("db"), c.req.param("documentId"));
655
+ if (!row) {
656
+ return c.json({ error: "Document not found" }, 404);
657
+ }
658
+ return c.json({ success: true }, 200);
659
+ });