@voyantjs/bookings 0.119.2 → 0.119.3
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.
- package/dist/pricing-assignment/age.d.ts +33 -0
- package/dist/pricing-assignment/age.d.ts.map +1 -0
- package/dist/pricing-assignment/age.js +109 -0
- package/dist/pricing-assignment/draft.d.ts +69 -0
- package/dist/pricing-assignment/draft.d.ts.map +1 -0
- package/dist/pricing-assignment/draft.js +284 -0
- package/dist/pricing-assignment/types.d.ts +113 -0
- package/dist/pricing-assignment/types.d.ts.map +1 -0
- package/dist/pricing-assignment/types.js +30 -0
- package/dist/pricing-assignment/unit-helpers.d.ts +11 -0
- package/dist/pricing-assignment/unit-helpers.d.ts.map +1 -0
- package/dist/pricing-assignment/unit-helpers.js +19 -0
- package/dist/pricing-assignment/verify.d.ts +67 -0
- package/dist/pricing-assignment/verify.d.ts.map +1 -0
- package/dist/pricing-assignment/verify.js +121 -0
- package/dist/pricing-assignment.d.ts +4 -275
- package/dist/pricing-assignment.d.ts.map +1 -1
- package/dist/pricing-assignment.js +4 -559
- package/dist/routes-admin.d.ts +2894 -0
- package/dist/routes-admin.d.ts.map +1 -0
- package/dist/routes-admin.js +2111 -0
- package/dist/routes.d.ts +1 -2893
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +1 -2111
- package/dist/service-core.d.ts +6240 -0
- package/dist/service-core.d.ts.map +1 -0
- package/dist/service-core.js +4350 -0
- package/dist/service-public-core.d.ts +907 -0
- package/dist/service-public-core.d.ts.map +1 -0
- package/dist/service-public-core.js +1176 -0
- package/dist/service-public.d.ts +1 -906
- package/dist/service-public.d.ts.map +1 -1
- package/dist/service-public.js +1 -1176
- package/dist/service.d.ts +1 -6239
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1 -4350
- package/package.json +5 -5
package/dist/routes.js
CHANGED
|
@@ -1,2111 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { ACTION_LEDGER_APPROVAL_ID_HEADER, ActionApprovalDecisionConflictError, ActionLedgerIdempotencyConflictError, actionLedgerService, appendActionLedgerMutation, appendActionLedgerSensitiveRead, buildActionApprovalCommandFingerprint, buildActionLedgerApprovedExecutionFields, decideActionLedgerApproval, evaluateActionLedgerApprovalRequirement, evaluateActionLedgerCapabilityAccess, ledgerSensitiveRead, mapActionLedgerRequestContext, requestActionLedgerApproval, } from "@voyantjs/action-ledger";
|
|
3
|
-
import { aggregateSnapshotKey, readThroughAggregateSnapshot, } from "@voyantjs/db/aggregate-snapshots";
|
|
4
|
-
import { ForbiddenApiError, handleApiError, idempotencyKey, normalizeValidationError, parseJsonBody, parseQuery, RequestValidationError, requireUserId, UnauthorizedApiError, } from "@voyantjs/hono";
|
|
5
|
-
import { Hono } from "hono";
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
import { BOOKING_PII_READ_CAPABILITY, BOOKING_STATUS_CAPABILITIES, } from "./action-ledger-capabilities.js";
|
|
8
|
-
import { createBookingPiiService } from "./pii.js";
|
|
9
|
-
import { redactBookingContact, redactTravelerIdentity, shouldRevealBookingPii, } from "./pii-redaction.js";
|
|
10
|
-
import { BOOKING_ROUTE_RUNTIME_CONTAINER_KEY, buildBookingRouteRuntime, } from "./route-runtime.js";
|
|
11
|
-
import { bookingGroupRoutes } from "./routes-groups.js";
|
|
12
|
-
import { bookingPiiAccessLog } from "./schema.js";
|
|
13
|
-
import { bookingsService } from "./service.js";
|
|
14
|
-
import { bookingGroupsService } from "./service-groups.js";
|
|
15
|
-
import { publicBookingsService, resolveSessionPricingSnapshot } from "./service-public.js";
|
|
16
|
-
import { bookingAggregatesQuerySchema, bookingListQuerySchema, cancelBookingSchema, completeBookingSchema, confirmBookingSchema, convertProductSchema, createBookingSchema, createTravelerWithTravelDetailsSchema, expireBookingSchema, expireStaleBookingsSchema, extendBookingHoldSchema, insertBookingDocumentSchema, insertBookingFulfillmentSchema, insertBookingItemSchema, insertBookingItemTravelerSchema, insertBookingNoteSchema, insertSupplierStatusSchema, insertTravelerSchema, internalBookingOverviewLookupQuerySchema, overrideBookingStatusSchema, pricingPreviewSchema, recordBookingRedemptionSchema, reserveBookingFromTransactionSchema, reserveBookingSchema, sharingGroupsForSlotQuerySchema, startBookingSchema, updateBookingFulfillmentSchema, updateBookingItemSchema, updateBookingNoteSchema, updateBookingSchema, updateSupplierStatusSchema, updateTravelerSchema, updateTravelerWithTravelDetailsSchema, upsertTravelerTravelDetailsSchema, } from "./validation.js";
|
|
17
|
-
function hasPiiScope(scopes, action) {
|
|
18
|
-
if (!scopes || scopes.length === 0) {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
return (scopes.includes("*") ||
|
|
22
|
-
scopes.includes("bookings-pii:*") ||
|
|
23
|
-
scopes.includes(`bookings-pii:${action}`));
|
|
24
|
-
}
|
|
25
|
-
const BOOKING_PII_READ_ACTION_NAME = "booking.pii.read";
|
|
26
|
-
const BOOKING_PII_READ_ACTION_VERSION = "v1";
|
|
27
|
-
const BOOKING_PII_DECISION_POLICY = "bookings-pii-scope-or-staff-v1";
|
|
28
|
-
const BOOKING_PII_AUTHORIZATION_SOURCE = "bookings.pii.route";
|
|
29
|
-
const BOOKING_STATUS_APPROVAL_POLICY = "bookings-status-approval-v1";
|
|
30
|
-
const BOOKING_TRAVELER_LEDGER_ACTION_VERSION = "v1";
|
|
31
|
-
const BOOKING_ITEM_LEDGER_ACTION_VERSION = "v1";
|
|
32
|
-
const BOOKING_NOTE_LEDGER_ACTION_VERSION = "v1";
|
|
33
|
-
const TRAVELER_IDENTITY_DISCLOSED_FIELDS = [
|
|
34
|
-
"firstName",
|
|
35
|
-
"lastName",
|
|
36
|
-
"email",
|
|
37
|
-
"phone",
|
|
38
|
-
"specialRequests",
|
|
39
|
-
"notes",
|
|
40
|
-
];
|
|
41
|
-
const TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS = [
|
|
42
|
-
"nationality",
|
|
43
|
-
"documentType",
|
|
44
|
-
"documentNumber",
|
|
45
|
-
"documentExpiry",
|
|
46
|
-
"documentIssuingCountry",
|
|
47
|
-
"documentIssuingAuthority",
|
|
48
|
-
"documentPersonDocumentId",
|
|
49
|
-
"dateOfBirth",
|
|
50
|
-
"dietaryRequirements",
|
|
51
|
-
"accessibilityNeeds",
|
|
52
|
-
"isLeadTraveler",
|
|
53
|
-
"sharingGroupId",
|
|
54
|
-
"roomTypeId",
|
|
55
|
-
"bedPreference",
|
|
56
|
-
"allocations",
|
|
57
|
-
];
|
|
58
|
-
const TRAVELER_MUTATION_FIELDS = [
|
|
59
|
-
"personId",
|
|
60
|
-
"participantType",
|
|
61
|
-
"travelerCategory",
|
|
62
|
-
"firstName",
|
|
63
|
-
"lastName",
|
|
64
|
-
"email",
|
|
65
|
-
"phone",
|
|
66
|
-
"preferredLanguage",
|
|
67
|
-
"specialRequests",
|
|
68
|
-
"isPrimary",
|
|
69
|
-
"notes",
|
|
70
|
-
];
|
|
71
|
-
const BOOKING_ITEM_MUTATION_FIELDS = [
|
|
72
|
-
"title",
|
|
73
|
-
"description",
|
|
74
|
-
"itemType",
|
|
75
|
-
"status",
|
|
76
|
-
"serviceDate",
|
|
77
|
-
"startsAt",
|
|
78
|
-
"endsAt",
|
|
79
|
-
"quantity",
|
|
80
|
-
"sellCurrency",
|
|
81
|
-
"unitSellAmountCents",
|
|
82
|
-
"totalSellAmountCents",
|
|
83
|
-
"costCurrency",
|
|
84
|
-
"unitCostAmountCents",
|
|
85
|
-
"totalCostAmountCents",
|
|
86
|
-
"notes",
|
|
87
|
-
"productId",
|
|
88
|
-
"optionId",
|
|
89
|
-
"optionUnitId",
|
|
90
|
-
"pricingCategoryId",
|
|
91
|
-
"availabilitySlotId",
|
|
92
|
-
"productNameSnapshot",
|
|
93
|
-
"optionNameSnapshot",
|
|
94
|
-
"unitNameSnapshot",
|
|
95
|
-
"departureLabelSnapshot",
|
|
96
|
-
"sourceSnapshotId",
|
|
97
|
-
"sourceOfferId",
|
|
98
|
-
"metadata",
|
|
99
|
-
];
|
|
100
|
-
const bookingActionLedgerQuerySchema = z
|
|
101
|
-
.object({
|
|
102
|
-
cursorOccurredAt: z.string().datetime().optional(),
|
|
103
|
-
cursorId: z.string().trim().min(1).optional(),
|
|
104
|
-
limit: z.coerce.number().int().min(1).max(199).optional(),
|
|
105
|
-
})
|
|
106
|
-
.superRefine((value, ctx) => {
|
|
107
|
-
if (Boolean(value.cursorOccurredAt) === Boolean(value.cursorId))
|
|
108
|
-
return;
|
|
109
|
-
ctx.addIssue({
|
|
110
|
-
code: z.ZodIssueCode.custom,
|
|
111
|
-
path: value.cursorOccurredAt ? ["cursorId"] : ["cursorOccurredAt"],
|
|
112
|
-
message: "cursorOccurredAt and cursorId must be provided together",
|
|
113
|
-
});
|
|
114
|
-
})
|
|
115
|
-
.transform(({ cursorOccurredAt, cursorId, ...query }) => ({
|
|
116
|
-
...query,
|
|
117
|
-
cursor: cursorOccurredAt && cursorId
|
|
118
|
-
? {
|
|
119
|
-
occurredAt: cursorOccurredAt,
|
|
120
|
-
id: cursorId,
|
|
121
|
-
}
|
|
122
|
-
: undefined,
|
|
123
|
-
}));
|
|
124
|
-
const decideBookingActionApprovalBodySchema = z.object({
|
|
125
|
-
status: z.enum(["approved", "denied"]),
|
|
126
|
-
});
|
|
127
|
-
const DASHBOARD_AGGREGATES_CACHE_CONTROL = "private, max-age=30";
|
|
128
|
-
/** Server-side snapshot TTL — see readThroughAggregateSnapshot (#1629). */
|
|
129
|
-
const DASHBOARD_AGGREGATES_TTL_SECONDS = 60;
|
|
130
|
-
function cacheDashboardAggregates(c) {
|
|
131
|
-
c.header("Cache-Control", DASHBOARD_AGGREGATES_CACHE_CONTROL);
|
|
132
|
-
c.header("Vary", "Authorization", { append: true });
|
|
133
|
-
c.header("Vary", "Cookie", { append: true });
|
|
134
|
-
}
|
|
135
|
-
function getActionLedgerRequestContext(c) {
|
|
136
|
-
return {
|
|
137
|
-
userId: c.get("userId") ?? null,
|
|
138
|
-
agentId: c.get("agentId") ?? null,
|
|
139
|
-
workflowPrincipalId: c.get("workflowPrincipalId") ?? null,
|
|
140
|
-
principalSubtype: c.get("principalSubtype") ?? null,
|
|
141
|
-
sessionId: c.get("sessionId") ?? null,
|
|
142
|
-
apiTokenId: c.get("apiTokenId") ?? c.get("apiKeyId") ?? null,
|
|
143
|
-
callerType: c.get("callerType") ?? null,
|
|
144
|
-
actor: c.get("actor") ?? null,
|
|
145
|
-
isInternalRequest: c.get("isInternalRequest") ?? false,
|
|
146
|
-
organizationId: c.get("organizationId") ?? null,
|
|
147
|
-
workflowRunId: c.get("workflowRunId") ?? null,
|
|
148
|
-
workflowStepId: c.get("workflowStepId") ?? null,
|
|
149
|
-
correlationId: c.req.header("x-correlation-id") ?? c.req.header("x-request-id") ?? null,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
async function validateBookingBillingPartyReferences(c, data) {
|
|
153
|
-
const runtime = getRouteRuntime(c);
|
|
154
|
-
const db = c.get("db");
|
|
155
|
-
if (data.personId && runtime.resolveBillingPersonById) {
|
|
156
|
-
const exists = await runtime.resolveBillingPersonById(db, data.personId);
|
|
157
|
-
if (!exists) {
|
|
158
|
-
throw new RequestValidationError("Booking personId does not reference an existing person", {
|
|
159
|
-
fields: {
|
|
160
|
-
fieldErrors: { personId: ["Person not found"] },
|
|
161
|
-
formErrors: [],
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
if (data.organizationId && runtime.resolveBillingOrganizationById) {
|
|
167
|
-
const exists = await runtime.resolveBillingOrganizationById(db, data.organizationId);
|
|
168
|
-
if (!exists) {
|
|
169
|
-
throw new RequestValidationError("Booking organizationId does not reference an existing organization", {
|
|
170
|
-
fields: {
|
|
171
|
-
fieldErrors: { organizationId: ["Organization not found"] },
|
|
172
|
-
formErrors: [],
|
|
173
|
-
},
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
function changedBookingMutationFields(input, before, after) {
|
|
179
|
-
const fields = Object.keys(input).filter((field) => !ignoredBookingMutationFields.has(field));
|
|
180
|
-
if (!before || !after)
|
|
181
|
-
return fields.sort();
|
|
182
|
-
return fields.filter((field) => !bookingValuesEqual(before[field], after[field])).sort();
|
|
183
|
-
}
|
|
184
|
-
function changedBookingTravelerFields(input, before, after) {
|
|
185
|
-
const travelerFields = new Set(TRAVELER_MUTATION_FIELDS);
|
|
186
|
-
return changedBookingMutationFields(input, before, after).filter((field) => travelerFields.has(field));
|
|
187
|
-
}
|
|
188
|
-
function changedBookingTravelDetailFields(input) {
|
|
189
|
-
const travelDetailFields = new Set(TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS);
|
|
190
|
-
return Object.keys(input)
|
|
191
|
-
.filter((field) => travelDetailFields.has(field))
|
|
192
|
-
.sort();
|
|
193
|
-
}
|
|
194
|
-
function changedBookingItemFields(input, before, after) {
|
|
195
|
-
const itemFields = new Set(BOOKING_ITEM_MUTATION_FIELDS);
|
|
196
|
-
return changedBookingMutationFields(input, before, after).filter((field) => itemFields.has(field));
|
|
197
|
-
}
|
|
198
|
-
function bookingMutationSummary(action, fields, subject) {
|
|
199
|
-
if (action === "delete")
|
|
200
|
-
return `Deleted ${subject}`;
|
|
201
|
-
if (fields.length === 0)
|
|
202
|
-
return action === "create" ? `Created ${subject}` : `Updated ${subject}`;
|
|
203
|
-
const verb = action === "create" ? "Created" : "Updated";
|
|
204
|
-
return `${verb} ${subject} fields: ${fields.join(", ")}`;
|
|
205
|
-
}
|
|
206
|
-
async function appendBookingMutationLedgerEntry(c, input) {
|
|
207
|
-
return appendBookingMutationLedgerEntryToDb(c.get("db"), getActionLedgerRequestContext(c), input);
|
|
208
|
-
}
|
|
209
|
-
async function appendBookingMutationLedgerEntryToDb(db, context, input) {
|
|
210
|
-
return appendActionLedgerMutation(db, {
|
|
211
|
-
context,
|
|
212
|
-
actionName: input.actionName,
|
|
213
|
-
actionVersion: input.actionVersion,
|
|
214
|
-
actionKind: input.action,
|
|
215
|
-
evaluatedRisk: input.evaluatedRisk ?? "medium",
|
|
216
|
-
targetType: input.targetType,
|
|
217
|
-
targetId: input.targetId,
|
|
218
|
-
routeOrToolName: input.routeOrToolName,
|
|
219
|
-
authorizationSource: input.authorizationSource ?? "bookings.route",
|
|
220
|
-
mutationDetail: {
|
|
221
|
-
summary: input.summary ?? bookingMutationSummary(input.action, input.changedFields, input.subject),
|
|
222
|
-
reversalKind: "none",
|
|
223
|
-
},
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
async function appendBookingTravelerMutationLedgerEntryToDb(db, context, input) {
|
|
227
|
-
return appendBookingMutationLedgerEntryToDb(db, context, {
|
|
228
|
-
...input,
|
|
229
|
-
actionName: input.actionName,
|
|
230
|
-
actionVersion: BOOKING_TRAVELER_LEDGER_ACTION_VERSION,
|
|
231
|
-
targetType: "booking_traveler",
|
|
232
|
-
targetId: input.travelerId,
|
|
233
|
-
authorizationSource: BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
234
|
-
evaluatedRisk: input.evaluatedRisk ?? "high",
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
function bookingValuesEqual(left, right) {
|
|
238
|
-
if (left instanceof Date || right instanceof Date) {
|
|
239
|
-
const leftTime = left instanceof Date ? left.getTime() : new Date(String(left)).getTime();
|
|
240
|
-
const rightTime = right instanceof Date ? right.getTime() : new Date(String(right)).getTime();
|
|
241
|
-
return leftTime === rightTime;
|
|
242
|
-
}
|
|
243
|
-
return JSON.stringify(left) === JSON.stringify(right);
|
|
244
|
-
}
|
|
245
|
-
const ignoredBookingMutationFields = new Set(["updatedAt", "createdAt"]);
|
|
246
|
-
async function authorizeBookingStatusMutation(c, input) {
|
|
247
|
-
const capability = BOOKING_STATUS_CAPABILITIES[input.key];
|
|
248
|
-
const access = evaluateActionLedgerCapabilityAccess({
|
|
249
|
-
definition: capability,
|
|
250
|
-
actor: c.get("actor"),
|
|
251
|
-
callerType: c.get("callerType"),
|
|
252
|
-
scopes: c.get("scopes"),
|
|
253
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
254
|
-
});
|
|
255
|
-
if (access.allowed) {
|
|
256
|
-
const approvalRequirement = evaluateActionLedgerApprovalRequirement({
|
|
257
|
-
access,
|
|
258
|
-
conditionalApprovalRequired: requiresBookingStatusApproval(c, input.key),
|
|
259
|
-
reasonCode: bookingStatusApprovalReason(c, input.key),
|
|
260
|
-
});
|
|
261
|
-
if (approvalRequirement.required) {
|
|
262
|
-
const approvedAction = await resolveApprovedBookingStatusAction(c, input, access, approvalRequirement);
|
|
263
|
-
if (approvedAction) {
|
|
264
|
-
if (!approvedAction.allowed)
|
|
265
|
-
return approvedAction;
|
|
266
|
-
return { allowed: true, access, approvedAction: approvedAction.action };
|
|
267
|
-
}
|
|
268
|
-
const idempotencyKey = c.req.header("idempotency-key") ?? null;
|
|
269
|
-
if (!idempotencyKey) {
|
|
270
|
-
return {
|
|
271
|
-
allowed: false,
|
|
272
|
-
response: c.json({
|
|
273
|
-
error: "Approval-required booking status actions require an Idempotency-Key",
|
|
274
|
-
}, 400),
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
const idempotencyScope = `${input.routeOrToolName}:${input.bookingId}`;
|
|
278
|
-
const idempotencyFingerprint = await buildBookingStatusApprovalFingerprint(input, await loadBookingStatusApprovalTargetState(c, input.bookingId), access, approvalRequirement);
|
|
279
|
-
const requestInput = {
|
|
280
|
-
context: getActionLedgerRequestContext(c),
|
|
281
|
-
actionName: input.actionName,
|
|
282
|
-
actionVersion: capability.version,
|
|
283
|
-
actionKind: "update",
|
|
284
|
-
evaluatedRisk: approvalRequirement.evaluatedRisk,
|
|
285
|
-
targetType: "booking",
|
|
286
|
-
targetId: input.bookingId,
|
|
287
|
-
routeOrToolName: input.routeOrToolName,
|
|
288
|
-
capabilityId: access.capabilityId,
|
|
289
|
-
capabilityVersion: access.capabilityVersion,
|
|
290
|
-
authorizationSource: access.authorizationSource,
|
|
291
|
-
idempotencyScope,
|
|
292
|
-
idempotencyKey,
|
|
293
|
-
idempotencyFingerprint,
|
|
294
|
-
mutationDetail: {
|
|
295
|
-
summary: `Booking status ${capability.action} awaiting approval: ${approvalRequirement.reasonCode}`,
|
|
296
|
-
reversalKind: "none",
|
|
297
|
-
},
|
|
298
|
-
approval: {
|
|
299
|
-
policyName: BOOKING_STATUS_APPROVAL_POLICY,
|
|
300
|
-
policyVersion: capability.version,
|
|
301
|
-
riskSnapshot: approvalRequirement.evaluatedRisk,
|
|
302
|
-
reasonCode: approvalRequirement.reasonCode,
|
|
303
|
-
},
|
|
304
|
-
};
|
|
305
|
-
let result;
|
|
306
|
-
try {
|
|
307
|
-
result = await requestActionLedgerApproval(c.get("db"), requestInput);
|
|
308
|
-
}
|
|
309
|
-
catch (error) {
|
|
310
|
-
if (error instanceof ActionLedgerIdempotencyConflictError) {
|
|
311
|
-
return {
|
|
312
|
-
allowed: false,
|
|
313
|
-
response: c.json({
|
|
314
|
-
error: error.message,
|
|
315
|
-
existingActionId: error.existingActionId,
|
|
316
|
-
}, 409),
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
throw error;
|
|
320
|
-
}
|
|
321
|
-
return {
|
|
322
|
-
allowed: false,
|
|
323
|
-
response: c.json({
|
|
324
|
-
data: {
|
|
325
|
-
approvalRequired: true,
|
|
326
|
-
requestedAction: {
|
|
327
|
-
id: result.requestedAction.id,
|
|
328
|
-
status: result.requestedAction.status,
|
|
329
|
-
actionName: result.requestedAction.actionName,
|
|
330
|
-
targetType: result.requestedAction.targetType,
|
|
331
|
-
targetId: result.requestedAction.targetId,
|
|
332
|
-
},
|
|
333
|
-
approval: {
|
|
334
|
-
id: result.approval.id,
|
|
335
|
-
status: result.approval.status,
|
|
336
|
-
requestedActionId: result.approval.requestedActionId,
|
|
337
|
-
requestedByPrincipalId: result.approval.requestedByPrincipalId,
|
|
338
|
-
assignedToPrincipalId: result.approval.assignedToPrincipalId,
|
|
339
|
-
policyName: result.approval.policyName,
|
|
340
|
-
policyVersion: result.approval.policyVersion,
|
|
341
|
-
riskSnapshot: result.approval.riskSnapshot,
|
|
342
|
-
reasonCode: result.approval.reasonCode,
|
|
343
|
-
expiresAt: result.approval.expiresAt,
|
|
344
|
-
createdAt: result.approval.createdAt,
|
|
345
|
-
},
|
|
346
|
-
replayed: result.replayed,
|
|
347
|
-
},
|
|
348
|
-
}, 202),
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
return { allowed: true, access };
|
|
352
|
-
}
|
|
353
|
-
await appendActionLedgerMutation(c.get("db"), {
|
|
354
|
-
context: getActionLedgerRequestContext(c),
|
|
355
|
-
actionName: input.actionName,
|
|
356
|
-
actionVersion: capability.version,
|
|
357
|
-
actionKind: "update",
|
|
358
|
-
status: "denied",
|
|
359
|
-
evaluatedRisk: access.evaluatedRisk,
|
|
360
|
-
targetType: "booking",
|
|
361
|
-
targetId: input.bookingId,
|
|
362
|
-
routeOrToolName: input.routeOrToolName,
|
|
363
|
-
capabilityId: access.capabilityId,
|
|
364
|
-
capabilityVersion: access.capabilityVersion,
|
|
365
|
-
authorizationSource: access.authorizationSource,
|
|
366
|
-
mutationDetail: {
|
|
367
|
-
summary: `Booking status ${capability.action} denied: ${access.reason}`,
|
|
368
|
-
reversalKind: "none",
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
return {
|
|
372
|
-
allowed: false,
|
|
373
|
-
response: handleApiError(new ForbiddenApiError(), c),
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
async function resolveApprovedBookingStatusAction(c, input, access, approvalRequirement) {
|
|
377
|
-
const approvalId = c.req.header(ACTION_LEDGER_APPROVAL_ID_HEADER);
|
|
378
|
-
if (!approvalId)
|
|
379
|
-
return null;
|
|
380
|
-
const executionFingerprint = await buildBookingStatusApprovalFingerprint(input, await loadBookingStatusApprovalTargetState(c, input.bookingId), access, approvalRequirement);
|
|
381
|
-
const actorFields = mapActionLedgerRequestContext(getActionLedgerRequestContext(c));
|
|
382
|
-
const validation = await actionLedgerService.validateApprovedAction(c.get("db"), {
|
|
383
|
-
approvalId,
|
|
384
|
-
actionName: input.actionName,
|
|
385
|
-
actionVersion: BOOKING_STATUS_CAPABILITIES[input.key].version,
|
|
386
|
-
requestedActionKind: "update",
|
|
387
|
-
requestedActionStatus: "awaiting_approval",
|
|
388
|
-
targetType: "booking",
|
|
389
|
-
targetId: input.bookingId,
|
|
390
|
-
routeOrToolName: input.routeOrToolName,
|
|
391
|
-
principalType: actorFields.principalType,
|
|
392
|
-
principalId: actorFields.principalId,
|
|
393
|
-
idempotencyFingerprint: executionFingerprint,
|
|
394
|
-
executionActionKind: "update",
|
|
395
|
-
executionStatus: "succeeded",
|
|
396
|
-
});
|
|
397
|
-
if (!validation.ok) {
|
|
398
|
-
return {
|
|
399
|
-
allowed: false,
|
|
400
|
-
response: actionApprovalValidationResponse(c, validation),
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
return {
|
|
404
|
-
allowed: true,
|
|
405
|
-
action: {
|
|
406
|
-
requestedActionId: validation.requestedAction.id,
|
|
407
|
-
approvalId: validation.approval.id,
|
|
408
|
-
idempotencyFingerprint: validation.idempotencyFingerprint,
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
function buildBookingStatusApprovalFingerprint(input, targetState, access, approvalRequirement) {
|
|
413
|
-
return buildActionApprovalCommandFingerprint({
|
|
414
|
-
actionName: input.actionName,
|
|
415
|
-
actionVersion: BOOKING_STATUS_CAPABILITIES[input.key].version,
|
|
416
|
-
targetType: "booking",
|
|
417
|
-
targetId: input.bookingId,
|
|
418
|
-
commandInput: {
|
|
419
|
-
command: input.commandInput ?? null,
|
|
420
|
-
targetState,
|
|
421
|
-
},
|
|
422
|
-
approvalPolicy: approvalRequirement.approvalPolicy,
|
|
423
|
-
capabilityId: access.capabilityId,
|
|
424
|
-
capabilityVersion: access.capabilityVersion,
|
|
425
|
-
evaluatedRisk: approvalRequirement.evaluatedRisk,
|
|
426
|
-
reasonCode: approvalRequirement.reasonCode,
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
async function loadBookingStatusApprovalTargetState(c, bookingId) {
|
|
430
|
-
const booking = await bookingsService.getBookingById(c.get("db"), bookingId);
|
|
431
|
-
if (!booking)
|
|
432
|
-
return { exists: false };
|
|
433
|
-
return {
|
|
434
|
-
exists: true,
|
|
435
|
-
status: booking.status,
|
|
436
|
-
sellCurrency: booking.sellCurrency,
|
|
437
|
-
sellAmountCents: booking.sellAmountCents,
|
|
438
|
-
costAmountCents: booking.costAmountCents,
|
|
439
|
-
customerPaymentPolicy: booking.customerPaymentPolicy,
|
|
440
|
-
holdExpiresAt: serializeBookingApprovalDate(booking.holdExpiresAt),
|
|
441
|
-
confirmedAt: serializeBookingApprovalDate(booking.confirmedAt),
|
|
442
|
-
awaitingPaymentAt: serializeBookingApprovalDate(booking.awaitingPaymentAt),
|
|
443
|
-
paidAt: serializeBookingApprovalDate(booking.paidAt),
|
|
444
|
-
cancelledAt: serializeBookingApprovalDate(booking.cancelledAt),
|
|
445
|
-
completedAt: serializeBookingApprovalDate(booking.completedAt),
|
|
446
|
-
expiredAt: serializeBookingApprovalDate(booking.expiredAt),
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
function serializeBookingApprovalDate(value) {
|
|
450
|
-
if (!value)
|
|
451
|
-
return null;
|
|
452
|
-
return value instanceof Date ? value.toISOString() : value;
|
|
453
|
-
}
|
|
454
|
-
function actionApprovalValidationResponse(c, validation) {
|
|
455
|
-
switch (validation.reason) {
|
|
456
|
-
case "not_found":
|
|
457
|
-
return c.json({ error: "Action approval not found" }, 404);
|
|
458
|
-
case "not_approved":
|
|
459
|
-
return c.json({
|
|
460
|
-
error: "Action approval is not approved",
|
|
461
|
-
approvalId: validation.approval?.id,
|
|
462
|
-
status: validation.status,
|
|
463
|
-
}, 409);
|
|
464
|
-
case "expired":
|
|
465
|
-
return c.json({
|
|
466
|
-
error: "Action approval has expired",
|
|
467
|
-
approvalId: validation.approval?.id,
|
|
468
|
-
}, 409);
|
|
469
|
-
case "mismatched_action":
|
|
470
|
-
return c.json({
|
|
471
|
-
error: "Action approval does not match this booking status action",
|
|
472
|
-
approvalId: validation.approval?.id,
|
|
473
|
-
}, 409);
|
|
474
|
-
case "already_executed":
|
|
475
|
-
return c.json({
|
|
476
|
-
error: "Action approval has already been executed",
|
|
477
|
-
approvalId: validation.approval?.id,
|
|
478
|
-
existingActionId: validation.existingActionId,
|
|
479
|
-
}, 409);
|
|
480
|
-
case "missing_fingerprint":
|
|
481
|
-
return c.json({
|
|
482
|
-
error: "Action approval is missing an approved command fingerprint",
|
|
483
|
-
approvalId: validation.approval?.id,
|
|
484
|
-
}, 409);
|
|
485
|
-
case "fingerprint_mismatch":
|
|
486
|
-
return c.json({
|
|
487
|
-
error: "Action approval command input does not match the approved request",
|
|
488
|
-
approvalId: validation.approval?.id,
|
|
489
|
-
}, 409);
|
|
490
|
-
case "principal_mismatch":
|
|
491
|
-
return c.json({ error: "Action approval belongs to a different principal" }, 403);
|
|
492
|
-
}
|
|
493
|
-
const exhaustiveReason = validation.reason;
|
|
494
|
-
return c.json({ error: `Unhandled action approval validation failure: ${exhaustiveReason}` }, 500);
|
|
495
|
-
}
|
|
496
|
-
function bookingStatusMutationRuntime(c, auth) {
|
|
497
|
-
const approvedExecution = auth.approvedAction
|
|
498
|
-
? buildActionLedgerApprovedExecutionFields(auth.approvedAction)
|
|
499
|
-
: null;
|
|
500
|
-
return {
|
|
501
|
-
eventBus: c.get("eventBus"),
|
|
502
|
-
closePaymentSchedulesForBooking: getRouteRuntime(c).closePaymentSchedulesForBooking,
|
|
503
|
-
actionLedgerContext: getActionLedgerRequestContext(c),
|
|
504
|
-
actionLedgerAuthorizationSource: auth.access.authorizationSource,
|
|
505
|
-
actionLedgerCausationActionId: approvedExecution?.causationActionId ?? null,
|
|
506
|
-
actionLedgerApprovalId: approvedExecution?.approvalId ?? null,
|
|
507
|
-
actionLedgerIdempotencyScope: approvedExecution?.idempotencyScope ?? null,
|
|
508
|
-
actionLedgerIdempotencyKey: approvedExecution?.idempotencyKey ?? null,
|
|
509
|
-
actionLedgerIdempotencyFingerprint: approvedExecution?.idempotencyFingerprint ?? null,
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
function requiresBookingStatusApproval(c, key) {
|
|
513
|
-
const capability = BOOKING_STATUS_CAPABILITIES[key];
|
|
514
|
-
if (capability.approvalPolicy !== "conditional")
|
|
515
|
-
return false;
|
|
516
|
-
return (c.get("callerType") === "agent" ||
|
|
517
|
-
c.get("callerType") === "workflow" ||
|
|
518
|
-
Boolean(c.get("agentId")) ||
|
|
519
|
-
Boolean(c.get("workflowPrincipalId")));
|
|
520
|
-
}
|
|
521
|
-
function bookingStatusApprovalReason(c, key) {
|
|
522
|
-
if (c.get("callerType") === "agent" || c.get("agentId")) {
|
|
523
|
-
return `${key}_requested_by_agent`;
|
|
524
|
-
}
|
|
525
|
-
if (c.get("callerType") === "workflow" || c.get("workflowPrincipalId")) {
|
|
526
|
-
return `${key}_requested_by_workflow`;
|
|
527
|
-
}
|
|
528
|
-
return null;
|
|
529
|
-
}
|
|
530
|
-
async function logBookingPiiReadActionLedger(c, input) {
|
|
531
|
-
await appendActionLedgerSensitiveRead(c.get("db"), {
|
|
532
|
-
context: getActionLedgerRequestContext(c),
|
|
533
|
-
actionName: BOOKING_PII_READ_ACTION_NAME,
|
|
534
|
-
actionVersion: BOOKING_PII_READ_ACTION_VERSION,
|
|
535
|
-
status: input.status,
|
|
536
|
-
evaluatedRisk: input.evaluatedRisk ?? "high",
|
|
537
|
-
targetType: "booking_traveler",
|
|
538
|
-
targetId: input.travelerId,
|
|
539
|
-
routeOrToolName: input.routeOrToolName,
|
|
540
|
-
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
541
|
-
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
542
|
-
authorizationSource: input.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
543
|
-
reasonCode: input.reason,
|
|
544
|
-
disclosedFieldSet: input.status === "succeeded" ? (input.disclosedFieldSet ?? []) : [],
|
|
545
|
-
disclosureSummary: input.disclosureSummary ?? null,
|
|
546
|
-
decisionPolicy: input.decisionPolicy ?? BOOKING_PII_DECISION_POLICY,
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
async function logBookingPiiAccess(c, input) {
|
|
550
|
-
await c
|
|
551
|
-
.get("db")
|
|
552
|
-
.insert(bookingPiiAccessLog)
|
|
553
|
-
.values({
|
|
554
|
-
bookingId: input.bookingId ?? null,
|
|
555
|
-
travelerId: input.travelerId ?? null,
|
|
556
|
-
actorId: c.get("userId") ?? null,
|
|
557
|
-
actorType: c.get("actor") ?? null,
|
|
558
|
-
callerType: c.get("callerType") ?? null,
|
|
559
|
-
action: input.action,
|
|
560
|
-
outcome: input.outcome,
|
|
561
|
-
reason: input.reason ?? null,
|
|
562
|
-
metadata: input.metadata ?? null,
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
async function authorizeBookingPiiAccess(c, input) {
|
|
566
|
-
if (c.get("isInternalRequest")) {
|
|
567
|
-
return { allowed: true, access: undefined };
|
|
568
|
-
}
|
|
569
|
-
const userId = c.get("userId");
|
|
570
|
-
if (!userId) {
|
|
571
|
-
await logBookingPiiAccess(c, {
|
|
572
|
-
...input,
|
|
573
|
-
outcome: "denied",
|
|
574
|
-
reason: "missing_user",
|
|
575
|
-
});
|
|
576
|
-
if (input.action === "read") {
|
|
577
|
-
await logBookingPiiReadActionLedger(c, {
|
|
578
|
-
travelerId: input.travelerId,
|
|
579
|
-
status: "denied",
|
|
580
|
-
reason: "missing_user",
|
|
581
|
-
routeOrToolName: "bookings.pii.authorize",
|
|
582
|
-
disclosureSummary: "Booking PII read denied before reveal",
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
return {
|
|
586
|
-
allowed: false,
|
|
587
|
-
response: handleApiError(new UnauthorizedApiError(), c),
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
const customAuthorizer = c.get("authorizeBookingPii");
|
|
591
|
-
if (customAuthorizer) {
|
|
592
|
-
const allowed = await customAuthorizer({
|
|
593
|
-
db: c.get("db"),
|
|
594
|
-
userId,
|
|
595
|
-
actor: c.get("actor"),
|
|
596
|
-
callerType: c.get("callerType"),
|
|
597
|
-
scopes: c.get("scopes"),
|
|
598
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
599
|
-
...input,
|
|
600
|
-
});
|
|
601
|
-
if (!allowed) {
|
|
602
|
-
await logBookingPiiAccess(c, {
|
|
603
|
-
...input,
|
|
604
|
-
outcome: "denied",
|
|
605
|
-
reason: "custom_policy_denied",
|
|
606
|
-
});
|
|
607
|
-
if (input.action === "read") {
|
|
608
|
-
await logBookingPiiReadActionLedger(c, {
|
|
609
|
-
travelerId: input.travelerId,
|
|
610
|
-
status: "denied",
|
|
611
|
-
reason: "custom_policy_denied",
|
|
612
|
-
routeOrToolName: "bookings.pii.authorize",
|
|
613
|
-
disclosureSummary: "Booking PII read denied before reveal",
|
|
614
|
-
decisionPolicy: "custom",
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
return {
|
|
618
|
-
allowed: false,
|
|
619
|
-
response: handleApiError(new ForbiddenApiError(), c),
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
return { allowed: true, access: undefined };
|
|
623
|
-
}
|
|
624
|
-
const actor = c.get("actor");
|
|
625
|
-
const scopes = c.get("scopes");
|
|
626
|
-
if (input.action === "read") {
|
|
627
|
-
const access = evaluateActionLedgerCapabilityAccess({
|
|
628
|
-
definition: BOOKING_PII_READ_CAPABILITY,
|
|
629
|
-
actor,
|
|
630
|
-
callerType: c.get("callerType"),
|
|
631
|
-
scopes,
|
|
632
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
633
|
-
});
|
|
634
|
-
if (!access.allowed) {
|
|
635
|
-
await logBookingPiiAccess(c, {
|
|
636
|
-
...input,
|
|
637
|
-
outcome: "denied",
|
|
638
|
-
reason: access.reason,
|
|
639
|
-
metadata: {
|
|
640
|
-
actor: actor ?? null,
|
|
641
|
-
authorizationSource: access.authorizationSource,
|
|
642
|
-
capabilityId: access.capabilityId,
|
|
643
|
-
capabilityVersion: access.capabilityVersion,
|
|
644
|
-
},
|
|
645
|
-
});
|
|
646
|
-
await logBookingPiiReadActionLedger(c, {
|
|
647
|
-
travelerId: input.travelerId,
|
|
648
|
-
status: "denied",
|
|
649
|
-
reason: access.reason,
|
|
650
|
-
routeOrToolName: "bookings.pii.authorize",
|
|
651
|
-
disclosureSummary: "Booking PII read denied before reveal",
|
|
652
|
-
authorizationSource: access.authorizationSource,
|
|
653
|
-
evaluatedRisk: access.evaluatedRisk,
|
|
654
|
-
});
|
|
655
|
-
return {
|
|
656
|
-
allowed: false,
|
|
657
|
-
response: handleApiError(new ForbiddenApiError(), c),
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
return { allowed: true, access };
|
|
661
|
-
}
|
|
662
|
-
const allowed = hasPiiScope(scopes, input.action) || actor === "staff";
|
|
663
|
-
if (!allowed) {
|
|
664
|
-
await logBookingPiiAccess(c, {
|
|
665
|
-
...input,
|
|
666
|
-
outcome: "denied",
|
|
667
|
-
reason: "insufficient_scope",
|
|
668
|
-
metadata: { actor: actor ?? null },
|
|
669
|
-
});
|
|
670
|
-
return {
|
|
671
|
-
allowed: false,
|
|
672
|
-
response: handleApiError(new ForbiddenApiError(), c),
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
return { allowed: true, access: undefined };
|
|
676
|
-
}
|
|
677
|
-
function handleKmsConfigError(c, error) {
|
|
678
|
-
if (error instanceof Error) {
|
|
679
|
-
return c.json({
|
|
680
|
-
error: "Booking PII encryption is not configured",
|
|
681
|
-
details: error.message,
|
|
682
|
-
}, 500);
|
|
683
|
-
}
|
|
684
|
-
return c.json({ error: "Booking PII encryption is not configured" }, 500);
|
|
685
|
-
}
|
|
686
|
-
function getRouteRuntime(c) {
|
|
687
|
-
try {
|
|
688
|
-
const runtime = c.var.container?.resolve(BOOKING_ROUTE_RUNTIME_CONTAINER_KEY);
|
|
689
|
-
return runtime ?? buildBookingRouteRuntime(c.env);
|
|
690
|
-
}
|
|
691
|
-
catch {
|
|
692
|
-
return buildBookingRouteRuntime(c.env);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
async function createAuditedBookingPiiService(c, bookingId) {
|
|
696
|
-
const runtime = getRouteRuntime(c);
|
|
697
|
-
const kms = await runtime.getKmsProvider();
|
|
698
|
-
return createBookingPiiService({
|
|
699
|
-
kms,
|
|
700
|
-
onAudit: async (event) => {
|
|
701
|
-
await logBookingPiiAccess(c, {
|
|
702
|
-
bookingId,
|
|
703
|
-
travelerId: event.travelerId,
|
|
704
|
-
action: event.action === "encrypt"
|
|
705
|
-
? "update"
|
|
706
|
-
: event.action === "decrypt"
|
|
707
|
-
? "read"
|
|
708
|
-
: event.action,
|
|
709
|
-
outcome: "allowed",
|
|
710
|
-
});
|
|
711
|
-
},
|
|
712
|
-
});
|
|
713
|
-
}
|
|
714
|
-
function serializeBookingActionLedgerDate(value) {
|
|
715
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
716
|
-
if (Number.isNaN(date.getTime())) {
|
|
717
|
-
throw new Error("Booking action ledger timestamp must be a valid date");
|
|
718
|
-
}
|
|
719
|
-
return date.toISOString();
|
|
720
|
-
}
|
|
721
|
-
function serializeBookingActionLedgerEntry(entry) {
|
|
722
|
-
return {
|
|
723
|
-
...entry,
|
|
724
|
-
occurredAt: serializeBookingActionLedgerDate(entry.occurredAt),
|
|
725
|
-
createdAt: serializeBookingActionLedgerDate(entry.createdAt),
|
|
726
|
-
};
|
|
727
|
-
}
|
|
728
|
-
function toBookingActionLedgerCursor(entry) {
|
|
729
|
-
return {
|
|
730
|
-
occurredAt: serializeBookingActionLedgerDate(entry.occurredAt),
|
|
731
|
-
id: entry.id,
|
|
732
|
-
};
|
|
733
|
-
}
|
|
734
|
-
function sortBookingActionLedgerEntries(entries) {
|
|
735
|
-
return [...entries].sort((a, b) => {
|
|
736
|
-
const occurredAtDelta = new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime();
|
|
737
|
-
if (occurredAtDelta !== 0)
|
|
738
|
-
return occurredAtDelta;
|
|
739
|
-
return b.id.localeCompare(a.id);
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
function buildBookingActionLedgerPage({ bookingEntries, travelerEntries, itemEntries = [], limit, }) {
|
|
743
|
-
const entriesById = new Map();
|
|
744
|
-
for (const entry of bookingEntries) {
|
|
745
|
-
entriesById.set(entry.id, entry);
|
|
746
|
-
}
|
|
747
|
-
for (const entry of travelerEntries) {
|
|
748
|
-
entriesById.set(entry.id, entry);
|
|
749
|
-
}
|
|
750
|
-
for (const entry of itemEntries) {
|
|
751
|
-
entriesById.set(entry.id, entry);
|
|
752
|
-
}
|
|
753
|
-
const sortedEntries = sortBookingActionLedgerEntries([...entriesById.values()]);
|
|
754
|
-
const entries = sortedEntries.slice(0, limit);
|
|
755
|
-
const lastEntry = entries.at(-1);
|
|
756
|
-
const nextCursor = sortedEntries.length > limit && lastEntry ? toBookingActionLedgerCursor(lastEntry) : null;
|
|
757
|
-
return { entries, nextCursor };
|
|
758
|
-
}
|
|
759
|
-
async function listBookingActionLedger(c) {
|
|
760
|
-
const bookingId = c.req.param("id");
|
|
761
|
-
if (!bookingId) {
|
|
762
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
763
|
-
}
|
|
764
|
-
const query = parseQuery(c, bookingActionLedgerQuerySchema);
|
|
765
|
-
const limit = query.limit ?? 50;
|
|
766
|
-
const queryLimit = limit + 1;
|
|
767
|
-
const booking = await bookingsService.getBookingById(c.get("db"), bookingId);
|
|
768
|
-
if (!booking) {
|
|
769
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
770
|
-
}
|
|
771
|
-
const travelers = await bookingsService.listTravelers(c.get("db"), bookingId);
|
|
772
|
-
const reveal = shouldRevealBookingPii({
|
|
773
|
-
actor: c.get("actor"),
|
|
774
|
-
scopes: c.get("scopes"),
|
|
775
|
-
callerType: c.get("callerType"),
|
|
776
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
777
|
-
});
|
|
778
|
-
const visibleTravelers = reveal ? travelers : travelers.map((row) => redactTravelerIdentity(row));
|
|
779
|
-
const travelerIds = travelers.map((traveler) => traveler.id);
|
|
780
|
-
const items = await bookingsService.listItems(c.get("db"), bookingId);
|
|
781
|
-
const itemIds = items.map((item) => item.id);
|
|
782
|
-
const [bookingEntriesResult, travelerEntriesResult, itemEntriesResult] = await Promise.all([
|
|
783
|
-
actionLedgerService.listEntries(c.get("db"), {
|
|
784
|
-
targetType: "booking",
|
|
785
|
-
targetId: bookingId,
|
|
786
|
-
cursor: query.cursor,
|
|
787
|
-
limit: queryLimit,
|
|
788
|
-
}),
|
|
789
|
-
travelerIds.length > 0
|
|
790
|
-
? actionLedgerService.listEntries(c.get("db"), {
|
|
791
|
-
targetType: "booking_traveler",
|
|
792
|
-
targetIds: travelerIds,
|
|
793
|
-
cursor: query.cursor,
|
|
794
|
-
limit: queryLimit,
|
|
795
|
-
})
|
|
796
|
-
: Promise.resolve({ entries: [], nextCursor: null }),
|
|
797
|
-
itemIds.length > 0
|
|
798
|
-
? actionLedgerService.listEntries(c.get("db"), {
|
|
799
|
-
targetType: "booking_item",
|
|
800
|
-
targetIds: itemIds,
|
|
801
|
-
cursor: query.cursor,
|
|
802
|
-
limit: queryLimit,
|
|
803
|
-
})
|
|
804
|
-
: Promise.resolve({ entries: [], nextCursor: null }),
|
|
805
|
-
]);
|
|
806
|
-
const page = buildBookingActionLedgerPage({
|
|
807
|
-
bookingEntries: bookingEntriesResult.entries,
|
|
808
|
-
travelerEntries: travelerEntriesResult.entries,
|
|
809
|
-
itemEntries: itemEntriesResult.entries,
|
|
810
|
-
limit,
|
|
811
|
-
});
|
|
812
|
-
return c.json({
|
|
813
|
-
data: page.entries.map(serializeBookingActionLedgerEntry),
|
|
814
|
-
travelers: visibleTravelers.map((traveler) => ({
|
|
815
|
-
id: traveler.id,
|
|
816
|
-
firstName: traveler.firstName,
|
|
817
|
-
lastName: traveler.lastName,
|
|
818
|
-
})),
|
|
819
|
-
pageInfo: {
|
|
820
|
-
nextCursor: page.nextCursor,
|
|
821
|
-
},
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
function serializeBookingActionApproval(approval) {
|
|
825
|
-
return {
|
|
826
|
-
...approval,
|
|
827
|
-
expiresAt: approval.expiresAt ? serializeBookingActionLedgerDate(approval.expiresAt) : null,
|
|
828
|
-
decidedAt: approval.decidedAt ? serializeBookingActionLedgerDate(approval.decidedAt) : null,
|
|
829
|
-
createdAt: serializeBookingActionLedgerDate(approval.createdAt),
|
|
830
|
-
};
|
|
831
|
-
}
|
|
832
|
-
function findBookingStatusCapability(capabilityId) {
|
|
833
|
-
return Object.values(BOOKING_STATUS_CAPABILITIES).find((capability) => capability.id === capabilityId);
|
|
834
|
-
}
|
|
835
|
-
async function decideBookingActionApproval(c) {
|
|
836
|
-
const bookingId = c.req.param("id");
|
|
837
|
-
const approvalId = c.req.param("approvalId");
|
|
838
|
-
if (!bookingId || !approvalId) {
|
|
839
|
-
return c.json({ error: "Action approval not found" }, 404);
|
|
840
|
-
}
|
|
841
|
-
if (!c.get("isInternalRequest") && !c.get("userId")) {
|
|
842
|
-
return handleApiError(new UnauthorizedApiError(), c);
|
|
843
|
-
}
|
|
844
|
-
const body = await parseJsonBody(c, decideBookingActionApprovalBodySchema);
|
|
845
|
-
const existing = await actionLedgerService.getApproval(c.get("db"), approvalId);
|
|
846
|
-
if (!existing?.requestedAction?.entry) {
|
|
847
|
-
return c.json({ error: "Action approval not found" }, 404);
|
|
848
|
-
}
|
|
849
|
-
const requestedAction = existing.requestedAction.entry;
|
|
850
|
-
if (requestedAction.targetType !== "booking" || requestedAction.targetId !== bookingId) {
|
|
851
|
-
return c.json({ error: "Action approval not found" }, 404);
|
|
852
|
-
}
|
|
853
|
-
const capability = findBookingStatusCapability(requestedAction.capabilityId);
|
|
854
|
-
if (!capability) {
|
|
855
|
-
return c.json({ error: "Action approval is not a booking status approval" }, 409);
|
|
856
|
-
}
|
|
857
|
-
const access = evaluateActionLedgerCapabilityAccess({
|
|
858
|
-
definition: capability,
|
|
859
|
-
actor: c.get("actor"),
|
|
860
|
-
callerType: c.get("callerType"),
|
|
861
|
-
scopes: c.get("scopes"),
|
|
862
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
863
|
-
});
|
|
864
|
-
if (!access.allowed) {
|
|
865
|
-
return handleApiError(new ForbiddenApiError(), c);
|
|
866
|
-
}
|
|
867
|
-
try {
|
|
868
|
-
const result = await decideActionLedgerApproval(c.get("db"), {
|
|
869
|
-
context: getActionLedgerRequestContext(c),
|
|
870
|
-
id: approvalId,
|
|
871
|
-
status: body.status,
|
|
872
|
-
actionName: "booking.status.approval.decide",
|
|
873
|
-
actionVersion: capability.version,
|
|
874
|
-
evaluatedRisk: requestedAction.evaluatedRisk,
|
|
875
|
-
targetType: "booking",
|
|
876
|
-
targetId: bookingId,
|
|
877
|
-
routeOrToolName: "bookings.approvals.decide",
|
|
878
|
-
capabilityId: capability.id,
|
|
879
|
-
capabilityVersion: capability.version,
|
|
880
|
-
authorizationSource: access.authorizationSource,
|
|
881
|
-
});
|
|
882
|
-
if (!result) {
|
|
883
|
-
return c.json({ error: "Action approval not found" }, 404);
|
|
884
|
-
}
|
|
885
|
-
return c.json({
|
|
886
|
-
data: {
|
|
887
|
-
approval: serializeBookingActionApproval(result.approval),
|
|
888
|
-
decisionAction: serializeBookingActionLedgerEntry(result.decisionAction),
|
|
889
|
-
},
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
catch (error) {
|
|
893
|
-
if (error instanceof ActionApprovalDecisionConflictError) {
|
|
894
|
-
return c.json({
|
|
895
|
-
error: "Action approval has already been decided",
|
|
896
|
-
approvalId: error.approvalId,
|
|
897
|
-
status: error.currentStatus,
|
|
898
|
-
}, 409);
|
|
899
|
-
}
|
|
900
|
-
throw error;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
// ==========================================================================
|
|
904
|
-
// Bookings — method-chained for Hono RPC type inference
|
|
905
|
-
// ==========================================================================
|
|
906
|
-
export const bookingRoutes = new Hono()
|
|
907
|
-
// ==========================================================================
|
|
908
|
-
// Bookings CRUD
|
|
909
|
-
// ==========================================================================
|
|
910
|
-
// 1. GET / — List bookings
|
|
911
|
-
.get("/", async (c) => {
|
|
912
|
-
const query = parseQuery(c, bookingListQuerySchema);
|
|
913
|
-
const result = await bookingsService.listBookings(c.get("db"), query);
|
|
914
|
-
const reveal = shouldRevealBookingPii({
|
|
915
|
-
actor: c.get("actor"),
|
|
916
|
-
scopes: c.get("scopes"),
|
|
917
|
-
callerType: c.get("callerType"),
|
|
918
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
919
|
-
});
|
|
920
|
-
await logBookingPiiAccess(c, {
|
|
921
|
-
action: "read",
|
|
922
|
-
outcome: "allowed",
|
|
923
|
-
reason: reveal ? "list_reveal" : "list_redacted",
|
|
924
|
-
metadata: { rowCount: result.data.length, reveal },
|
|
925
|
-
});
|
|
926
|
-
if (reveal)
|
|
927
|
-
return c.json(result);
|
|
928
|
-
return c.json({
|
|
929
|
-
...result,
|
|
930
|
-
data: result.data.map((row) => redactBookingContact(row)),
|
|
931
|
-
});
|
|
932
|
-
})
|
|
933
|
-
// 1a. POST /pricing-preview — Resolve a pricing snapshot without creating a session.
|
|
934
|
-
.post("/pricing-preview", async (c) => {
|
|
935
|
-
const body = await parseJsonBody(c, pricingPreviewSchema);
|
|
936
|
-
const snapshot = await resolveSessionPricingSnapshot(c.get("db"), body.productId, {
|
|
937
|
-
optionId: body.optionId ?? undefined,
|
|
938
|
-
catalogId: body.catalogId ?? undefined,
|
|
939
|
-
requirePublicProduct: false,
|
|
940
|
-
});
|
|
941
|
-
if (!snapshot) {
|
|
942
|
-
return c.json({ error: "Pricing unavailable for this selection" }, 404);
|
|
943
|
-
}
|
|
944
|
-
return c.json({ data: snapshot });
|
|
945
|
-
})
|
|
946
|
-
// 1b. GET /aggregates — Pre-aggregated dashboard metrics. Served from a
|
|
947
|
-
// read-through TTL snapshot so a warm dashboard load is one indexed read
|
|
948
|
-
// instead of the full aggregate fan-out (#1629).
|
|
949
|
-
.get("/aggregates", async (c) => {
|
|
950
|
-
const query = parseQuery(c, bookingAggregatesQuerySchema);
|
|
951
|
-
cacheDashboardAggregates(c);
|
|
952
|
-
const snapshot = await readThroughAggregateSnapshot(c.get("db"), {
|
|
953
|
-
key: aggregateSnapshotKey("bookings", "aggregates", query),
|
|
954
|
-
ttlSeconds: DASHBOARD_AGGREGATES_TTL_SECONDS,
|
|
955
|
-
compute: () => bookingsService.getBookingAggregates(c.get("db"), query),
|
|
956
|
-
});
|
|
957
|
-
return c.json({ data: snapshot.data });
|
|
958
|
-
})
|
|
959
|
-
// 1b. GET /overview — Internal/admin booking overview lookup
|
|
960
|
-
.get("/overview", async (c) => {
|
|
961
|
-
const overview = await publicBookingsService.getOverviewByLookup(c.get("db"), parseQuery(c, internalBookingOverviewLookupQuerySchema));
|
|
962
|
-
if (!overview) {
|
|
963
|
-
return c.json({ error: "Booking overview not found" }, 404);
|
|
964
|
-
}
|
|
965
|
-
return c.json({ data: overview });
|
|
966
|
-
})
|
|
967
|
-
.get("/sharing-groups", async (c) => {
|
|
968
|
-
const query = parseQuery(c, sharingGroupsForSlotQuerySchema);
|
|
969
|
-
const data = await bookingsService.listSharingGroupsForSlot(c.get("db"), query.slotId);
|
|
970
|
-
return c.json({ data });
|
|
971
|
-
})
|
|
972
|
-
.get("/sharing-groups/:groupId/travelers", async (c) => {
|
|
973
|
-
const query = parseQuery(c, sharingGroupsForSlotQuerySchema);
|
|
974
|
-
const data = await bookingsService.listTravelersBySharingGroup(c.get("db"), query.slotId, c.req.param("groupId"));
|
|
975
|
-
const reveal = shouldRevealBookingPii({
|
|
976
|
-
actor: c.get("actor"),
|
|
977
|
-
scopes: c.get("scopes"),
|
|
978
|
-
callerType: c.get("callerType"),
|
|
979
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
980
|
-
});
|
|
981
|
-
await logBookingPiiAccess(c, {
|
|
982
|
-
action: "read",
|
|
983
|
-
outcome: "allowed",
|
|
984
|
-
reason: reveal ? "sharing_group_travelers_reveal" : "sharing_group_travelers_redacted",
|
|
985
|
-
metadata: { rowCount: data.length, reveal },
|
|
986
|
-
});
|
|
987
|
-
return c.json({ data: reveal ? data : data.map((row) => redactTravelerIdentity(row)) });
|
|
988
|
-
})
|
|
989
|
-
// 2. GET /:id — Get single booking
|
|
990
|
-
.get("/:id", async (c) => {
|
|
991
|
-
const row = await bookingsService.getBookingById(c.get("db"), c.req.param("id"));
|
|
992
|
-
if (!row) {
|
|
993
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
994
|
-
}
|
|
995
|
-
const reveal = shouldRevealBookingPii({
|
|
996
|
-
actor: c.get("actor"),
|
|
997
|
-
scopes: c.get("scopes"),
|
|
998
|
-
callerType: c.get("callerType"),
|
|
999
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
1000
|
-
});
|
|
1001
|
-
await logBookingPiiAccess(c, {
|
|
1002
|
-
bookingId: row.id,
|
|
1003
|
-
action: "read",
|
|
1004
|
-
outcome: "allowed",
|
|
1005
|
-
reason: reveal ? "detail_reveal" : "detail_redacted",
|
|
1006
|
-
metadata: { reveal },
|
|
1007
|
-
});
|
|
1008
|
-
return c.json({ data: reveal ? row : redactBookingContact(row) });
|
|
1009
|
-
})
|
|
1010
|
-
// 2b. GET /:id/action-ledger — Booking-scoped action timeline
|
|
1011
|
-
.get("/:id/action-ledger", listBookingActionLedger)
|
|
1012
|
-
// 2c. POST /:id/action-approvals/:approvalId/decide — Booking-scoped approval decision
|
|
1013
|
-
.post("/:id/action-approvals/:approvalId/decide", decideBookingActionApproval)
|
|
1014
|
-
// 3. POST /reserve — Reserve inventory and create on-hold booking
|
|
1015
|
-
.post("/reserve", idempotencyKey({ scope: "POST /v1/admin/bookings/reserve" }), async (c) => {
|
|
1016
|
-
const data = await parseJsonBody(c, reserveBookingSchema);
|
|
1017
|
-
await validateBookingBillingPartyReferences(c, data);
|
|
1018
|
-
const result = await bookingsService.reserveBooking(c.get("db"), data, c.get("userId"));
|
|
1019
|
-
if ("booking" in result) {
|
|
1020
|
-
return c.json({ data: result.booking }, 201);
|
|
1021
|
-
}
|
|
1022
|
-
if (result.status === "slot_not_found") {
|
|
1023
|
-
return c.json({ error: "Availability slot not found" }, 404);
|
|
1024
|
-
}
|
|
1025
|
-
if (result.status === "insufficient_capacity") {
|
|
1026
|
-
return c.json({ error: "Insufficient slot capacity" }, 409);
|
|
1027
|
-
}
|
|
1028
|
-
if (result.status === "slot_unavailable") {
|
|
1029
|
-
return c.json({ error: "Availability slot is not bookable" }, 409);
|
|
1030
|
-
}
|
|
1031
|
-
if (result.status === "slot_product_mismatch" || result.status === "slot_option_mismatch") {
|
|
1032
|
-
return c.json({ error: "Reservation item does not match availability slot" }, 409);
|
|
1033
|
-
}
|
|
1034
|
-
return c.json({ error: "Unable to reserve booking" }, 400);
|
|
1035
|
-
})
|
|
1036
|
-
// 3a. POST /from-product — Create booking draft from product definition
|
|
1037
|
-
.post("/from-product", idempotencyKey({ scope: "POST /v1/admin/bookings/from-product" }), async (c) => {
|
|
1038
|
-
const data = await parseJsonBody(c, convertProductSchema);
|
|
1039
|
-
await validateBookingBillingPartyReferences(c, data);
|
|
1040
|
-
const row = await bookingsService.createBookingFromProduct(c.get("db"), data, c.get("userId"));
|
|
1041
|
-
if (!row) {
|
|
1042
|
-
return c.json({ error: "Product or option not found" }, 404);
|
|
1043
|
-
}
|
|
1044
|
-
return c.json({ data: row }, 201);
|
|
1045
|
-
})
|
|
1046
|
-
// 3b. POST /from-offer/:offerId/reserve — Reserve booking from transaction offer
|
|
1047
|
-
.post("/from-offer/:offerId/reserve", idempotencyKey({ scope: "POST /v1/admin/bookings/from-offer" }), async (c) => {
|
|
1048
|
-
const result = await bookingsService.reserveBookingFromOffer(c.get("db"), c.req.param("offerId"), await parseJsonBody(c, reserveBookingFromTransactionSchema), c.get("userId"));
|
|
1049
|
-
if (result.status === "not_found") {
|
|
1050
|
-
return c.json({ error: "Offer not found" }, 404);
|
|
1051
|
-
}
|
|
1052
|
-
if (result.status === "slot_not_found") {
|
|
1053
|
-
return c.json({ error: "Availability slot not found" }, 404);
|
|
1054
|
-
}
|
|
1055
|
-
if (result.status === "insufficient_capacity") {
|
|
1056
|
-
return c.json({ error: "Insufficient slot capacity" }, 409);
|
|
1057
|
-
}
|
|
1058
|
-
if (result.status === "slot_unavailable") {
|
|
1059
|
-
return c.json({ error: "Availability slot is not bookable" }, 409);
|
|
1060
|
-
}
|
|
1061
|
-
if (result.status === "slot_product_mismatch" || result.status === "slot_option_mismatch") {
|
|
1062
|
-
return c.json({ error: "Reservation item does not match availability slot" }, 409);
|
|
1063
|
-
}
|
|
1064
|
-
if ("booking" in result) {
|
|
1065
|
-
return c.json({ data: result.booking }, 201);
|
|
1066
|
-
}
|
|
1067
|
-
return c.json({ error: "Unable to reserve booking from offer" }, 400);
|
|
1068
|
-
})
|
|
1069
|
-
// 3c. POST /from-order/:orderId/reserve — Reserve booking from transaction order
|
|
1070
|
-
.post("/from-order/:orderId/reserve", idempotencyKey({ scope: "POST /v1/admin/bookings/from-order" }), async (c) => {
|
|
1071
|
-
const result = await bookingsService.reserveBookingFromOrder(c.get("db"), c.req.param("orderId"), await parseJsonBody(c, reserveBookingFromTransactionSchema), c.get("userId"));
|
|
1072
|
-
if (result.status === "not_found") {
|
|
1073
|
-
return c.json({ error: "Order not found" }, 404);
|
|
1074
|
-
}
|
|
1075
|
-
if (result.status === "slot_not_found") {
|
|
1076
|
-
return c.json({ error: "Availability slot not found" }, 404);
|
|
1077
|
-
}
|
|
1078
|
-
if (result.status === "insufficient_capacity") {
|
|
1079
|
-
return c.json({ error: "Insufficient slot capacity" }, 409);
|
|
1080
|
-
}
|
|
1081
|
-
if (result.status === "slot_unavailable") {
|
|
1082
|
-
return c.json({ error: "Availability slot is not bookable" }, 409);
|
|
1083
|
-
}
|
|
1084
|
-
if (result.status === "slot_product_mismatch" || result.status === "slot_option_mismatch") {
|
|
1085
|
-
return c.json({ error: "Reservation item does not match availability slot" }, 409);
|
|
1086
|
-
}
|
|
1087
|
-
if ("booking" in result) {
|
|
1088
|
-
return c.json({ data: result.booking }, 201);
|
|
1089
|
-
}
|
|
1090
|
-
return c.json({ error: "Unable to reserve booking from order" }, 400);
|
|
1091
|
-
})
|
|
1092
|
-
// 4. POST / — Create booking (manual/backoffice only)
|
|
1093
|
-
.post("/", idempotencyKey({ scope: "POST /v1/admin/bookings" }), async (c) => {
|
|
1094
|
-
try {
|
|
1095
|
-
const data = await parseJsonBody(c, createBookingSchema, {
|
|
1096
|
-
invalidBodyMessage: "Invalid booking create payload",
|
|
1097
|
-
});
|
|
1098
|
-
await validateBookingBillingPartyReferences(c, data);
|
|
1099
|
-
return c.json({
|
|
1100
|
-
data: await bookingsService.createBooking(c.get("db"), data, c.get("userId")),
|
|
1101
|
-
}, 201);
|
|
1102
|
-
}
|
|
1103
|
-
catch (error) {
|
|
1104
|
-
const validationError = normalizeValidationError(error);
|
|
1105
|
-
if (validationError?.status === 400) {
|
|
1106
|
-
return c.json({
|
|
1107
|
-
error: validationError.message,
|
|
1108
|
-
details: validationError.details?.fields ?? validationError.details,
|
|
1109
|
-
}, 400);
|
|
1110
|
-
}
|
|
1111
|
-
throw error;
|
|
1112
|
-
}
|
|
1113
|
-
})
|
|
1114
|
-
// 5. PATCH /:id — Update booking
|
|
1115
|
-
.patch("/:id", async (c) => {
|
|
1116
|
-
const data = await parseJsonBody(c, updateBookingSchema);
|
|
1117
|
-
await validateBookingBillingPartyReferences(c, data);
|
|
1118
|
-
const row = await bookingsService.updateBooking(c.get("db"), c.req.param("id"), data);
|
|
1119
|
-
if (!row) {
|
|
1120
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1121
|
-
}
|
|
1122
|
-
return c.json({ data: row });
|
|
1123
|
-
})
|
|
1124
|
-
// 6. DELETE /:id — Delete booking
|
|
1125
|
-
.delete("/:id", async (c) => {
|
|
1126
|
-
const row = await bookingsService.deleteBooking(c.get("db"), c.req.param("id"));
|
|
1127
|
-
if (!row) {
|
|
1128
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1129
|
-
}
|
|
1130
|
-
return c.json({ success: true }, 200);
|
|
1131
|
-
})
|
|
1132
|
-
// ==========================================================================
|
|
1133
|
-
// Status
|
|
1134
|
-
// ==========================================================================
|
|
1135
|
-
// 8. POST /:id/confirm — Confirm an on-hold booking
|
|
1136
|
-
.post("/:id/confirm", async (c) => {
|
|
1137
|
-
const bookingId = c.req.param("id");
|
|
1138
|
-
const data = await parseJsonBody(c, confirmBookingSchema);
|
|
1139
|
-
const auth = await authorizeBookingStatusMutation(c, {
|
|
1140
|
-
key: "confirm",
|
|
1141
|
-
actionName: "booking.status.confirm",
|
|
1142
|
-
routeOrToolName: "bookings.confirm",
|
|
1143
|
-
bookingId,
|
|
1144
|
-
});
|
|
1145
|
-
if (!auth.allowed)
|
|
1146
|
-
return auth.response;
|
|
1147
|
-
const result = await bookingsService.confirmBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
1148
|
-
if (result.status === "not_found") {
|
|
1149
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1150
|
-
}
|
|
1151
|
-
if (result.status === "hold_expired") {
|
|
1152
|
-
return c.json({ error: "Booking hold has expired" }, 409);
|
|
1153
|
-
}
|
|
1154
|
-
if (result.status === "invalid_transition") {
|
|
1155
|
-
return c.json({ error: "Booking is not in an on-hold state" }, 409);
|
|
1156
|
-
}
|
|
1157
|
-
if ("booking" in result) {
|
|
1158
|
-
return c.json({ data: result.booking });
|
|
1159
|
-
}
|
|
1160
|
-
return c.json({ error: "Unable to confirm booking" }, 400);
|
|
1161
|
-
})
|
|
1162
|
-
// 9. POST /:id/extend-hold — Extend booking hold expiry
|
|
1163
|
-
.post("/:id/extend-hold", async (c) => {
|
|
1164
|
-
const result = await bookingsService.extendBookingHold(c.get("db"), c.req.param("id"), await parseJsonBody(c, extendBookingHoldSchema), c.get("userId"));
|
|
1165
|
-
if (result.status === "not_found") {
|
|
1166
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1167
|
-
}
|
|
1168
|
-
if (result.status === "hold_expired") {
|
|
1169
|
-
return c.json({ error: "Booking hold has expired" }, 409);
|
|
1170
|
-
}
|
|
1171
|
-
if (result.status === "invalid_transition") {
|
|
1172
|
-
return c.json({ error: "Booking is not in an on-hold state" }, 409);
|
|
1173
|
-
}
|
|
1174
|
-
if ("booking" in result) {
|
|
1175
|
-
return c.json({ data: result.booking });
|
|
1176
|
-
}
|
|
1177
|
-
return c.json({ error: "Unable to extend booking hold" }, 400);
|
|
1178
|
-
})
|
|
1179
|
-
// 10. POST /:id/expire — Expire an on-hold booking
|
|
1180
|
-
.post("/:id/expire", async (c) => {
|
|
1181
|
-
const bookingId = c.req.param("id");
|
|
1182
|
-
const data = await parseJsonBody(c, expireBookingSchema);
|
|
1183
|
-
const auth = await authorizeBookingStatusMutation(c, {
|
|
1184
|
-
key: "expire",
|
|
1185
|
-
actionName: "booking.status.expire",
|
|
1186
|
-
routeOrToolName: "bookings.expire",
|
|
1187
|
-
bookingId,
|
|
1188
|
-
});
|
|
1189
|
-
if (!auth.allowed)
|
|
1190
|
-
return auth.response;
|
|
1191
|
-
const result = await bookingsService.expireBooking(c.get("db"), bookingId, data, c.get("userId"), {
|
|
1192
|
-
...bookingStatusMutationRuntime(c, auth),
|
|
1193
|
-
cause: "route",
|
|
1194
|
-
});
|
|
1195
|
-
if (result.status === "not_found") {
|
|
1196
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1197
|
-
}
|
|
1198
|
-
if (result.status === "invalid_transition") {
|
|
1199
|
-
return c.json({ error: "Booking is not in an on-hold state" }, 409);
|
|
1200
|
-
}
|
|
1201
|
-
if ("booking" in result) {
|
|
1202
|
-
return c.json({ data: result.booking });
|
|
1203
|
-
}
|
|
1204
|
-
return c.json({ error: "Unable to expire booking" }, 400);
|
|
1205
|
-
})
|
|
1206
|
-
// 10b. POST /expire-stale — Expire all stale on-hold bookings up to a cutoff
|
|
1207
|
-
.post("/expire-stale", async (c) => {
|
|
1208
|
-
return c.json(await bookingsService.expireStaleBookings(c.get("db"), await parseJsonBody(c, expireStaleBookingsSchema), c.get("userId"), {
|
|
1209
|
-
eventBus: c.get("eventBus"),
|
|
1210
|
-
closePaymentSchedulesForBooking: getRouteRuntime(c).closePaymentSchedulesForBooking,
|
|
1211
|
-
}));
|
|
1212
|
-
})
|
|
1213
|
-
// 11. POST /:id/cancel — Cancel a booking and release allocations
|
|
1214
|
-
.post("/:id/cancel", async (c) => {
|
|
1215
|
-
const bookingId = c.req.param("id");
|
|
1216
|
-
const data = await parseJsonBody(c, cancelBookingSchema);
|
|
1217
|
-
const auth = await authorizeBookingStatusMutation(c, {
|
|
1218
|
-
key: "cancel",
|
|
1219
|
-
actionName: "booking.status.cancel",
|
|
1220
|
-
routeOrToolName: "bookings.cancel",
|
|
1221
|
-
bookingId,
|
|
1222
|
-
commandInput: data,
|
|
1223
|
-
});
|
|
1224
|
-
if (!auth.allowed)
|
|
1225
|
-
return auth.response;
|
|
1226
|
-
const result = await bookingsService.cancelBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
1227
|
-
if (result.status === "not_found") {
|
|
1228
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1229
|
-
}
|
|
1230
|
-
if (result.status === "invalid_transition") {
|
|
1231
|
-
return c.json({ error: "Booking cannot be cancelled from its current state" }, 409);
|
|
1232
|
-
}
|
|
1233
|
-
if ("booking" in result) {
|
|
1234
|
-
return c.json({ data: result.booking });
|
|
1235
|
-
}
|
|
1236
|
-
return c.json({ error: "Unable to cancel booking" }, 400);
|
|
1237
|
-
})
|
|
1238
|
-
// 11a. POST /:id/start — Mark a confirmed booking as in-progress
|
|
1239
|
-
.post("/:id/start", async (c) => {
|
|
1240
|
-
const bookingId = c.req.param("id");
|
|
1241
|
-
const data = await parseJsonBody(c, startBookingSchema);
|
|
1242
|
-
const auth = await authorizeBookingStatusMutation(c, {
|
|
1243
|
-
key: "start",
|
|
1244
|
-
actionName: "booking.status.start",
|
|
1245
|
-
routeOrToolName: "bookings.start",
|
|
1246
|
-
bookingId,
|
|
1247
|
-
});
|
|
1248
|
-
if (!auth.allowed)
|
|
1249
|
-
return auth.response;
|
|
1250
|
-
const result = await bookingsService.startBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
1251
|
-
if (result.status === "not_found") {
|
|
1252
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1253
|
-
}
|
|
1254
|
-
if (result.status === "invalid_transition") {
|
|
1255
|
-
return c.json({ error: "Booking is not in a confirmed state" }, 409);
|
|
1256
|
-
}
|
|
1257
|
-
if ("booking" in result) {
|
|
1258
|
-
return c.json({ data: result.booking });
|
|
1259
|
-
}
|
|
1260
|
-
return c.json({ error: "Unable to start booking" }, 400);
|
|
1261
|
-
})
|
|
1262
|
-
// 11b. POST /:id/complete — Mark an in-progress booking as completed
|
|
1263
|
-
.post("/:id/complete", async (c) => {
|
|
1264
|
-
const bookingId = c.req.param("id");
|
|
1265
|
-
const data = await parseJsonBody(c, completeBookingSchema);
|
|
1266
|
-
const auth = await authorizeBookingStatusMutation(c, {
|
|
1267
|
-
key: "complete",
|
|
1268
|
-
actionName: "booking.status.complete",
|
|
1269
|
-
routeOrToolName: "bookings.complete",
|
|
1270
|
-
bookingId,
|
|
1271
|
-
});
|
|
1272
|
-
if (!auth.allowed)
|
|
1273
|
-
return auth.response;
|
|
1274
|
-
const result = await bookingsService.completeBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
1275
|
-
if (result.status === "not_found") {
|
|
1276
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1277
|
-
}
|
|
1278
|
-
if (result.status === "invalid_transition") {
|
|
1279
|
-
return c.json({ error: "Booking is not in an in-progress state" }, 409);
|
|
1280
|
-
}
|
|
1281
|
-
if ("booking" in result) {
|
|
1282
|
-
return c.json({ data: result.booking });
|
|
1283
|
-
}
|
|
1284
|
-
return c.json({ error: "Unable to complete booking" }, 400);
|
|
1285
|
-
})
|
|
1286
|
-
// 11c. POST /:id/override-status — Admin override that bypasses the
|
|
1287
|
-
// transition graph. Terminal overrides cascade to items and allocations;
|
|
1288
|
-
// non-terminal overrides remain booking-row data correction. Always emits
|
|
1289
|
-
// booking.status_overridden for audit. Confirmed overrides also emit
|
|
1290
|
-
// booking.confirmed unless suppressLifecycleEvents is true. Requires a
|
|
1291
|
-
// non-empty `reason`.
|
|
1292
|
-
.post("/:id/override-status", async (c) => {
|
|
1293
|
-
const bookingId = c.req.param("id");
|
|
1294
|
-
const data = await parseJsonBody(c, overrideBookingStatusSchema);
|
|
1295
|
-
const auth = await authorizeBookingStatusMutation(c, {
|
|
1296
|
-
key: "override",
|
|
1297
|
-
actionName: "booking.status.override",
|
|
1298
|
-
routeOrToolName: "bookings.override-status",
|
|
1299
|
-
bookingId,
|
|
1300
|
-
commandInput: data,
|
|
1301
|
-
});
|
|
1302
|
-
if (!auth.allowed)
|
|
1303
|
-
return auth.response;
|
|
1304
|
-
const result = await bookingsService.overrideBookingStatus(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
1305
|
-
if (result.status === "not_found") {
|
|
1306
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1307
|
-
}
|
|
1308
|
-
if ("booking" in result) {
|
|
1309
|
-
return c.json({ data: result.booking });
|
|
1310
|
-
}
|
|
1311
|
-
return c.json({ error: "Unable to override booking status" }, 400);
|
|
1312
|
-
})
|
|
1313
|
-
// 12. GET /:id/allocations — List booking allocations
|
|
1314
|
-
.get("/:id/allocations", async (c) => {
|
|
1315
|
-
return c.json({ data: await bookingsService.listAllocations(c.get("db"), c.req.param("id")) });
|
|
1316
|
-
})
|
|
1317
|
-
// ==========================================================================
|
|
1318
|
-
// Travelers
|
|
1319
|
-
// ==========================================================================
|
|
1320
|
-
.get("/:id/travelers", async (c) => {
|
|
1321
|
-
const travelers = await bookingsService.listTravelers(c.get("db"), c.req.param("id"));
|
|
1322
|
-
const reveal = shouldRevealBookingPii({
|
|
1323
|
-
actor: c.get("actor"),
|
|
1324
|
-
scopes: c.get("scopes"),
|
|
1325
|
-
callerType: c.get("callerType"),
|
|
1326
|
-
isInternalRequest: c.get("isInternalRequest"),
|
|
1327
|
-
});
|
|
1328
|
-
await logBookingPiiAccess(c, {
|
|
1329
|
-
bookingId: c.req.param("id"),
|
|
1330
|
-
action: "read",
|
|
1331
|
-
outcome: "allowed",
|
|
1332
|
-
reason: reveal ? "travelers_reveal" : "travelers_redacted",
|
|
1333
|
-
metadata: { rowCount: travelers.length, reveal },
|
|
1334
|
-
});
|
|
1335
|
-
if (reveal)
|
|
1336
|
-
return c.json({ data: travelers });
|
|
1337
|
-
return c.json({ data: travelers.map((row) => redactTravelerIdentity(row)) });
|
|
1338
|
-
})
|
|
1339
|
-
/**
|
|
1340
|
-
* GET /:id/travelers/:travelerId/reveal
|
|
1341
|
-
*
|
|
1342
|
-
* Per-traveler unmasked read. The list endpoint masks PII for
|
|
1343
|
-
* non-superuser staff; this endpoint reveals on a single click so
|
|
1344
|
-
* the operator can confirm a phone / email when actually needed,
|
|
1345
|
-
* with the access logged as `traveler_reveal`. Authorization uses
|
|
1346
|
-
* the same policy as `/travel-details` (staff or `bookings-pii:read`
|
|
1347
|
-
* scope), so it Just Works for the dashboard's normal staff session.
|
|
1348
|
-
*
|
|
1349
|
-
* Returns 404 when the traveler isn't found, 403 when the caller
|
|
1350
|
-
* isn't authorized to see PII.
|
|
1351
|
-
*/
|
|
1352
|
-
.get("/:id/travelers/:travelerId/reveal", async (c) => {
|
|
1353
|
-
const bookingId = c.req.param("id");
|
|
1354
|
-
const travelerId = c.req.param("travelerId");
|
|
1355
|
-
const auth = await authorizeBookingPiiAccess(c, {
|
|
1356
|
-
bookingId,
|
|
1357
|
-
travelerId,
|
|
1358
|
-
action: "read",
|
|
1359
|
-
});
|
|
1360
|
-
if (!auth.allowed)
|
|
1361
|
-
return auth.response;
|
|
1362
|
-
const traveler = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
|
|
1363
|
-
if (!traveler) {
|
|
1364
|
-
await logBookingPiiAccess(c, {
|
|
1365
|
-
bookingId,
|
|
1366
|
-
travelerId,
|
|
1367
|
-
action: "read",
|
|
1368
|
-
outcome: "denied",
|
|
1369
|
-
reason: "traveler_not_found",
|
|
1370
|
-
});
|
|
1371
|
-
await logBookingPiiReadActionLedger(c, {
|
|
1372
|
-
travelerId,
|
|
1373
|
-
status: "denied",
|
|
1374
|
-
reason: "traveler_not_found",
|
|
1375
|
-
routeOrToolName: "bookings.travelers.reveal",
|
|
1376
|
-
disclosureSummary: "Booking PII read denied before reveal",
|
|
1377
|
-
authorizationSource: auth.access?.authorizationSource,
|
|
1378
|
-
evaluatedRisk: auth.access?.evaluatedRisk,
|
|
1379
|
-
});
|
|
1380
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1381
|
-
}
|
|
1382
|
-
let travelDetails;
|
|
1383
|
-
try {
|
|
1384
|
-
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
1385
|
-
travelDetails = await ledgerSensitiveRead(c.get("db"), {
|
|
1386
|
-
context: getActionLedgerRequestContext(c),
|
|
1387
|
-
actionName: BOOKING_PII_READ_ACTION_NAME,
|
|
1388
|
-
actionVersion: BOOKING_PII_READ_ACTION_VERSION,
|
|
1389
|
-
status: "succeeded",
|
|
1390
|
-
evaluatedRisk: auth.access?.evaluatedRisk ?? "high",
|
|
1391
|
-
targetType: "booking_traveler",
|
|
1392
|
-
targetId: traveler.id,
|
|
1393
|
-
routeOrToolName: "bookings.travelers.reveal",
|
|
1394
|
-
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
1395
|
-
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
1396
|
-
authorizationSource: auth.access?.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
1397
|
-
reasonCode: "traveler_reveal",
|
|
1398
|
-
disclosedFieldSet: [
|
|
1399
|
-
...TRAVELER_IDENTITY_DISCLOSED_FIELDS,
|
|
1400
|
-
...TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS,
|
|
1401
|
-
],
|
|
1402
|
-
disclosureSummary: "Traveler identity reveal",
|
|
1403
|
-
decisionPolicy: BOOKING_PII_DECISION_POLICY,
|
|
1404
|
-
}, () => pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId")));
|
|
1405
|
-
}
|
|
1406
|
-
catch (error) {
|
|
1407
|
-
return handleKmsConfigError(c, error);
|
|
1408
|
-
}
|
|
1409
|
-
await logBookingPiiAccess(c, {
|
|
1410
|
-
bookingId,
|
|
1411
|
-
travelerId,
|
|
1412
|
-
action: "read",
|
|
1413
|
-
outcome: "allowed",
|
|
1414
|
-
reason: "traveler_reveal",
|
|
1415
|
-
});
|
|
1416
|
-
return c.json({ data: { ...traveler, travelDetails } });
|
|
1417
|
-
})
|
|
1418
|
-
.get("/:id/travelers/:travelerId/travel-details", async (c) => {
|
|
1419
|
-
const auth = await authorizeBookingPiiAccess(c, {
|
|
1420
|
-
bookingId: c.req.param("id"),
|
|
1421
|
-
travelerId: c.req.param("travelerId"),
|
|
1422
|
-
action: "read",
|
|
1423
|
-
});
|
|
1424
|
-
if (!auth.allowed) {
|
|
1425
|
-
return auth.response;
|
|
1426
|
-
}
|
|
1427
|
-
const traveler = await bookingsService.getTravelerRecordById(c.get("db"), c.req.param("id"), c.req.param("travelerId"));
|
|
1428
|
-
if (!traveler) {
|
|
1429
|
-
await logBookingPiiAccess(c, {
|
|
1430
|
-
bookingId: c.req.param("id"),
|
|
1431
|
-
travelerId: c.req.param("travelerId"),
|
|
1432
|
-
action: "read",
|
|
1433
|
-
outcome: "denied",
|
|
1434
|
-
reason: "participant_not_found",
|
|
1435
|
-
});
|
|
1436
|
-
await logBookingPiiReadActionLedger(c, {
|
|
1437
|
-
travelerId: c.req.param("travelerId"),
|
|
1438
|
-
status: "denied",
|
|
1439
|
-
reason: "participant_not_found",
|
|
1440
|
-
routeOrToolName: "bookings.travelers.travel-details",
|
|
1441
|
-
disclosureSummary: "Booking PII read denied before reveal",
|
|
1442
|
-
authorizationSource: auth.access?.authorizationSource,
|
|
1443
|
-
evaluatedRisk: auth.access?.evaluatedRisk,
|
|
1444
|
-
});
|
|
1445
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1446
|
-
}
|
|
1447
|
-
let details;
|
|
1448
|
-
try {
|
|
1449
|
-
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
1450
|
-
details = await ledgerSensitiveRead(c.get("db"), {
|
|
1451
|
-
context: getActionLedgerRequestContext(c),
|
|
1452
|
-
actionName: BOOKING_PII_READ_ACTION_NAME,
|
|
1453
|
-
actionVersion: BOOKING_PII_READ_ACTION_VERSION,
|
|
1454
|
-
status: "succeeded",
|
|
1455
|
-
evaluatedRisk: auth.access?.evaluatedRisk ?? "high",
|
|
1456
|
-
targetType: "booking_traveler",
|
|
1457
|
-
targetId: traveler.id,
|
|
1458
|
-
routeOrToolName: "bookings.travelers.travel-details",
|
|
1459
|
-
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
1460
|
-
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
1461
|
-
authorizationSource: auth.access?.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
1462
|
-
reasonCode: "travel_details_reveal",
|
|
1463
|
-
disclosedFieldSet: TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS,
|
|
1464
|
-
disclosureSummary: "Traveler travel details reveal",
|
|
1465
|
-
decisionPolicy: BOOKING_PII_DECISION_POLICY,
|
|
1466
|
-
}, () => pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId")));
|
|
1467
|
-
}
|
|
1468
|
-
catch (error) {
|
|
1469
|
-
return handleKmsConfigError(c, error);
|
|
1470
|
-
}
|
|
1471
|
-
if (!details) {
|
|
1472
|
-
await logBookingPiiAccess(c, {
|
|
1473
|
-
bookingId: traveler.bookingId,
|
|
1474
|
-
travelerId: traveler.id,
|
|
1475
|
-
action: "read",
|
|
1476
|
-
outcome: "denied",
|
|
1477
|
-
reason: "travel_details_not_found",
|
|
1478
|
-
});
|
|
1479
|
-
return c.json({ error: "Traveler travel details not found" }, 404);
|
|
1480
|
-
}
|
|
1481
|
-
return c.json({ data: details });
|
|
1482
|
-
})
|
|
1483
|
-
.post("/:id/travelers", async (c) => {
|
|
1484
|
-
const body = await parseJsonBody(c, insertTravelerSchema);
|
|
1485
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1486
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1487
|
-
const txDb = tx;
|
|
1488
|
-
const row = await bookingsService.createTraveler(txDb, c.req.param("id"), body, c.get("userId"));
|
|
1489
|
-
if (!row)
|
|
1490
|
-
return null;
|
|
1491
|
-
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1492
|
-
action: "create",
|
|
1493
|
-
travelerId: row.id,
|
|
1494
|
-
changedFields: changedBookingTravelerFields(body, null, row),
|
|
1495
|
-
subject: "booking traveler",
|
|
1496
|
-
actionName: "booking.traveler.create",
|
|
1497
|
-
routeOrToolName: "bookings.travelers.create",
|
|
1498
|
-
evaluatedRisk: "high",
|
|
1499
|
-
});
|
|
1500
|
-
return row;
|
|
1501
|
-
});
|
|
1502
|
-
if (!row) {
|
|
1503
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1504
|
-
}
|
|
1505
|
-
return c.json({ data: row }, 201);
|
|
1506
|
-
})
|
|
1507
|
-
/**
|
|
1508
|
-
* Combined "create traveler + write encrypted travel details" path.
|
|
1509
|
-
* When `data.personId` is provided AND a `resolveTravelSnapshot`
|
|
1510
|
-
* resolver is wired on the runtime, the route auto-snapshots
|
|
1511
|
-
* dietary / accessibility / primary-passport from the linked
|
|
1512
|
-
* person record. Explicit input always wins over snapshot.
|
|
1513
|
-
*/
|
|
1514
|
-
.post("/:id/travelers/with-travel-details", async (c) => {
|
|
1515
|
-
try {
|
|
1516
|
-
const data = await parseJsonBody(c, createTravelerWithTravelDetailsSchema);
|
|
1517
|
-
const runtime = getRouteRuntime(c);
|
|
1518
|
-
const kms = await runtime.getKmsProvider();
|
|
1519
|
-
const pii = await createAuditedBookingPiiService(c, c.req.param("id"));
|
|
1520
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1521
|
-
const result = await c.get("db").transaction(async (tx) => {
|
|
1522
|
-
const txDb = tx;
|
|
1523
|
-
const result = await bookingsService.createTravelerWithTravelDetails(txDb, c.req.param("id"), data, {
|
|
1524
|
-
pii,
|
|
1525
|
-
userId: c.get("userId"),
|
|
1526
|
-
actorId: c.get("userId"),
|
|
1527
|
-
resolveTravelSnapshot: runtime.resolveTravelSnapshot
|
|
1528
|
-
? (personId) => runtime.resolveTravelSnapshot(txDb, personId, { kms })
|
|
1529
|
-
: undefined,
|
|
1530
|
-
});
|
|
1531
|
-
if (!result)
|
|
1532
|
-
return null;
|
|
1533
|
-
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1534
|
-
action: "create",
|
|
1535
|
-
travelerId: result.traveler.id,
|
|
1536
|
-
changedFields: [
|
|
1537
|
-
...new Set([
|
|
1538
|
-
...changedBookingTravelerFields(data, null, result.traveler),
|
|
1539
|
-
...changedBookingTravelDetailFields(data),
|
|
1540
|
-
]),
|
|
1541
|
-
].sort(),
|
|
1542
|
-
subject: "booking traveler with travel details",
|
|
1543
|
-
actionName: "booking.traveler_with_travel_details.create",
|
|
1544
|
-
routeOrToolName: "bookings.travelers.with-travel-details.create",
|
|
1545
|
-
evaluatedRisk: "high",
|
|
1546
|
-
});
|
|
1547
|
-
return result;
|
|
1548
|
-
});
|
|
1549
|
-
if (!result) {
|
|
1550
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1551
|
-
}
|
|
1552
|
-
return c.json({ data: result }, 201);
|
|
1553
|
-
}
|
|
1554
|
-
catch (error) {
|
|
1555
|
-
return handleKmsConfigError(c, error);
|
|
1556
|
-
}
|
|
1557
|
-
})
|
|
1558
|
-
/**
|
|
1559
|
-
* Combined "update traveler + (re-)write encrypted travel details"
|
|
1560
|
-
* path. Snapshot-on-update is intentionally NOT wired here — once a
|
|
1561
|
-
* booking is open, the traveler row is its own source of truth and
|
|
1562
|
-
* person-record edits should not retroactively rewrite trip data.
|
|
1563
|
-
*/
|
|
1564
|
-
.patch("/:id/travelers/:travelerId/with-travel-details", async (c) => {
|
|
1565
|
-
try {
|
|
1566
|
-
const bookingId = c.req.param("id");
|
|
1567
|
-
const travelerId = c.req.param("travelerId");
|
|
1568
|
-
// Enforce booking↔traveler pairing before delegating to the
|
|
1569
|
-
// service, which writes by traveler id only. Without this guard
|
|
1570
|
-
// a caller could pass /bookings/A/travelers/<traveler-from-B>
|
|
1571
|
-
// and write to B while the audit/PII context is built from A.
|
|
1572
|
-
const traveler = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
|
|
1573
|
-
if (!traveler) {
|
|
1574
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1575
|
-
}
|
|
1576
|
-
const data = await parseJsonBody(c, updateTravelerWithTravelDetailsSchema);
|
|
1577
|
-
const pii = await createAuditedBookingPiiService(c, bookingId);
|
|
1578
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1579
|
-
const result = await c.get("db").transaction(async (tx) => {
|
|
1580
|
-
const result = await bookingsService.updateTravelerWithTravelDetails(tx, travelerId, data, { pii, actorId: c.get("userId") });
|
|
1581
|
-
if (!result)
|
|
1582
|
-
return null;
|
|
1583
|
-
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1584
|
-
action: "update",
|
|
1585
|
-
travelerId: result.traveler.id,
|
|
1586
|
-
changedFields: [
|
|
1587
|
-
...new Set([
|
|
1588
|
-
...changedBookingTravelerFields(data, traveler, result.traveler),
|
|
1589
|
-
...changedBookingTravelDetailFields(data),
|
|
1590
|
-
]),
|
|
1591
|
-
].sort(),
|
|
1592
|
-
subject: "booking traveler with travel details",
|
|
1593
|
-
actionName: "booking.traveler_with_travel_details.update",
|
|
1594
|
-
routeOrToolName: "bookings.travelers.with-travel-details.update",
|
|
1595
|
-
evaluatedRisk: "high",
|
|
1596
|
-
});
|
|
1597
|
-
return result;
|
|
1598
|
-
});
|
|
1599
|
-
if (!result) {
|
|
1600
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1601
|
-
}
|
|
1602
|
-
return c.json({ data: result });
|
|
1603
|
-
}
|
|
1604
|
-
catch (error) {
|
|
1605
|
-
return handleKmsConfigError(c, error);
|
|
1606
|
-
}
|
|
1607
|
-
})
|
|
1608
|
-
.patch("/:id/travelers/:travelerId/travel-details", async (c) => {
|
|
1609
|
-
const auth = await authorizeBookingPiiAccess(c, {
|
|
1610
|
-
bookingId: c.req.param("id"),
|
|
1611
|
-
travelerId: c.req.param("travelerId"),
|
|
1612
|
-
action: "update",
|
|
1613
|
-
});
|
|
1614
|
-
if (!auth.allowed) {
|
|
1615
|
-
return auth.response;
|
|
1616
|
-
}
|
|
1617
|
-
const traveler = await bookingsService.getTravelerRecordById(c.get("db"), c.req.param("id"), c.req.param("travelerId"));
|
|
1618
|
-
if (!traveler) {
|
|
1619
|
-
await logBookingPiiAccess(c, {
|
|
1620
|
-
bookingId: c.req.param("id"),
|
|
1621
|
-
travelerId: c.req.param("travelerId"),
|
|
1622
|
-
action: "update",
|
|
1623
|
-
outcome: "denied",
|
|
1624
|
-
reason: "participant_not_found",
|
|
1625
|
-
});
|
|
1626
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1627
|
-
}
|
|
1628
|
-
try {
|
|
1629
|
-
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
1630
|
-
const body = await parseJsonBody(c, upsertTravelerTravelDetailsSchema);
|
|
1631
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1632
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1633
|
-
const row = await pii.upsertTravelerTravelDetails(tx, traveler.id, body, c.get("userId"));
|
|
1634
|
-
if (!row)
|
|
1635
|
-
return null;
|
|
1636
|
-
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1637
|
-
action: "update",
|
|
1638
|
-
travelerId: traveler.id,
|
|
1639
|
-
changedFields: changedBookingTravelDetailFields(body),
|
|
1640
|
-
subject: "booking traveler travel details",
|
|
1641
|
-
actionName: "booking.traveler_travel_details.update",
|
|
1642
|
-
routeOrToolName: "bookings.travelers.travel-details.update",
|
|
1643
|
-
evaluatedRisk: "high",
|
|
1644
|
-
});
|
|
1645
|
-
return row;
|
|
1646
|
-
});
|
|
1647
|
-
if (!row) {
|
|
1648
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1649
|
-
}
|
|
1650
|
-
return c.json({ data: row });
|
|
1651
|
-
}
|
|
1652
|
-
catch (error) {
|
|
1653
|
-
return handleKmsConfigError(c, error);
|
|
1654
|
-
}
|
|
1655
|
-
})
|
|
1656
|
-
.patch("/:id/travelers/:travelerId", async (c) => {
|
|
1657
|
-
const bookingId = c.req.param("id");
|
|
1658
|
-
const travelerId = c.req.param("travelerId");
|
|
1659
|
-
const before = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
|
|
1660
|
-
if (!before) {
|
|
1661
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1662
|
-
}
|
|
1663
|
-
const body = await parseJsonBody(c, updateTravelerSchema);
|
|
1664
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1665
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1666
|
-
const row = await bookingsService.updateTraveler(tx, travelerId, body);
|
|
1667
|
-
if (!row)
|
|
1668
|
-
return null;
|
|
1669
|
-
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1670
|
-
action: "update",
|
|
1671
|
-
travelerId: row.id,
|
|
1672
|
-
changedFields: changedBookingTravelerFields(body, before, row),
|
|
1673
|
-
subject: "booking traveler",
|
|
1674
|
-
actionName: "booking.traveler.update",
|
|
1675
|
-
routeOrToolName: "bookings.travelers.update",
|
|
1676
|
-
evaluatedRisk: "high",
|
|
1677
|
-
});
|
|
1678
|
-
return row;
|
|
1679
|
-
});
|
|
1680
|
-
if (!row) {
|
|
1681
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1682
|
-
}
|
|
1683
|
-
return c.json({ data: row });
|
|
1684
|
-
})
|
|
1685
|
-
.delete("/:id/travelers/:travelerId/travel-details", async (c) => {
|
|
1686
|
-
const auth = await authorizeBookingPiiAccess(c, {
|
|
1687
|
-
bookingId: c.req.param("id"),
|
|
1688
|
-
travelerId: c.req.param("travelerId"),
|
|
1689
|
-
action: "delete",
|
|
1690
|
-
});
|
|
1691
|
-
if (!auth.allowed) {
|
|
1692
|
-
return auth.response;
|
|
1693
|
-
}
|
|
1694
|
-
const traveler = await bookingsService.getTravelerRecordById(c.get("db"), c.req.param("id"), c.req.param("travelerId"));
|
|
1695
|
-
if (!traveler) {
|
|
1696
|
-
await logBookingPiiAccess(c, {
|
|
1697
|
-
bookingId: c.req.param("id"),
|
|
1698
|
-
travelerId: c.req.param("travelerId"),
|
|
1699
|
-
action: "delete",
|
|
1700
|
-
outcome: "denied",
|
|
1701
|
-
reason: "participant_not_found",
|
|
1702
|
-
});
|
|
1703
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1704
|
-
}
|
|
1705
|
-
try {
|
|
1706
|
-
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
1707
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1708
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1709
|
-
const row = await pii.deleteTravelerTravelDetails(tx, traveler.id, c.get("userId"));
|
|
1710
|
-
if (!row)
|
|
1711
|
-
return null;
|
|
1712
|
-
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1713
|
-
action: "delete",
|
|
1714
|
-
travelerId: traveler.id,
|
|
1715
|
-
changedFields: [],
|
|
1716
|
-
subject: "booking traveler travel details",
|
|
1717
|
-
actionName: "booking.traveler_travel_details.delete",
|
|
1718
|
-
routeOrToolName: "bookings.travelers.travel-details.delete",
|
|
1719
|
-
evaluatedRisk: "high",
|
|
1720
|
-
});
|
|
1721
|
-
return row;
|
|
1722
|
-
});
|
|
1723
|
-
if (!row) {
|
|
1724
|
-
return c.json({ error: "Traveler travel details not found" }, 404);
|
|
1725
|
-
}
|
|
1726
|
-
return c.json({ success: true }, 200);
|
|
1727
|
-
}
|
|
1728
|
-
catch (error) {
|
|
1729
|
-
return handleKmsConfigError(c, error);
|
|
1730
|
-
}
|
|
1731
|
-
})
|
|
1732
|
-
.delete("/:id/travelers/:travelerId", async (c) => {
|
|
1733
|
-
const bookingId = c.req.param("id");
|
|
1734
|
-
const travelerId = c.req.param("travelerId");
|
|
1735
|
-
const before = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
|
|
1736
|
-
if (!before) {
|
|
1737
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1738
|
-
}
|
|
1739
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1740
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1741
|
-
const row = await bookingsService.deleteTraveler(tx, travelerId);
|
|
1742
|
-
if (!row)
|
|
1743
|
-
return null;
|
|
1744
|
-
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1745
|
-
action: "delete",
|
|
1746
|
-
travelerId,
|
|
1747
|
-
changedFields: [],
|
|
1748
|
-
subject: "booking traveler",
|
|
1749
|
-
actionName: "booking.traveler.delete",
|
|
1750
|
-
routeOrToolName: "bookings.travelers.delete",
|
|
1751
|
-
evaluatedRisk: "high",
|
|
1752
|
-
});
|
|
1753
|
-
return row;
|
|
1754
|
-
});
|
|
1755
|
-
if (!row) {
|
|
1756
|
-
return c.json({ error: "Traveler not found" }, 404);
|
|
1757
|
-
}
|
|
1758
|
-
return c.json({ success: true }, 200);
|
|
1759
|
-
})
|
|
1760
|
-
// ==========================================================================
|
|
1761
|
-
// Items
|
|
1762
|
-
// ==========================================================================
|
|
1763
|
-
// 16. GET /:id/items — List booking items
|
|
1764
|
-
.get("/:id/items", async (c) => {
|
|
1765
|
-
return c.json({ data: await bookingsService.listItems(c.get("db"), c.req.param("id")) });
|
|
1766
|
-
})
|
|
1767
|
-
// 17. POST /:id/items — Add booking item
|
|
1768
|
-
.post("/:id/items", async (c) => {
|
|
1769
|
-
const body = await parseJsonBody(c, insertBookingItemSchema);
|
|
1770
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1771
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1772
|
-
const row = await bookingsService.createItem(tx, c.req.param("id"), body, c.get("userId"));
|
|
1773
|
-
if (!row)
|
|
1774
|
-
return null;
|
|
1775
|
-
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1776
|
-
action: "create",
|
|
1777
|
-
actionName: "booking.item.create",
|
|
1778
|
-
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1779
|
-
targetType: "booking_item",
|
|
1780
|
-
targetId: row.id,
|
|
1781
|
-
changedFields: changedBookingItemFields(body, null, row),
|
|
1782
|
-
subject: "booking item",
|
|
1783
|
-
routeOrToolName: "bookings.items.create",
|
|
1784
|
-
evaluatedRisk: "high",
|
|
1785
|
-
});
|
|
1786
|
-
return row;
|
|
1787
|
-
});
|
|
1788
|
-
if (!row) {
|
|
1789
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1790
|
-
}
|
|
1791
|
-
return c.json({ data: row }, 201);
|
|
1792
|
-
})
|
|
1793
|
-
// 18. PATCH /:id/items/:itemId — Update booking item
|
|
1794
|
-
.patch("/:id/items/:itemId", async (c) => {
|
|
1795
|
-
const bookingId = c.req.param("id");
|
|
1796
|
-
const itemId = c.req.param("itemId");
|
|
1797
|
-
const before = (await bookingsService.listItems(c.get("db"), bookingId)).find((item) => item.id === itemId) ?? null;
|
|
1798
|
-
if (!before) {
|
|
1799
|
-
return c.json({ error: "Booking item not found" }, 404);
|
|
1800
|
-
}
|
|
1801
|
-
const body = await parseJsonBody(c, updateBookingItemSchema);
|
|
1802
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1803
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1804
|
-
const row = await bookingsService.updateItem(tx, itemId, body);
|
|
1805
|
-
if (!row)
|
|
1806
|
-
return null;
|
|
1807
|
-
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1808
|
-
action: "update",
|
|
1809
|
-
actionName: "booking.item.update",
|
|
1810
|
-
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1811
|
-
targetType: "booking_item",
|
|
1812
|
-
targetId: row.id,
|
|
1813
|
-
changedFields: changedBookingItemFields(body, before, row),
|
|
1814
|
-
subject: "booking item",
|
|
1815
|
-
routeOrToolName: "bookings.items.update",
|
|
1816
|
-
evaluatedRisk: "high",
|
|
1817
|
-
});
|
|
1818
|
-
return row;
|
|
1819
|
-
});
|
|
1820
|
-
if (!row) {
|
|
1821
|
-
return c.json({ error: "Booking item not found" }, 404);
|
|
1822
|
-
}
|
|
1823
|
-
return c.json({ data: row });
|
|
1824
|
-
})
|
|
1825
|
-
// 19. DELETE /:id/items/:itemId — Delete booking item
|
|
1826
|
-
.delete("/:id/items/:itemId", async (c) => {
|
|
1827
|
-
const bookingId = c.req.param("id");
|
|
1828
|
-
const itemId = c.req.param("itemId");
|
|
1829
|
-
const before = (await bookingsService.listItems(c.get("db"), bookingId)).find((item) => item.id === itemId) ?? null;
|
|
1830
|
-
if (!before) {
|
|
1831
|
-
return c.json({ error: "Booking item not found" }, 404);
|
|
1832
|
-
}
|
|
1833
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1834
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1835
|
-
const row = await bookingsService.deleteItem(tx, itemId);
|
|
1836
|
-
if (!row)
|
|
1837
|
-
return null;
|
|
1838
|
-
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1839
|
-
action: "delete",
|
|
1840
|
-
actionName: "booking.item.delete",
|
|
1841
|
-
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1842
|
-
targetType: "booking_item",
|
|
1843
|
-
targetId: itemId,
|
|
1844
|
-
changedFields: [],
|
|
1845
|
-
subject: "booking item",
|
|
1846
|
-
routeOrToolName: "bookings.items.delete",
|
|
1847
|
-
evaluatedRisk: "high",
|
|
1848
|
-
summary: "Deleted booking item",
|
|
1849
|
-
});
|
|
1850
|
-
return row;
|
|
1851
|
-
});
|
|
1852
|
-
if (!row) {
|
|
1853
|
-
return c.json({ error: "Booking item not found" }, 404);
|
|
1854
|
-
}
|
|
1855
|
-
return c.json({ success: true }, 200);
|
|
1856
|
-
})
|
|
1857
|
-
// 20. GET /:id/items/:itemId/travelers — List item travelers
|
|
1858
|
-
.get("/:id/items/:itemId/travelers", async (c) => {
|
|
1859
|
-
return c.json({
|
|
1860
|
-
data: await bookingsService.listItemParticipants(c.get("db"), c.req.param("itemId")),
|
|
1861
|
-
});
|
|
1862
|
-
})
|
|
1863
|
-
// 21. POST /:id/items/:itemId/travelers — Link traveler to item
|
|
1864
|
-
.post("/:id/items/:itemId/travelers", async (c) => {
|
|
1865
|
-
const bookingId = c.req.param("id");
|
|
1866
|
-
const itemId = c.req.param("itemId");
|
|
1867
|
-
const item = (await bookingsService.listItems(c.get("db"), bookingId)).find((row) => row.id === itemId) ??
|
|
1868
|
-
null;
|
|
1869
|
-
if (!item) {
|
|
1870
|
-
return c.json({ error: "Booking item not found" }, 404);
|
|
1871
|
-
}
|
|
1872
|
-
const body = await parseJsonBody(c, insertBookingItemTravelerSchema);
|
|
1873
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1874
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1875
|
-
const row = await bookingsService.addItemParticipant(tx, itemId, body);
|
|
1876
|
-
if (!row)
|
|
1877
|
-
return null;
|
|
1878
|
-
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1879
|
-
action: "create",
|
|
1880
|
-
actionName: "booking.item_traveler.create",
|
|
1881
|
-
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1882
|
-
targetType: "booking_item",
|
|
1883
|
-
targetId: itemId,
|
|
1884
|
-
changedFields: changedBookingItemFields(body, null, row),
|
|
1885
|
-
subject: "booking item traveler link",
|
|
1886
|
-
routeOrToolName: "bookings.items.travelers.create",
|
|
1887
|
-
evaluatedRisk: "high",
|
|
1888
|
-
summary: "Linked traveler to booking item",
|
|
1889
|
-
});
|
|
1890
|
-
return row;
|
|
1891
|
-
});
|
|
1892
|
-
if (!row) {
|
|
1893
|
-
return c.json({ error: "Booking item or traveler not found" }, 404);
|
|
1894
|
-
}
|
|
1895
|
-
return c.json({ data: row }, 201);
|
|
1896
|
-
})
|
|
1897
|
-
// 22. DELETE /:id/items/:itemId/travelers/:linkId — Unlink traveler from item
|
|
1898
|
-
.delete("/:id/items/:itemId/travelers/:linkId", async (c) => {
|
|
1899
|
-
const bookingId = c.req.param("id");
|
|
1900
|
-
const itemId = c.req.param("itemId");
|
|
1901
|
-
const linkId = c.req.param("linkId");
|
|
1902
|
-
const item = (await bookingsService.listItems(c.get("db"), bookingId)).find((row) => row.id === itemId) ??
|
|
1903
|
-
null;
|
|
1904
|
-
if (!item) {
|
|
1905
|
-
return c.json({ error: "Booking item not found" }, 404);
|
|
1906
|
-
}
|
|
1907
|
-
const before = (await bookingsService.listItemParticipants(c.get("db"), itemId)).find((link) => link.id === linkId) ?? null;
|
|
1908
|
-
if (!before) {
|
|
1909
|
-
return c.json({ error: "Booking item traveler link not found" }, 404);
|
|
1910
|
-
}
|
|
1911
|
-
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1912
|
-
const row = await c.get("db").transaction(async (tx) => {
|
|
1913
|
-
const row = await bookingsService.removeItemParticipant(tx, linkId);
|
|
1914
|
-
if (!row)
|
|
1915
|
-
return null;
|
|
1916
|
-
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1917
|
-
action: "delete",
|
|
1918
|
-
actionName: "booking.item_traveler.delete",
|
|
1919
|
-
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1920
|
-
targetType: "booking_item",
|
|
1921
|
-
targetId: itemId,
|
|
1922
|
-
changedFields: [],
|
|
1923
|
-
subject: "booking item traveler link",
|
|
1924
|
-
routeOrToolName: "bookings.items.travelers.delete",
|
|
1925
|
-
evaluatedRisk: "high",
|
|
1926
|
-
summary: "Unlinked traveler from booking item",
|
|
1927
|
-
});
|
|
1928
|
-
return row;
|
|
1929
|
-
});
|
|
1930
|
-
if (!row) {
|
|
1931
|
-
return c.json({ error: "Booking item traveler link not found" }, 404);
|
|
1932
|
-
}
|
|
1933
|
-
return c.json({ success: true }, 200);
|
|
1934
|
-
})
|
|
1935
|
-
// ==========================================================================
|
|
1936
|
-
// Supplier Statuses
|
|
1937
|
-
// ==========================================================================
|
|
1938
|
-
.get("/:id/supplier-statuses", async (c) => {
|
|
1939
|
-
return c.json({
|
|
1940
|
-
data: await bookingsService.listSupplierStatuses(c.get("db"), c.req.param("id")),
|
|
1941
|
-
});
|
|
1942
|
-
})
|
|
1943
|
-
.post("/:id/supplier-statuses", async (c) => {
|
|
1944
|
-
const row = await bookingsService.createSupplierStatus(c.get("db"), c.req.param("id"), await parseJsonBody(c, insertSupplierStatusSchema), c.get("userId"));
|
|
1945
|
-
if (!row) {
|
|
1946
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
1947
|
-
}
|
|
1948
|
-
return c.json({ data: row }, 201);
|
|
1949
|
-
})
|
|
1950
|
-
.patch("/:id/supplier-statuses/:statusId", async (c) => {
|
|
1951
|
-
const row = await bookingsService.updateSupplierStatus(c.get("db"), c.req.param("id"), c.req.param("statusId"), await parseJsonBody(c, updateSupplierStatusSchema), c.get("userId"));
|
|
1952
|
-
if (!row) {
|
|
1953
|
-
return c.json({ error: "Supplier status not found" }, 404);
|
|
1954
|
-
}
|
|
1955
|
-
return c.json({ data: row });
|
|
1956
|
-
})
|
|
1957
|
-
// ==========================================================================
|
|
1958
|
-
// Fulfillment
|
|
1959
|
-
// ==========================================================================
|
|
1960
|
-
.get("/:id/fulfillments", async (c) => {
|
|
1961
|
-
return c.json({ data: await bookingsService.listFulfillments(c.get("db"), c.req.param("id")) });
|
|
1962
|
-
})
|
|
1963
|
-
.post("/:id/fulfillments", async (c) => {
|
|
1964
|
-
const row = await bookingsService.issueFulfillment(c.get("db"), c.req.param("id"), await parseJsonBody(c, insertBookingFulfillmentSchema), c.get("userId"));
|
|
1965
|
-
if (!row) {
|
|
1966
|
-
return c.json({ error: "Booking, item, or traveler not found" }, 404);
|
|
1967
|
-
}
|
|
1968
|
-
return c.json({ data: row }, 201);
|
|
1969
|
-
})
|
|
1970
|
-
.patch("/:id/fulfillments/:fulfillmentId", async (c) => {
|
|
1971
|
-
const row = await bookingsService.updateFulfillment(c.get("db"), c.req.param("id"), c.req.param("fulfillmentId"), await parseJsonBody(c, updateBookingFulfillmentSchema), c.get("userId"));
|
|
1972
|
-
if (!row) {
|
|
1973
|
-
return c.json({ error: "Fulfillment, item, or traveler not found" }, 404);
|
|
1974
|
-
}
|
|
1975
|
-
return c.json({ data: row });
|
|
1976
|
-
})
|
|
1977
|
-
// ==========================================================================
|
|
1978
|
-
// Redemption
|
|
1979
|
-
// ==========================================================================
|
|
1980
|
-
.get("/:id/redemptions", async (c) => {
|
|
1981
|
-
return c.json({
|
|
1982
|
-
data: await bookingsService.listRedemptionEvents(c.get("db"), c.req.param("id")),
|
|
1983
|
-
});
|
|
1984
|
-
})
|
|
1985
|
-
.post("/:id/redemptions", async (c) => {
|
|
1986
|
-
const row = await bookingsService.recordRedemption(c.get("db"), c.req.param("id"), await parseJsonBody(c, recordBookingRedemptionSchema), c.get("userId"));
|
|
1987
|
-
if (!row) {
|
|
1988
|
-
return c.json({ error: "Booking, item, or traveler not found" }, 404);
|
|
1989
|
-
}
|
|
1990
|
-
return c.json({ data: row }, 201);
|
|
1991
|
-
})
|
|
1992
|
-
// ==========================================================================
|
|
1993
|
-
// Activity Log
|
|
1994
|
-
// ==========================================================================
|
|
1995
|
-
// 26. GET /:id/activity — List activity log
|
|
1996
|
-
.get("/:id/activity", async (c) => {
|
|
1997
|
-
return c.json({ data: await bookingsService.listActivity(c.get("db"), c.req.param("id")) });
|
|
1998
|
-
})
|
|
1999
|
-
// 26a. GET /:id/group — Shared-room group membership for this booking (or null)
|
|
2000
|
-
.get("/:id/group", async (c) => {
|
|
2001
|
-
const result = await bookingGroupsService.getBookingGroupForBooking(c.get("db"), c.req.param("id"));
|
|
2002
|
-
return c.json({ data: result ?? null });
|
|
2003
|
-
})
|
|
2004
|
-
// ==========================================================================
|
|
2005
|
-
// Notes
|
|
2006
|
-
// ==========================================================================
|
|
2007
|
-
// 27. GET /:id/notes — List notes
|
|
2008
|
-
.get("/:id/notes", async (c) => {
|
|
2009
|
-
return c.json({ data: await bookingsService.listNotes(c.get("db"), c.req.param("id")) });
|
|
2010
|
-
})
|
|
2011
|
-
// 28. POST /:id/notes — Add note
|
|
2012
|
-
.post("/:id/notes", async (c) => {
|
|
2013
|
-
const userId = requireUserId(c);
|
|
2014
|
-
const bookingId = c.req.param("id");
|
|
2015
|
-
const row = await bookingsService.createNote(c.get("db"), bookingId, userId, await parseJsonBody(c, insertBookingNoteSchema));
|
|
2016
|
-
if (!row) {
|
|
2017
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
2018
|
-
}
|
|
2019
|
-
await appendBookingMutationLedgerEntry(c, {
|
|
2020
|
-
action: "create",
|
|
2021
|
-
actionName: "booking.note.create",
|
|
2022
|
-
actionVersion: BOOKING_NOTE_LEDGER_ACTION_VERSION,
|
|
2023
|
-
targetType: "booking",
|
|
2024
|
-
targetId: bookingId,
|
|
2025
|
-
changedFields: ["content"],
|
|
2026
|
-
subject: "booking note",
|
|
2027
|
-
routeOrToolName: "bookings.notes.create",
|
|
2028
|
-
evaluatedRisk: "medium",
|
|
2029
|
-
summary: "Created booking note",
|
|
2030
|
-
});
|
|
2031
|
-
return c.json({ data: row }, 201);
|
|
2032
|
-
})
|
|
2033
|
-
// 28b. PATCH /:id/notes/:noteId — Edit note
|
|
2034
|
-
.patch("/:id/notes/:noteId", async (c) => {
|
|
2035
|
-
const bookingId = c.req.param("id");
|
|
2036
|
-
const noteId = c.req.param("noteId");
|
|
2037
|
-
const row = await bookingsService.updateNote(c.get("db"), bookingId, noteId, await parseJsonBody(c, updateBookingNoteSchema));
|
|
2038
|
-
if (!row) {
|
|
2039
|
-
return c.json({ error: "Note not found" }, 404);
|
|
2040
|
-
}
|
|
2041
|
-
await appendBookingMutationLedgerEntry(c, {
|
|
2042
|
-
action: "update",
|
|
2043
|
-
actionName: "booking.note.update",
|
|
2044
|
-
actionVersion: BOOKING_NOTE_LEDGER_ACTION_VERSION,
|
|
2045
|
-
targetType: "booking",
|
|
2046
|
-
targetId: bookingId,
|
|
2047
|
-
changedFields: ["content"],
|
|
2048
|
-
subject: "booking note",
|
|
2049
|
-
routeOrToolName: "bookings.notes.update",
|
|
2050
|
-
evaluatedRisk: "medium",
|
|
2051
|
-
summary: "Updated booking note",
|
|
2052
|
-
});
|
|
2053
|
-
return c.json({ data: row });
|
|
2054
|
-
})
|
|
2055
|
-
// 28c. DELETE /:id/notes/:noteId — Delete note
|
|
2056
|
-
.delete("/:id/notes/:noteId", async (c) => {
|
|
2057
|
-
const bookingId = c.req.param("id");
|
|
2058
|
-
const row = await bookingsService.deleteNote(c.get("db"), bookingId, c.req.param("noteId"));
|
|
2059
|
-
if (!row) {
|
|
2060
|
-
return c.json({ error: "Note not found" }, 404);
|
|
2061
|
-
}
|
|
2062
|
-
await appendBookingMutationLedgerEntry(c, {
|
|
2063
|
-
action: "delete",
|
|
2064
|
-
actionName: "booking.note.delete",
|
|
2065
|
-
actionVersion: BOOKING_NOTE_LEDGER_ACTION_VERSION,
|
|
2066
|
-
targetType: "booking",
|
|
2067
|
-
targetId: bookingId,
|
|
2068
|
-
changedFields: [],
|
|
2069
|
-
subject: "booking note",
|
|
2070
|
-
routeOrToolName: "bookings.notes.delete",
|
|
2071
|
-
evaluatedRisk: "medium",
|
|
2072
|
-
summary: "Deleted booking note",
|
|
2073
|
-
});
|
|
2074
|
-
return c.json({ success: true }, 200);
|
|
2075
|
-
})
|
|
2076
|
-
// ==========================================================================
|
|
2077
|
-
// Documents
|
|
2078
|
-
// ==========================================================================
|
|
2079
|
-
// 29. GET /:id/documents — List documents for booking
|
|
2080
|
-
.get("/:id/documents", async (c) => {
|
|
2081
|
-
return c.json({ data: await bookingsService.listDocuments(c.get("db"), c.req.param("id")) });
|
|
2082
|
-
})
|
|
2083
|
-
// 30. POST /:id/documents — Add document to booking
|
|
2084
|
-
.post("/:id/documents", async (c) => {
|
|
2085
|
-
const row = await bookingsService.createDocument(c.get("db"), c.req.param("id"), await parseJsonBody(c, insertBookingDocumentSchema));
|
|
2086
|
-
if (!row) {
|
|
2087
|
-
return c.json({ error: "Booking not found" }, 404);
|
|
2088
|
-
}
|
|
2089
|
-
return c.json({ data: row }, 201);
|
|
2090
|
-
})
|
|
2091
|
-
// 31. DELETE /:id/documents/:documentId — Delete document
|
|
2092
|
-
.delete("/:id/documents/:documentId", async (c) => {
|
|
2093
|
-
const row = await bookingsService.deleteDocument(c.get("db"), c.req.param("documentId"));
|
|
2094
|
-
if (!row) {
|
|
2095
|
-
return c.json({ error: "Document not found" }, 404);
|
|
2096
|
-
}
|
|
2097
|
-
return c.json({ success: true }, 200);
|
|
2098
|
-
})
|
|
2099
|
-
// ==========================================================================
|
|
2100
|
-
// Booking Groups (shared-room / split-booking model)
|
|
2101
|
-
// ==========================================================================
|
|
2102
|
-
.route("/groups", bookingGroupRoutes);
|
|
2103
|
-
export const __test__ = {
|
|
2104
|
-
bookingActionLedgerQuerySchema,
|
|
2105
|
-
bookingMutationSummary,
|
|
2106
|
-
buildBookingActionLedgerPage,
|
|
2107
|
-
changedBookingItemFields,
|
|
2108
|
-
changedBookingMutationFields,
|
|
2109
|
-
changedBookingTravelDetailFields,
|
|
2110
|
-
changedBookingTravelerFields,
|
|
2111
|
-
};
|
|
1
|
+
export * from "./routes-admin.js";
|