@voyantjs/bookings 0.52.2 → 0.52.4
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/README.md +16 -0
- package/dist/action-ledger-capabilities.d.ts +306 -0
- package/dist/action-ledger-capabilities.d.ts.map +1 -0
- package/dist/action-ledger-capabilities.js +92 -0
- package/dist/action-ledger-drift-remediation.d.ts +30 -0
- package/dist/action-ledger-drift-remediation.d.ts.map +1 -0
- package/dist/action-ledger-drift-remediation.js +85 -0
- package/dist/action-ledger-drift.d.ts +29 -0
- package/dist/action-ledger-drift.d.ts.map +1 -0
- package/dist/action-ledger-drift.js +217 -0
- package/dist/availability-ref.d.ts +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/routes-groups.d.ts +13 -13
- package/dist/routes-public.d.ts +9 -9
- package/dist/routes-shared.d.ts +13 -4
- package/dist/routes-shared.d.ts.map +1 -1
- package/dist/routes.d.ts +347 -663
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +1191 -49
- package/dist/schema-core.d.ts +3 -3
- package/dist/schema-items.d.ts +3 -3
- package/dist/service-public.d.ts +24 -24
- package/dist/service.d.ts +53 -45
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +93 -7
- package/dist/validation-public.d.ts +12 -12
- package/dist/validation-shared.d.ts +4 -4
- package/dist/validation.d.ts +20 -20
- package/package.json +22 -6
package/dist/routes.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { ACTION_LEDGER_APPROVAL_ID_HEADER, ActionApprovalDecisionConflictError, ActionLedgerIdempotencyConflictError, actionLedgerService, appendActionLedgerMutation, appendActionLedgerSensitiveRead, buildActionApprovalCommandFingerprint, buildActionLedgerApprovedExecutionFields, decideActionLedgerApproval, evaluateActionLedgerApprovalRequirement, evaluateActionLedgerCapabilityAccess, ledgerSensitiveRead, mapActionLedgerRequestContext, requestActionLedgerApproval, } from "@voyantjs/action-ledger";
|
|
1
2
|
import { ForbiddenApiError, handleApiError, idempotencyKey, normalizeValidationError, parseJsonBody, parseQuery, requireUserId, UnauthorizedApiError, } from "@voyantjs/hono";
|
|
2
3
|
import { Hono } from "hono";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { BOOKING_PII_READ_CAPABILITY, BOOKING_STATUS_CAPABILITIES, } from "./action-ledger-capabilities.js";
|
|
3
6
|
import { createBookingPiiService } from "./pii.js";
|
|
4
7
|
import { redactBookingContact, redactTravelerIdentity, shouldRevealBookingPii, } from "./pii-redaction.js";
|
|
5
8
|
import { BOOKING_ROUTE_RUNTIME_CONTAINER_KEY, buildBookingRouteRuntime, } from "./route-runtime.js";
|
|
@@ -17,6 +20,489 @@ function hasPiiScope(scopes, action) {
|
|
|
17
20
|
scopes.includes("bookings-pii:*") ||
|
|
18
21
|
scopes.includes(`bookings-pii:${action}`));
|
|
19
22
|
}
|
|
23
|
+
const BOOKING_PII_READ_ACTION_NAME = "booking.pii.read";
|
|
24
|
+
const BOOKING_PII_READ_ACTION_VERSION = "v1";
|
|
25
|
+
const BOOKING_PII_DECISION_POLICY = "bookings-pii-scope-or-staff-v1";
|
|
26
|
+
const BOOKING_PII_AUTHORIZATION_SOURCE = "bookings.pii.route";
|
|
27
|
+
const BOOKING_STATUS_APPROVAL_POLICY = "bookings-status-approval-v1";
|
|
28
|
+
const BOOKING_TRAVELER_LEDGER_ACTION_VERSION = "v1";
|
|
29
|
+
const BOOKING_ITEM_LEDGER_ACTION_VERSION = "v1";
|
|
30
|
+
const BOOKING_NOTE_LEDGER_ACTION_VERSION = "v1";
|
|
31
|
+
const TRAVELER_IDENTITY_DISCLOSED_FIELDS = [
|
|
32
|
+
"firstName",
|
|
33
|
+
"lastName",
|
|
34
|
+
"email",
|
|
35
|
+
"phone",
|
|
36
|
+
"specialRequests",
|
|
37
|
+
"notes",
|
|
38
|
+
];
|
|
39
|
+
const TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS = [
|
|
40
|
+
"nationality",
|
|
41
|
+
"passportNumber",
|
|
42
|
+
"passportExpiry",
|
|
43
|
+
"passportIssuingCountry",
|
|
44
|
+
"passportIssuingAuthority",
|
|
45
|
+
"passportPersonDocumentId",
|
|
46
|
+
"dateOfBirth",
|
|
47
|
+
"dietaryRequirements",
|
|
48
|
+
"accessibilityNeeds",
|
|
49
|
+
"isLeadTraveler",
|
|
50
|
+
"sharingGroupId",
|
|
51
|
+
"roomTypeId",
|
|
52
|
+
"bedPreference",
|
|
53
|
+
"allocations",
|
|
54
|
+
];
|
|
55
|
+
const TRAVELER_MUTATION_FIELDS = [
|
|
56
|
+
"personId",
|
|
57
|
+
"participantType",
|
|
58
|
+
"travelerCategory",
|
|
59
|
+
"firstName",
|
|
60
|
+
"lastName",
|
|
61
|
+
"email",
|
|
62
|
+
"phone",
|
|
63
|
+
"preferredLanguage",
|
|
64
|
+
"specialRequests",
|
|
65
|
+
"isPrimary",
|
|
66
|
+
"notes",
|
|
67
|
+
];
|
|
68
|
+
const BOOKING_ITEM_MUTATION_FIELDS = [
|
|
69
|
+
"title",
|
|
70
|
+
"description",
|
|
71
|
+
"itemType",
|
|
72
|
+
"status",
|
|
73
|
+
"serviceDate",
|
|
74
|
+
"startsAt",
|
|
75
|
+
"endsAt",
|
|
76
|
+
"quantity",
|
|
77
|
+
"sellCurrency",
|
|
78
|
+
"unitSellAmountCents",
|
|
79
|
+
"totalSellAmountCents",
|
|
80
|
+
"costCurrency",
|
|
81
|
+
"unitCostAmountCents",
|
|
82
|
+
"totalCostAmountCents",
|
|
83
|
+
"notes",
|
|
84
|
+
"productId",
|
|
85
|
+
"optionId",
|
|
86
|
+
"optionUnitId",
|
|
87
|
+
"pricingCategoryId",
|
|
88
|
+
"sourceSnapshotId",
|
|
89
|
+
"sourceOfferId",
|
|
90
|
+
"metadata",
|
|
91
|
+
];
|
|
92
|
+
const bookingActionLedgerQuerySchema = z
|
|
93
|
+
.object({
|
|
94
|
+
cursorOccurredAt: z.string().datetime().optional(),
|
|
95
|
+
cursorId: z.string().trim().min(1).optional(),
|
|
96
|
+
limit: z.coerce.number().int().min(1).max(199).optional(),
|
|
97
|
+
})
|
|
98
|
+
.superRefine((value, ctx) => {
|
|
99
|
+
if (Boolean(value.cursorOccurredAt) === Boolean(value.cursorId))
|
|
100
|
+
return;
|
|
101
|
+
ctx.addIssue({
|
|
102
|
+
code: z.ZodIssueCode.custom,
|
|
103
|
+
path: value.cursorOccurredAt ? ["cursorId"] : ["cursorOccurredAt"],
|
|
104
|
+
message: "cursorOccurredAt and cursorId must be provided together",
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
.transform(({ cursorOccurredAt, cursorId, ...query }) => ({
|
|
108
|
+
...query,
|
|
109
|
+
cursor: cursorOccurredAt && cursorId
|
|
110
|
+
? {
|
|
111
|
+
occurredAt: cursorOccurredAt,
|
|
112
|
+
id: cursorId,
|
|
113
|
+
}
|
|
114
|
+
: undefined,
|
|
115
|
+
}));
|
|
116
|
+
const decideBookingActionApprovalBodySchema = z.object({
|
|
117
|
+
status: z.enum(["approved", "denied"]),
|
|
118
|
+
});
|
|
119
|
+
function getActionLedgerRequestContext(c) {
|
|
120
|
+
return {
|
|
121
|
+
userId: c.get("userId") ?? null,
|
|
122
|
+
agentId: c.get("agentId") ?? null,
|
|
123
|
+
workflowPrincipalId: c.get("workflowPrincipalId") ?? null,
|
|
124
|
+
principalSubtype: c.get("principalSubtype") ?? null,
|
|
125
|
+
sessionId: c.get("sessionId") ?? null,
|
|
126
|
+
apiTokenId: c.get("apiTokenId") ?? c.get("apiKeyId") ?? null,
|
|
127
|
+
callerType: c.get("callerType") ?? null,
|
|
128
|
+
actor: c.get("actor") ?? null,
|
|
129
|
+
isInternalRequest: c.get("isInternalRequest") ?? false,
|
|
130
|
+
organizationId: c.get("organizationId") ?? null,
|
|
131
|
+
workflowRunId: c.get("workflowRunId") ?? null,
|
|
132
|
+
workflowStepId: c.get("workflowStepId") ?? null,
|
|
133
|
+
correlationId: c.req.header("x-correlation-id") ?? c.req.header("x-request-id") ?? null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function changedBookingMutationFields(input, before, after) {
|
|
137
|
+
const fields = Object.keys(input).filter((field) => !ignoredBookingMutationFields.has(field));
|
|
138
|
+
if (!before || !after)
|
|
139
|
+
return fields.sort();
|
|
140
|
+
return fields.filter((field) => !bookingValuesEqual(before[field], after[field])).sort();
|
|
141
|
+
}
|
|
142
|
+
function changedBookingTravelerFields(input, before, after) {
|
|
143
|
+
const travelerFields = new Set(TRAVELER_MUTATION_FIELDS);
|
|
144
|
+
return changedBookingMutationFields(input, before, after).filter((field) => travelerFields.has(field));
|
|
145
|
+
}
|
|
146
|
+
function changedBookingTravelDetailFields(input) {
|
|
147
|
+
const travelDetailFields = new Set(TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS);
|
|
148
|
+
return Object.keys(input)
|
|
149
|
+
.filter((field) => travelDetailFields.has(field))
|
|
150
|
+
.sort();
|
|
151
|
+
}
|
|
152
|
+
function changedBookingItemFields(input, before, after) {
|
|
153
|
+
const itemFields = new Set(BOOKING_ITEM_MUTATION_FIELDS);
|
|
154
|
+
return changedBookingMutationFields(input, before, after).filter((field) => itemFields.has(field));
|
|
155
|
+
}
|
|
156
|
+
function bookingMutationSummary(action, fields, subject) {
|
|
157
|
+
if (action === "delete")
|
|
158
|
+
return `Deleted ${subject}`;
|
|
159
|
+
if (fields.length === 0)
|
|
160
|
+
return action === "create" ? `Created ${subject}` : `Updated ${subject}`;
|
|
161
|
+
const verb = action === "create" ? "Created" : "Updated";
|
|
162
|
+
return `${verb} ${subject} fields: ${fields.join(", ")}`;
|
|
163
|
+
}
|
|
164
|
+
async function appendBookingMutationLedgerEntry(c, input) {
|
|
165
|
+
return appendBookingMutationLedgerEntryToDb(c.get("db"), getActionLedgerRequestContext(c), input);
|
|
166
|
+
}
|
|
167
|
+
async function appendBookingMutationLedgerEntryToDb(db, context, input) {
|
|
168
|
+
return appendActionLedgerMutation(db, {
|
|
169
|
+
context,
|
|
170
|
+
actionName: input.actionName,
|
|
171
|
+
actionVersion: input.actionVersion,
|
|
172
|
+
actionKind: input.action,
|
|
173
|
+
evaluatedRisk: input.evaluatedRisk ?? "medium",
|
|
174
|
+
targetType: input.targetType,
|
|
175
|
+
targetId: input.targetId,
|
|
176
|
+
routeOrToolName: input.routeOrToolName,
|
|
177
|
+
authorizationSource: input.authorizationSource ?? "bookings.route",
|
|
178
|
+
mutationDetail: {
|
|
179
|
+
summary: input.summary ?? bookingMutationSummary(input.action, input.changedFields, input.subject),
|
|
180
|
+
reversalKind: "none",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async function appendBookingTravelerMutationLedgerEntryToDb(db, context, input) {
|
|
185
|
+
return appendBookingMutationLedgerEntryToDb(db, context, {
|
|
186
|
+
...input,
|
|
187
|
+
actionName: input.actionName,
|
|
188
|
+
actionVersion: BOOKING_TRAVELER_LEDGER_ACTION_VERSION,
|
|
189
|
+
targetType: "booking_traveler",
|
|
190
|
+
targetId: input.travelerId,
|
|
191
|
+
authorizationSource: BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
192
|
+
evaluatedRisk: input.evaluatedRisk ?? "high",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function bookingValuesEqual(left, right) {
|
|
196
|
+
if (left instanceof Date || right instanceof Date) {
|
|
197
|
+
const leftTime = left instanceof Date ? left.getTime() : new Date(String(left)).getTime();
|
|
198
|
+
const rightTime = right instanceof Date ? right.getTime() : new Date(String(right)).getTime();
|
|
199
|
+
return leftTime === rightTime;
|
|
200
|
+
}
|
|
201
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
202
|
+
}
|
|
203
|
+
const ignoredBookingMutationFields = new Set(["updatedAt", "createdAt"]);
|
|
204
|
+
async function authorizeBookingStatusMutation(c, input) {
|
|
205
|
+
const capability = BOOKING_STATUS_CAPABILITIES[input.key];
|
|
206
|
+
const access = evaluateActionLedgerCapabilityAccess({
|
|
207
|
+
definition: capability,
|
|
208
|
+
actor: c.get("actor"),
|
|
209
|
+
callerType: c.get("callerType"),
|
|
210
|
+
scopes: c.get("scopes"),
|
|
211
|
+
isInternalRequest: c.get("isInternalRequest"),
|
|
212
|
+
});
|
|
213
|
+
if (access.allowed) {
|
|
214
|
+
const approvalRequirement = evaluateActionLedgerApprovalRequirement({
|
|
215
|
+
access,
|
|
216
|
+
conditionalApprovalRequired: requiresBookingStatusApproval(c, input.key),
|
|
217
|
+
reasonCode: bookingStatusApprovalReason(c, input.key),
|
|
218
|
+
});
|
|
219
|
+
if (approvalRequirement.required) {
|
|
220
|
+
const approvedAction = await resolveApprovedBookingStatusAction(c, input, access, approvalRequirement);
|
|
221
|
+
if (approvedAction) {
|
|
222
|
+
if (!approvedAction.allowed)
|
|
223
|
+
return approvedAction;
|
|
224
|
+
return { allowed: true, access, approvedAction: approvedAction.action };
|
|
225
|
+
}
|
|
226
|
+
const idempotencyKey = c.req.header("idempotency-key") ?? null;
|
|
227
|
+
if (!idempotencyKey) {
|
|
228
|
+
return {
|
|
229
|
+
allowed: false,
|
|
230
|
+
response: c.json({
|
|
231
|
+
error: "Approval-required booking status actions require an Idempotency-Key",
|
|
232
|
+
}, 400),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const idempotencyScope = `${input.routeOrToolName}:${input.bookingId}`;
|
|
236
|
+
const idempotencyFingerprint = await buildBookingStatusApprovalFingerprint(input, await loadBookingStatusApprovalTargetState(c, input.bookingId), access, approvalRequirement);
|
|
237
|
+
const requestInput = {
|
|
238
|
+
context: getActionLedgerRequestContext(c),
|
|
239
|
+
actionName: input.actionName,
|
|
240
|
+
actionVersion: capability.version,
|
|
241
|
+
actionKind: "update",
|
|
242
|
+
evaluatedRisk: approvalRequirement.evaluatedRisk,
|
|
243
|
+
targetType: "booking",
|
|
244
|
+
targetId: input.bookingId,
|
|
245
|
+
routeOrToolName: input.routeOrToolName,
|
|
246
|
+
capabilityId: access.capabilityId,
|
|
247
|
+
capabilityVersion: access.capabilityVersion,
|
|
248
|
+
authorizationSource: access.authorizationSource,
|
|
249
|
+
idempotencyScope,
|
|
250
|
+
idempotencyKey,
|
|
251
|
+
idempotencyFingerprint,
|
|
252
|
+
mutationDetail: {
|
|
253
|
+
summary: `Booking status ${capability.action} awaiting approval: ${approvalRequirement.reasonCode}`,
|
|
254
|
+
reversalKind: "none",
|
|
255
|
+
},
|
|
256
|
+
approval: {
|
|
257
|
+
policyName: BOOKING_STATUS_APPROVAL_POLICY,
|
|
258
|
+
policyVersion: capability.version,
|
|
259
|
+
riskSnapshot: approvalRequirement.evaluatedRisk,
|
|
260
|
+
reasonCode: approvalRequirement.reasonCode,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
let result;
|
|
264
|
+
try {
|
|
265
|
+
result = await requestActionLedgerApproval(c.get("db"), requestInput);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (error instanceof ActionLedgerIdempotencyConflictError) {
|
|
269
|
+
return {
|
|
270
|
+
allowed: false,
|
|
271
|
+
response: c.json({
|
|
272
|
+
error: error.message,
|
|
273
|
+
existingActionId: error.existingActionId,
|
|
274
|
+
}, 409),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
allowed: false,
|
|
281
|
+
response: c.json({
|
|
282
|
+
data: {
|
|
283
|
+
approvalRequired: true,
|
|
284
|
+
requestedAction: {
|
|
285
|
+
id: result.requestedAction.id,
|
|
286
|
+
status: result.requestedAction.status,
|
|
287
|
+
actionName: result.requestedAction.actionName,
|
|
288
|
+
targetType: result.requestedAction.targetType,
|
|
289
|
+
targetId: result.requestedAction.targetId,
|
|
290
|
+
},
|
|
291
|
+
approval: {
|
|
292
|
+
id: result.approval.id,
|
|
293
|
+
status: result.approval.status,
|
|
294
|
+
requestedActionId: result.approval.requestedActionId,
|
|
295
|
+
requestedByPrincipalId: result.approval.requestedByPrincipalId,
|
|
296
|
+
assignedToPrincipalId: result.approval.assignedToPrincipalId,
|
|
297
|
+
policyName: result.approval.policyName,
|
|
298
|
+
policyVersion: result.approval.policyVersion,
|
|
299
|
+
riskSnapshot: result.approval.riskSnapshot,
|
|
300
|
+
reasonCode: result.approval.reasonCode,
|
|
301
|
+
expiresAt: result.approval.expiresAt,
|
|
302
|
+
createdAt: result.approval.createdAt,
|
|
303
|
+
},
|
|
304
|
+
replayed: result.replayed,
|
|
305
|
+
},
|
|
306
|
+
}, 202),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return { allowed: true, access };
|
|
310
|
+
}
|
|
311
|
+
await appendActionLedgerMutation(c.get("db"), {
|
|
312
|
+
context: getActionLedgerRequestContext(c),
|
|
313
|
+
actionName: input.actionName,
|
|
314
|
+
actionVersion: capability.version,
|
|
315
|
+
actionKind: "update",
|
|
316
|
+
status: "denied",
|
|
317
|
+
evaluatedRisk: access.evaluatedRisk,
|
|
318
|
+
targetType: "booking",
|
|
319
|
+
targetId: input.bookingId,
|
|
320
|
+
routeOrToolName: input.routeOrToolName,
|
|
321
|
+
capabilityId: access.capabilityId,
|
|
322
|
+
capabilityVersion: access.capabilityVersion,
|
|
323
|
+
authorizationSource: access.authorizationSource,
|
|
324
|
+
mutationDetail: {
|
|
325
|
+
summary: `Booking status ${capability.action} denied: ${access.reason}`,
|
|
326
|
+
reversalKind: "none",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
allowed: false,
|
|
331
|
+
response: handleApiError(new ForbiddenApiError(), c),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
async function resolveApprovedBookingStatusAction(c, input, access, approvalRequirement) {
|
|
335
|
+
const approvalId = c.req.header(ACTION_LEDGER_APPROVAL_ID_HEADER);
|
|
336
|
+
if (!approvalId)
|
|
337
|
+
return null;
|
|
338
|
+
const executionFingerprint = await buildBookingStatusApprovalFingerprint(input, await loadBookingStatusApprovalTargetState(c, input.bookingId), access, approvalRequirement);
|
|
339
|
+
const actorFields = mapActionLedgerRequestContext(getActionLedgerRequestContext(c));
|
|
340
|
+
const validation = await actionLedgerService.validateApprovedAction(c.get("db"), {
|
|
341
|
+
approvalId,
|
|
342
|
+
actionName: input.actionName,
|
|
343
|
+
actionVersion: BOOKING_STATUS_CAPABILITIES[input.key].version,
|
|
344
|
+
requestedActionKind: "update",
|
|
345
|
+
requestedActionStatus: "awaiting_approval",
|
|
346
|
+
targetType: "booking",
|
|
347
|
+
targetId: input.bookingId,
|
|
348
|
+
routeOrToolName: input.routeOrToolName,
|
|
349
|
+
principalType: actorFields.principalType,
|
|
350
|
+
principalId: actorFields.principalId,
|
|
351
|
+
idempotencyFingerprint: executionFingerprint,
|
|
352
|
+
executionActionKind: "update",
|
|
353
|
+
executionStatus: "succeeded",
|
|
354
|
+
});
|
|
355
|
+
if (!validation.ok) {
|
|
356
|
+
return {
|
|
357
|
+
allowed: false,
|
|
358
|
+
response: actionApprovalValidationResponse(c, validation),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
allowed: true,
|
|
363
|
+
action: {
|
|
364
|
+
requestedActionId: validation.requestedAction.id,
|
|
365
|
+
approvalId: validation.approval.id,
|
|
366
|
+
idempotencyFingerprint: validation.idempotencyFingerprint,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function buildBookingStatusApprovalFingerprint(input, targetState, access, approvalRequirement) {
|
|
371
|
+
return buildActionApprovalCommandFingerprint({
|
|
372
|
+
actionName: input.actionName,
|
|
373
|
+
actionVersion: BOOKING_STATUS_CAPABILITIES[input.key].version,
|
|
374
|
+
targetType: "booking",
|
|
375
|
+
targetId: input.bookingId,
|
|
376
|
+
commandInput: {
|
|
377
|
+
command: input.commandInput ?? null,
|
|
378
|
+
targetState,
|
|
379
|
+
},
|
|
380
|
+
approvalPolicy: approvalRequirement.approvalPolicy,
|
|
381
|
+
capabilityId: access.capabilityId,
|
|
382
|
+
capabilityVersion: access.capabilityVersion,
|
|
383
|
+
evaluatedRisk: approvalRequirement.evaluatedRisk,
|
|
384
|
+
reasonCode: approvalRequirement.reasonCode,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async function loadBookingStatusApprovalTargetState(c, bookingId) {
|
|
388
|
+
const booking = await bookingsService.getBookingById(c.get("db"), bookingId);
|
|
389
|
+
if (!booking)
|
|
390
|
+
return { exists: false };
|
|
391
|
+
return {
|
|
392
|
+
exists: true,
|
|
393
|
+
status: booking.status,
|
|
394
|
+
sellCurrency: booking.sellCurrency,
|
|
395
|
+
sellAmountCents: booking.sellAmountCents,
|
|
396
|
+
costAmountCents: booking.costAmountCents,
|
|
397
|
+
customerPaymentPolicy: booking.customerPaymentPolicy,
|
|
398
|
+
holdExpiresAt: serializeBookingApprovalDate(booking.holdExpiresAt),
|
|
399
|
+
confirmedAt: serializeBookingApprovalDate(booking.confirmedAt),
|
|
400
|
+
awaitingPaymentAt: serializeBookingApprovalDate(booking.awaitingPaymentAt),
|
|
401
|
+
paidAt: serializeBookingApprovalDate(booking.paidAt),
|
|
402
|
+
cancelledAt: serializeBookingApprovalDate(booking.cancelledAt),
|
|
403
|
+
completedAt: serializeBookingApprovalDate(booking.completedAt),
|
|
404
|
+
expiredAt: serializeBookingApprovalDate(booking.expiredAt),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function serializeBookingApprovalDate(value) {
|
|
408
|
+
if (!value)
|
|
409
|
+
return null;
|
|
410
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
411
|
+
}
|
|
412
|
+
function actionApprovalValidationResponse(c, validation) {
|
|
413
|
+
switch (validation.reason) {
|
|
414
|
+
case "not_found":
|
|
415
|
+
return c.json({ error: "Action approval not found" }, 404);
|
|
416
|
+
case "not_approved":
|
|
417
|
+
return c.json({
|
|
418
|
+
error: "Action approval is not approved",
|
|
419
|
+
approvalId: validation.approval?.id,
|
|
420
|
+
status: validation.status,
|
|
421
|
+
}, 409);
|
|
422
|
+
case "expired":
|
|
423
|
+
return c.json({
|
|
424
|
+
error: "Action approval has expired",
|
|
425
|
+
approvalId: validation.approval?.id,
|
|
426
|
+
}, 409);
|
|
427
|
+
case "mismatched_action":
|
|
428
|
+
return c.json({
|
|
429
|
+
error: "Action approval does not match this booking status action",
|
|
430
|
+
approvalId: validation.approval?.id,
|
|
431
|
+
}, 409);
|
|
432
|
+
case "already_executed":
|
|
433
|
+
return c.json({
|
|
434
|
+
error: "Action approval has already been executed",
|
|
435
|
+
approvalId: validation.approval?.id,
|
|
436
|
+
existingActionId: validation.existingActionId,
|
|
437
|
+
}, 409);
|
|
438
|
+
case "missing_fingerprint":
|
|
439
|
+
return c.json({
|
|
440
|
+
error: "Action approval is missing an approved command fingerprint",
|
|
441
|
+
approvalId: validation.approval?.id,
|
|
442
|
+
}, 409);
|
|
443
|
+
case "fingerprint_mismatch":
|
|
444
|
+
return c.json({
|
|
445
|
+
error: "Action approval command input does not match the approved request",
|
|
446
|
+
approvalId: validation.approval?.id,
|
|
447
|
+
}, 409);
|
|
448
|
+
case "principal_mismatch":
|
|
449
|
+
return c.json({ error: "Action approval belongs to a different principal" }, 403);
|
|
450
|
+
}
|
|
451
|
+
const exhaustiveReason = validation.reason;
|
|
452
|
+
return c.json({ error: `Unhandled action approval validation failure: ${exhaustiveReason}` }, 500);
|
|
453
|
+
}
|
|
454
|
+
function bookingStatusMutationRuntime(c, auth) {
|
|
455
|
+
const approvedExecution = auth.approvedAction
|
|
456
|
+
? buildActionLedgerApprovedExecutionFields(auth.approvedAction)
|
|
457
|
+
: null;
|
|
458
|
+
return {
|
|
459
|
+
eventBus: c.get("eventBus"),
|
|
460
|
+
actionLedgerContext: getActionLedgerRequestContext(c),
|
|
461
|
+
actionLedgerAuthorizationSource: auth.access.authorizationSource,
|
|
462
|
+
actionLedgerCausationActionId: approvedExecution?.causationActionId ?? null,
|
|
463
|
+
actionLedgerApprovalId: approvedExecution?.approvalId ?? null,
|
|
464
|
+
actionLedgerIdempotencyScope: approvedExecution?.idempotencyScope ?? null,
|
|
465
|
+
actionLedgerIdempotencyKey: approvedExecution?.idempotencyKey ?? null,
|
|
466
|
+
actionLedgerIdempotencyFingerprint: approvedExecution?.idempotencyFingerprint ?? null,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function requiresBookingStatusApproval(c, key) {
|
|
470
|
+
const capability = BOOKING_STATUS_CAPABILITIES[key];
|
|
471
|
+
if (capability.approvalPolicy !== "conditional")
|
|
472
|
+
return false;
|
|
473
|
+
return (c.get("callerType") === "agent" ||
|
|
474
|
+
c.get("callerType") === "workflow" ||
|
|
475
|
+
Boolean(c.get("agentId")) ||
|
|
476
|
+
Boolean(c.get("workflowPrincipalId")));
|
|
477
|
+
}
|
|
478
|
+
function bookingStatusApprovalReason(c, key) {
|
|
479
|
+
if (c.get("callerType") === "agent" || c.get("agentId")) {
|
|
480
|
+
return `${key}_requested_by_agent`;
|
|
481
|
+
}
|
|
482
|
+
if (c.get("callerType") === "workflow" || c.get("workflowPrincipalId")) {
|
|
483
|
+
return `${key}_requested_by_workflow`;
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
async function logBookingPiiReadActionLedger(c, input) {
|
|
488
|
+
await appendActionLedgerSensitiveRead(c.get("db"), {
|
|
489
|
+
context: getActionLedgerRequestContext(c),
|
|
490
|
+
actionName: BOOKING_PII_READ_ACTION_NAME,
|
|
491
|
+
actionVersion: BOOKING_PII_READ_ACTION_VERSION,
|
|
492
|
+
status: input.status,
|
|
493
|
+
evaluatedRisk: input.evaluatedRisk ?? "high",
|
|
494
|
+
targetType: "booking_traveler",
|
|
495
|
+
targetId: input.travelerId,
|
|
496
|
+
routeOrToolName: input.routeOrToolName,
|
|
497
|
+
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
498
|
+
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
499
|
+
authorizationSource: input.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
500
|
+
reasonCode: input.reason,
|
|
501
|
+
disclosedFieldSet: input.status === "succeeded" ? (input.disclosedFieldSet ?? []) : [],
|
|
502
|
+
disclosureSummary: input.disclosureSummary ?? null,
|
|
503
|
+
decisionPolicy: input.decisionPolicy ?? BOOKING_PII_DECISION_POLICY,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
20
506
|
async function logBookingPiiAccess(c, input) {
|
|
21
507
|
await c
|
|
22
508
|
.get("db")
|
|
@@ -35,7 +521,7 @@ async function logBookingPiiAccess(c, input) {
|
|
|
35
521
|
}
|
|
36
522
|
async function authorizeBookingPiiAccess(c, input) {
|
|
37
523
|
if (c.get("isInternalRequest")) {
|
|
38
|
-
return { allowed: true };
|
|
524
|
+
return { allowed: true, access: undefined };
|
|
39
525
|
}
|
|
40
526
|
const userId = c.get("userId");
|
|
41
527
|
if (!userId) {
|
|
@@ -44,6 +530,15 @@ async function authorizeBookingPiiAccess(c, input) {
|
|
|
44
530
|
outcome: "denied",
|
|
45
531
|
reason: "missing_user",
|
|
46
532
|
});
|
|
533
|
+
if (input.action === "read") {
|
|
534
|
+
await logBookingPiiReadActionLedger(c, {
|
|
535
|
+
travelerId: input.travelerId,
|
|
536
|
+
status: "denied",
|
|
537
|
+
reason: "missing_user",
|
|
538
|
+
routeOrToolName: "bookings.pii.authorize",
|
|
539
|
+
disclosureSummary: "Booking PII read denied before reveal",
|
|
540
|
+
});
|
|
541
|
+
}
|
|
47
542
|
return {
|
|
48
543
|
allowed: false,
|
|
49
544
|
response: handleApiError(new UnauthorizedApiError(), c),
|
|
@@ -66,15 +561,61 @@ async function authorizeBookingPiiAccess(c, input) {
|
|
|
66
561
|
outcome: "denied",
|
|
67
562
|
reason: "custom_policy_denied",
|
|
68
563
|
});
|
|
564
|
+
if (input.action === "read") {
|
|
565
|
+
await logBookingPiiReadActionLedger(c, {
|
|
566
|
+
travelerId: input.travelerId,
|
|
567
|
+
status: "denied",
|
|
568
|
+
reason: "custom_policy_denied",
|
|
569
|
+
routeOrToolName: "bookings.pii.authorize",
|
|
570
|
+
disclosureSummary: "Booking PII read denied before reveal",
|
|
571
|
+
decisionPolicy: "custom",
|
|
572
|
+
});
|
|
573
|
+
}
|
|
69
574
|
return {
|
|
70
575
|
allowed: false,
|
|
71
576
|
response: handleApiError(new ForbiddenApiError(), c),
|
|
72
577
|
};
|
|
73
578
|
}
|
|
74
|
-
return { allowed: true };
|
|
579
|
+
return { allowed: true, access: undefined };
|
|
75
580
|
}
|
|
76
581
|
const actor = c.get("actor");
|
|
77
582
|
const scopes = c.get("scopes");
|
|
583
|
+
if (input.action === "read") {
|
|
584
|
+
const access = evaluateActionLedgerCapabilityAccess({
|
|
585
|
+
definition: BOOKING_PII_READ_CAPABILITY,
|
|
586
|
+
actor,
|
|
587
|
+
callerType: c.get("callerType"),
|
|
588
|
+
scopes,
|
|
589
|
+
isInternalRequest: c.get("isInternalRequest"),
|
|
590
|
+
});
|
|
591
|
+
if (!access.allowed) {
|
|
592
|
+
await logBookingPiiAccess(c, {
|
|
593
|
+
...input,
|
|
594
|
+
outcome: "denied",
|
|
595
|
+
reason: access.reason,
|
|
596
|
+
metadata: {
|
|
597
|
+
actor: actor ?? null,
|
|
598
|
+
authorizationSource: access.authorizationSource,
|
|
599
|
+
capabilityId: access.capabilityId,
|
|
600
|
+
capabilityVersion: access.capabilityVersion,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
await logBookingPiiReadActionLedger(c, {
|
|
604
|
+
travelerId: input.travelerId,
|
|
605
|
+
status: "denied",
|
|
606
|
+
reason: access.reason,
|
|
607
|
+
routeOrToolName: "bookings.pii.authorize",
|
|
608
|
+
disclosureSummary: "Booking PII read denied before reveal",
|
|
609
|
+
authorizationSource: access.authorizationSource,
|
|
610
|
+
evaluatedRisk: access.evaluatedRisk,
|
|
611
|
+
});
|
|
612
|
+
return {
|
|
613
|
+
allowed: false,
|
|
614
|
+
response: handleApiError(new ForbiddenApiError(), c),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
return { allowed: true, access };
|
|
618
|
+
}
|
|
78
619
|
const allowed = hasPiiScope(scopes, input.action) || actor === "staff";
|
|
79
620
|
if (!allowed) {
|
|
80
621
|
await logBookingPiiAccess(c, {
|
|
@@ -88,7 +629,7 @@ async function authorizeBookingPiiAccess(c, input) {
|
|
|
88
629
|
response: handleApiError(new ForbiddenApiError(), c),
|
|
89
630
|
};
|
|
90
631
|
}
|
|
91
|
-
return { allowed: true };
|
|
632
|
+
return { allowed: true, access: undefined };
|
|
92
633
|
}
|
|
93
634
|
function handleKmsConfigError(c, error) {
|
|
94
635
|
if (error instanceof Error) {
|
|
@@ -127,6 +668,195 @@ async function createAuditedBookingPiiService(c, bookingId) {
|
|
|
127
668
|
},
|
|
128
669
|
});
|
|
129
670
|
}
|
|
671
|
+
function serializeBookingActionLedgerDate(value) {
|
|
672
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
673
|
+
if (Number.isNaN(date.getTime())) {
|
|
674
|
+
throw new Error("Booking action ledger timestamp must be a valid date");
|
|
675
|
+
}
|
|
676
|
+
return date.toISOString();
|
|
677
|
+
}
|
|
678
|
+
function serializeBookingActionLedgerEntry(entry) {
|
|
679
|
+
return {
|
|
680
|
+
...entry,
|
|
681
|
+
occurredAt: serializeBookingActionLedgerDate(entry.occurredAt),
|
|
682
|
+
createdAt: serializeBookingActionLedgerDate(entry.createdAt),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function toBookingActionLedgerCursor(entry) {
|
|
686
|
+
return {
|
|
687
|
+
occurredAt: serializeBookingActionLedgerDate(entry.occurredAt),
|
|
688
|
+
id: entry.id,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function sortBookingActionLedgerEntries(entries) {
|
|
692
|
+
return [...entries].sort((a, b) => {
|
|
693
|
+
const occurredAtDelta = new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime();
|
|
694
|
+
if (occurredAtDelta !== 0)
|
|
695
|
+
return occurredAtDelta;
|
|
696
|
+
return b.id.localeCompare(a.id);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
function buildBookingActionLedgerPage({ bookingEntries, travelerEntries, itemEntries = [], limit, }) {
|
|
700
|
+
const entriesById = new Map();
|
|
701
|
+
for (const entry of bookingEntries) {
|
|
702
|
+
entriesById.set(entry.id, entry);
|
|
703
|
+
}
|
|
704
|
+
for (const entry of travelerEntries) {
|
|
705
|
+
entriesById.set(entry.id, entry);
|
|
706
|
+
}
|
|
707
|
+
for (const entry of itemEntries) {
|
|
708
|
+
entriesById.set(entry.id, entry);
|
|
709
|
+
}
|
|
710
|
+
const sortedEntries = sortBookingActionLedgerEntries([...entriesById.values()]);
|
|
711
|
+
const entries = sortedEntries.slice(0, limit);
|
|
712
|
+
const lastEntry = entries.at(-1);
|
|
713
|
+
const nextCursor = sortedEntries.length > limit && lastEntry ? toBookingActionLedgerCursor(lastEntry) : null;
|
|
714
|
+
return { entries, nextCursor };
|
|
715
|
+
}
|
|
716
|
+
async function listBookingActionLedger(c) {
|
|
717
|
+
const bookingId = c.req.param("id");
|
|
718
|
+
if (!bookingId) {
|
|
719
|
+
return c.json({ error: "Booking not found" }, 404);
|
|
720
|
+
}
|
|
721
|
+
const query = parseQuery(c, bookingActionLedgerQuerySchema);
|
|
722
|
+
const limit = query.limit ?? 50;
|
|
723
|
+
const queryLimit = limit + 1;
|
|
724
|
+
const booking = await bookingsService.getBookingById(c.get("db"), bookingId);
|
|
725
|
+
if (!booking) {
|
|
726
|
+
return c.json({ error: "Booking not found" }, 404);
|
|
727
|
+
}
|
|
728
|
+
const travelers = await bookingsService.listTravelers(c.get("db"), bookingId);
|
|
729
|
+
const reveal = shouldRevealBookingPii({
|
|
730
|
+
actor: c.get("actor"),
|
|
731
|
+
scopes: c.get("scopes"),
|
|
732
|
+
callerType: c.get("callerType"),
|
|
733
|
+
isInternalRequest: c.get("isInternalRequest"),
|
|
734
|
+
});
|
|
735
|
+
const visibleTravelers = reveal ? travelers : travelers.map((row) => redactTravelerIdentity(row));
|
|
736
|
+
const travelerIds = travelers.map((traveler) => traveler.id);
|
|
737
|
+
const items = await bookingsService.listItems(c.get("db"), bookingId);
|
|
738
|
+
const itemIds = items.map((item) => item.id);
|
|
739
|
+
const [bookingEntriesResult, travelerEntriesResult, itemEntriesResult] = await Promise.all([
|
|
740
|
+
actionLedgerService.listEntries(c.get("db"), {
|
|
741
|
+
targetType: "booking",
|
|
742
|
+
targetId: bookingId,
|
|
743
|
+
cursor: query.cursor,
|
|
744
|
+
limit: queryLimit,
|
|
745
|
+
}),
|
|
746
|
+
travelerIds.length > 0
|
|
747
|
+
? actionLedgerService.listEntries(c.get("db"), {
|
|
748
|
+
targetType: "booking_traveler",
|
|
749
|
+
targetIds: travelerIds,
|
|
750
|
+
cursor: query.cursor,
|
|
751
|
+
limit: queryLimit,
|
|
752
|
+
})
|
|
753
|
+
: Promise.resolve({ entries: [], nextCursor: null }),
|
|
754
|
+
itemIds.length > 0
|
|
755
|
+
? actionLedgerService.listEntries(c.get("db"), {
|
|
756
|
+
targetType: "booking_item",
|
|
757
|
+
targetIds: itemIds,
|
|
758
|
+
cursor: query.cursor,
|
|
759
|
+
limit: queryLimit,
|
|
760
|
+
})
|
|
761
|
+
: Promise.resolve({ entries: [], nextCursor: null }),
|
|
762
|
+
]);
|
|
763
|
+
const page = buildBookingActionLedgerPage({
|
|
764
|
+
bookingEntries: bookingEntriesResult.entries,
|
|
765
|
+
travelerEntries: travelerEntriesResult.entries,
|
|
766
|
+
itemEntries: itemEntriesResult.entries,
|
|
767
|
+
limit,
|
|
768
|
+
});
|
|
769
|
+
return c.json({
|
|
770
|
+
data: page.entries.map(serializeBookingActionLedgerEntry),
|
|
771
|
+
travelers: visibleTravelers.map((traveler) => ({
|
|
772
|
+
id: traveler.id,
|
|
773
|
+
firstName: traveler.firstName,
|
|
774
|
+
lastName: traveler.lastName,
|
|
775
|
+
})),
|
|
776
|
+
pageInfo: {
|
|
777
|
+
nextCursor: page.nextCursor,
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
function serializeBookingActionApproval(approval) {
|
|
782
|
+
return {
|
|
783
|
+
...approval,
|
|
784
|
+
expiresAt: approval.expiresAt ? serializeBookingActionLedgerDate(approval.expiresAt) : null,
|
|
785
|
+
decidedAt: approval.decidedAt ? serializeBookingActionLedgerDate(approval.decidedAt) : null,
|
|
786
|
+
createdAt: serializeBookingActionLedgerDate(approval.createdAt),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
function findBookingStatusCapability(capabilityId) {
|
|
790
|
+
return Object.values(BOOKING_STATUS_CAPABILITIES).find((capability) => capability.id === capabilityId);
|
|
791
|
+
}
|
|
792
|
+
async function decideBookingActionApproval(c) {
|
|
793
|
+
const bookingId = c.req.param("id");
|
|
794
|
+
const approvalId = c.req.param("approvalId");
|
|
795
|
+
if (!bookingId || !approvalId) {
|
|
796
|
+
return c.json({ error: "Action approval not found" }, 404);
|
|
797
|
+
}
|
|
798
|
+
if (!c.get("isInternalRequest") && !c.get("userId")) {
|
|
799
|
+
return handleApiError(new UnauthorizedApiError(), c);
|
|
800
|
+
}
|
|
801
|
+
const body = await parseJsonBody(c, decideBookingActionApprovalBodySchema);
|
|
802
|
+
const existing = await actionLedgerService.getApproval(c.get("db"), approvalId);
|
|
803
|
+
if (!existing?.requestedAction?.entry) {
|
|
804
|
+
return c.json({ error: "Action approval not found" }, 404);
|
|
805
|
+
}
|
|
806
|
+
const requestedAction = existing.requestedAction.entry;
|
|
807
|
+
if (requestedAction.targetType !== "booking" || requestedAction.targetId !== bookingId) {
|
|
808
|
+
return c.json({ error: "Action approval not found" }, 404);
|
|
809
|
+
}
|
|
810
|
+
const capability = findBookingStatusCapability(requestedAction.capabilityId);
|
|
811
|
+
if (!capability) {
|
|
812
|
+
return c.json({ error: "Action approval is not a booking status approval" }, 409);
|
|
813
|
+
}
|
|
814
|
+
const access = evaluateActionLedgerCapabilityAccess({
|
|
815
|
+
definition: capability,
|
|
816
|
+
actor: c.get("actor"),
|
|
817
|
+
callerType: c.get("callerType"),
|
|
818
|
+
scopes: c.get("scopes"),
|
|
819
|
+
isInternalRequest: c.get("isInternalRequest"),
|
|
820
|
+
});
|
|
821
|
+
if (!access.allowed) {
|
|
822
|
+
return handleApiError(new ForbiddenApiError(), c);
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const result = await decideActionLedgerApproval(c.get("db"), {
|
|
826
|
+
context: getActionLedgerRequestContext(c),
|
|
827
|
+
id: approvalId,
|
|
828
|
+
status: body.status,
|
|
829
|
+
actionName: "booking.status.approval.decide",
|
|
830
|
+
actionVersion: capability.version,
|
|
831
|
+
evaluatedRisk: requestedAction.evaluatedRisk,
|
|
832
|
+
targetType: "booking",
|
|
833
|
+
targetId: bookingId,
|
|
834
|
+
routeOrToolName: "bookings.approvals.decide",
|
|
835
|
+
capabilityId: capability.id,
|
|
836
|
+
capabilityVersion: capability.version,
|
|
837
|
+
authorizationSource: access.authorizationSource,
|
|
838
|
+
});
|
|
839
|
+
if (!result) {
|
|
840
|
+
return c.json({ error: "Action approval not found" }, 404);
|
|
841
|
+
}
|
|
842
|
+
return c.json({
|
|
843
|
+
data: {
|
|
844
|
+
approval: serializeBookingActionApproval(result.approval),
|
|
845
|
+
decisionAction: serializeBookingActionLedgerEntry(result.decisionAction),
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (error instanceof ActionApprovalDecisionConflictError) {
|
|
851
|
+
return c.json({
|
|
852
|
+
error: "Action approval has already been decided",
|
|
853
|
+
approvalId: error.approvalId,
|
|
854
|
+
status: error.currentStatus,
|
|
855
|
+
}, 409);
|
|
856
|
+
}
|
|
857
|
+
throw error;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
130
860
|
// ==========================================================================
|
|
131
861
|
// Bookings — method-chained for Hono RPC type inference
|
|
132
862
|
// ==========================================================================
|
|
@@ -226,6 +956,10 @@ export const bookingRoutes = new Hono()
|
|
|
226
956
|
});
|
|
227
957
|
return c.json({ data: reveal ? row : redactBookingContact(row) });
|
|
228
958
|
})
|
|
959
|
+
// 2b. GET /:id/action-ledger — Booking-scoped action timeline
|
|
960
|
+
.get("/:id/action-ledger", listBookingActionLedger)
|
|
961
|
+
// 2c. POST /:id/action-approvals/:approvalId/decide — Booking-scoped approval decision
|
|
962
|
+
.post("/:id/action-approvals/:approvalId/decide", decideBookingActionApproval)
|
|
229
963
|
// 3. POST /reserve — Reserve inventory and create on-hold booking
|
|
230
964
|
.post("/reserve", idempotencyKey({ scope: "POST /v1/admin/bookings/reserve" }), async (c) => {
|
|
231
965
|
const result = await bookingsService.reserveBooking(c.get("db"), await parseJsonBody(c, reserveBookingSchema), c.get("userId"));
|
|
@@ -341,7 +1075,17 @@ export const bookingRoutes = new Hono()
|
|
|
341
1075
|
// ==========================================================================
|
|
342
1076
|
// 8. POST /:id/confirm — Confirm an on-hold booking
|
|
343
1077
|
.post("/:id/confirm", async (c) => {
|
|
344
|
-
const
|
|
1078
|
+
const bookingId = c.req.param("id");
|
|
1079
|
+
const data = await parseJsonBody(c, confirmBookingSchema);
|
|
1080
|
+
const auth = await authorizeBookingStatusMutation(c, {
|
|
1081
|
+
key: "confirm",
|
|
1082
|
+
actionName: "booking.status.confirm",
|
|
1083
|
+
routeOrToolName: "bookings.confirm",
|
|
1084
|
+
bookingId,
|
|
1085
|
+
});
|
|
1086
|
+
if (!auth.allowed)
|
|
1087
|
+
return auth.response;
|
|
1088
|
+
const result = await bookingsService.confirmBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
345
1089
|
if (result.status === "not_found") {
|
|
346
1090
|
return c.json({ error: "Booking not found" }, 404);
|
|
347
1091
|
}
|
|
@@ -375,7 +1119,20 @@ export const bookingRoutes = new Hono()
|
|
|
375
1119
|
})
|
|
376
1120
|
// 10. POST /:id/expire — Expire an on-hold booking
|
|
377
1121
|
.post("/:id/expire", async (c) => {
|
|
378
|
-
const
|
|
1122
|
+
const bookingId = c.req.param("id");
|
|
1123
|
+
const data = await parseJsonBody(c, expireBookingSchema);
|
|
1124
|
+
const auth = await authorizeBookingStatusMutation(c, {
|
|
1125
|
+
key: "expire",
|
|
1126
|
+
actionName: "booking.status.expire",
|
|
1127
|
+
routeOrToolName: "bookings.expire",
|
|
1128
|
+
bookingId,
|
|
1129
|
+
});
|
|
1130
|
+
if (!auth.allowed)
|
|
1131
|
+
return auth.response;
|
|
1132
|
+
const result = await bookingsService.expireBooking(c.get("db"), bookingId, data, c.get("userId"), {
|
|
1133
|
+
...bookingStatusMutationRuntime(c, auth),
|
|
1134
|
+
cause: "route",
|
|
1135
|
+
});
|
|
379
1136
|
if (result.status === "not_found") {
|
|
380
1137
|
return c.json({ error: "Booking not found" }, 404);
|
|
381
1138
|
}
|
|
@@ -393,7 +1150,18 @@ export const bookingRoutes = new Hono()
|
|
|
393
1150
|
})
|
|
394
1151
|
// 11. POST /:id/cancel — Cancel a booking and release allocations
|
|
395
1152
|
.post("/:id/cancel", async (c) => {
|
|
396
|
-
const
|
|
1153
|
+
const bookingId = c.req.param("id");
|
|
1154
|
+
const data = await parseJsonBody(c, cancelBookingSchema);
|
|
1155
|
+
const auth = await authorizeBookingStatusMutation(c, {
|
|
1156
|
+
key: "cancel",
|
|
1157
|
+
actionName: "booking.status.cancel",
|
|
1158
|
+
routeOrToolName: "bookings.cancel",
|
|
1159
|
+
bookingId,
|
|
1160
|
+
commandInput: data,
|
|
1161
|
+
});
|
|
1162
|
+
if (!auth.allowed)
|
|
1163
|
+
return auth.response;
|
|
1164
|
+
const result = await bookingsService.cancelBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
397
1165
|
if (result.status === "not_found") {
|
|
398
1166
|
return c.json({ error: "Booking not found" }, 404);
|
|
399
1167
|
}
|
|
@@ -407,7 +1175,17 @@ export const bookingRoutes = new Hono()
|
|
|
407
1175
|
})
|
|
408
1176
|
// 11a. POST /:id/start — Mark a confirmed booking as in-progress
|
|
409
1177
|
.post("/:id/start", async (c) => {
|
|
410
|
-
const
|
|
1178
|
+
const bookingId = c.req.param("id");
|
|
1179
|
+
const data = await parseJsonBody(c, startBookingSchema);
|
|
1180
|
+
const auth = await authorizeBookingStatusMutation(c, {
|
|
1181
|
+
key: "start",
|
|
1182
|
+
actionName: "booking.status.start",
|
|
1183
|
+
routeOrToolName: "bookings.start",
|
|
1184
|
+
bookingId,
|
|
1185
|
+
});
|
|
1186
|
+
if (!auth.allowed)
|
|
1187
|
+
return auth.response;
|
|
1188
|
+
const result = await bookingsService.startBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
411
1189
|
if (result.status === "not_found") {
|
|
412
1190
|
return c.json({ error: "Booking not found" }, 404);
|
|
413
1191
|
}
|
|
@@ -421,7 +1199,17 @@ export const bookingRoutes = new Hono()
|
|
|
421
1199
|
})
|
|
422
1200
|
// 11b. POST /:id/complete — Mark an in-progress booking as completed
|
|
423
1201
|
.post("/:id/complete", async (c) => {
|
|
424
|
-
const
|
|
1202
|
+
const bookingId = c.req.param("id");
|
|
1203
|
+
const data = await parseJsonBody(c, completeBookingSchema);
|
|
1204
|
+
const auth = await authorizeBookingStatusMutation(c, {
|
|
1205
|
+
key: "complete",
|
|
1206
|
+
actionName: "booking.status.complete",
|
|
1207
|
+
routeOrToolName: "bookings.complete",
|
|
1208
|
+
bookingId,
|
|
1209
|
+
});
|
|
1210
|
+
if (!auth.allowed)
|
|
1211
|
+
return auth.response;
|
|
1212
|
+
const result = await bookingsService.completeBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
425
1213
|
if (result.status === "not_found") {
|
|
426
1214
|
return c.json({ error: "Booking not found" }, 404);
|
|
427
1215
|
}
|
|
@@ -438,7 +1226,18 @@ export const bookingRoutes = new Hono()
|
|
|
438
1226
|
// allocations, or fulfillments. Always emits booking.status_overridden for
|
|
439
1227
|
// audit. Requires a non-empty `reason`.
|
|
440
1228
|
.post("/:id/override-status", async (c) => {
|
|
441
|
-
const
|
|
1229
|
+
const bookingId = c.req.param("id");
|
|
1230
|
+
const data = await parseJsonBody(c, overrideBookingStatusSchema);
|
|
1231
|
+
const auth = await authorizeBookingStatusMutation(c, {
|
|
1232
|
+
key: "override",
|
|
1233
|
+
actionName: "booking.status.override",
|
|
1234
|
+
routeOrToolName: "bookings.override-status",
|
|
1235
|
+
bookingId,
|
|
1236
|
+
commandInput: data,
|
|
1237
|
+
});
|
|
1238
|
+
if (!auth.allowed)
|
|
1239
|
+
return auth.response;
|
|
1240
|
+
const result = await bookingsService.overrideBookingStatus(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
|
|
442
1241
|
if (result.status === "not_found") {
|
|
443
1242
|
return c.json({ error: "Booking not found" }, 404);
|
|
444
1243
|
}
|
|
@@ -505,23 +1304,52 @@ export const bookingRoutes = new Hono()
|
|
|
505
1304
|
outcome: "denied",
|
|
506
1305
|
reason: "traveler_not_found",
|
|
507
1306
|
});
|
|
1307
|
+
await logBookingPiiReadActionLedger(c, {
|
|
1308
|
+
travelerId,
|
|
1309
|
+
status: "denied",
|
|
1310
|
+
reason: "traveler_not_found",
|
|
1311
|
+
routeOrToolName: "bookings.travelers.reveal",
|
|
1312
|
+
disclosureSummary: "Booking PII read denied before reveal",
|
|
1313
|
+
authorizationSource: auth.access?.authorizationSource,
|
|
1314
|
+
evaluatedRisk: auth.access?.evaluatedRisk,
|
|
1315
|
+
});
|
|
508
1316
|
return c.json({ error: "Traveler not found" }, 404);
|
|
509
1317
|
}
|
|
1318
|
+
let travelDetails;
|
|
510
1319
|
try {
|
|
511
1320
|
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
1321
|
+
travelDetails = await ledgerSensitiveRead(c.get("db"), {
|
|
1322
|
+
context: getActionLedgerRequestContext(c),
|
|
1323
|
+
actionName: BOOKING_PII_READ_ACTION_NAME,
|
|
1324
|
+
actionVersion: BOOKING_PII_READ_ACTION_VERSION,
|
|
1325
|
+
status: "succeeded",
|
|
1326
|
+
evaluatedRisk: auth.access?.evaluatedRisk ?? "high",
|
|
1327
|
+
targetType: "booking_traveler",
|
|
1328
|
+
targetId: traveler.id,
|
|
1329
|
+
routeOrToolName: "bookings.travelers.reveal",
|
|
1330
|
+
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
1331
|
+
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
1332
|
+
authorizationSource: auth.access?.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
1333
|
+
reasonCode: "traveler_reveal",
|
|
1334
|
+
disclosedFieldSet: [
|
|
1335
|
+
...TRAVELER_IDENTITY_DISCLOSED_FIELDS,
|
|
1336
|
+
...TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS,
|
|
1337
|
+
],
|
|
1338
|
+
disclosureSummary: "Traveler identity reveal",
|
|
1339
|
+
decisionPolicy: BOOKING_PII_DECISION_POLICY,
|
|
1340
|
+
}, () => pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId")));
|
|
521
1341
|
}
|
|
522
1342
|
catch (error) {
|
|
523
1343
|
return handleKmsConfigError(c, error);
|
|
524
1344
|
}
|
|
1345
|
+
await logBookingPiiAccess(c, {
|
|
1346
|
+
bookingId,
|
|
1347
|
+
travelerId,
|
|
1348
|
+
action: "read",
|
|
1349
|
+
outcome: "allowed",
|
|
1350
|
+
reason: "traveler_reveal",
|
|
1351
|
+
});
|
|
1352
|
+
return c.json({ data: { ...traveler, travelDetails } });
|
|
525
1353
|
})
|
|
526
1354
|
.get("/:id/travelers/:travelerId/travel-details", async (c) => {
|
|
527
1355
|
const auth = await authorizeBookingPiiAccess(c, {
|
|
@@ -541,29 +1369,72 @@ export const bookingRoutes = new Hono()
|
|
|
541
1369
|
outcome: "denied",
|
|
542
1370
|
reason: "participant_not_found",
|
|
543
1371
|
});
|
|
1372
|
+
await logBookingPiiReadActionLedger(c, {
|
|
1373
|
+
travelerId: c.req.param("travelerId"),
|
|
1374
|
+
status: "denied",
|
|
1375
|
+
reason: "participant_not_found",
|
|
1376
|
+
routeOrToolName: "bookings.travelers.travel-details",
|
|
1377
|
+
disclosureSummary: "Booking PII read denied before reveal",
|
|
1378
|
+
authorizationSource: auth.access?.authorizationSource,
|
|
1379
|
+
evaluatedRisk: auth.access?.evaluatedRisk,
|
|
1380
|
+
});
|
|
544
1381
|
return c.json({ error: "Traveler not found" }, 404);
|
|
545
1382
|
}
|
|
1383
|
+
let details;
|
|
546
1384
|
try {
|
|
547
1385
|
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1386
|
+
details = await ledgerSensitiveRead(c.get("db"), {
|
|
1387
|
+
context: getActionLedgerRequestContext(c),
|
|
1388
|
+
actionName: BOOKING_PII_READ_ACTION_NAME,
|
|
1389
|
+
actionVersion: BOOKING_PII_READ_ACTION_VERSION,
|
|
1390
|
+
status: "succeeded",
|
|
1391
|
+
evaluatedRisk: auth.access?.evaluatedRisk ?? "high",
|
|
1392
|
+
targetType: "booking_traveler",
|
|
1393
|
+
targetId: traveler.id,
|
|
1394
|
+
routeOrToolName: "bookings.travelers.travel-details",
|
|
1395
|
+
capabilityId: BOOKING_PII_READ_CAPABILITY.id,
|
|
1396
|
+
capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
|
|
1397
|
+
authorizationSource: auth.access?.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
|
|
1398
|
+
reasonCode: "travel_details_reveal",
|
|
1399
|
+
disclosedFieldSet: TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS,
|
|
1400
|
+
disclosureSummary: "Traveler travel details reveal",
|
|
1401
|
+
decisionPolicy: BOOKING_PII_DECISION_POLICY,
|
|
1402
|
+
}, () => pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId")));
|
|
560
1403
|
}
|
|
561
1404
|
catch (error) {
|
|
562
1405
|
return handleKmsConfigError(c, error);
|
|
563
1406
|
}
|
|
1407
|
+
if (!details) {
|
|
1408
|
+
await logBookingPiiAccess(c, {
|
|
1409
|
+
bookingId: traveler.bookingId,
|
|
1410
|
+
travelerId: traveler.id,
|
|
1411
|
+
action: "read",
|
|
1412
|
+
outcome: "denied",
|
|
1413
|
+
reason: "travel_details_not_found",
|
|
1414
|
+
});
|
|
1415
|
+
return c.json({ error: "Traveler travel details not found" }, 404);
|
|
1416
|
+
}
|
|
1417
|
+
return c.json({ data: details });
|
|
564
1418
|
})
|
|
565
1419
|
.post("/:id/travelers", async (c) => {
|
|
566
|
-
const
|
|
1420
|
+
const body = await parseJsonBody(c, insertTravelerSchema);
|
|
1421
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1422
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1423
|
+
const txDb = tx;
|
|
1424
|
+
const row = await bookingsService.createTraveler(txDb, c.req.param("id"), body, c.get("userId"));
|
|
1425
|
+
if (!row)
|
|
1426
|
+
return null;
|
|
1427
|
+
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1428
|
+
action: "create",
|
|
1429
|
+
travelerId: row.id,
|
|
1430
|
+
changedFields: changedBookingTravelerFields(body, null, row),
|
|
1431
|
+
subject: "booking traveler",
|
|
1432
|
+
actionName: "booking.traveler.create",
|
|
1433
|
+
routeOrToolName: "bookings.travelers.create",
|
|
1434
|
+
evaluatedRisk: "high",
|
|
1435
|
+
});
|
|
1436
|
+
return row;
|
|
1437
|
+
});
|
|
567
1438
|
if (!row) {
|
|
568
1439
|
return c.json({ error: "Booking not found" }, 404);
|
|
569
1440
|
}
|
|
@@ -582,13 +1453,34 @@ export const bookingRoutes = new Hono()
|
|
|
582
1453
|
const runtime = getRouteRuntime(c);
|
|
583
1454
|
const kms = await runtime.getKmsProvider();
|
|
584
1455
|
const pii = await createAuditedBookingPiiService(c, c.req.param("id"));
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
:
|
|
1456
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1457
|
+
const result = await c.get("db").transaction(async (tx) => {
|
|
1458
|
+
const txDb = tx;
|
|
1459
|
+
const result = await bookingsService.createTravelerWithTravelDetails(txDb, c.req.param("id"), data, {
|
|
1460
|
+
pii,
|
|
1461
|
+
userId: c.get("userId"),
|
|
1462
|
+
actorId: c.get("userId"),
|
|
1463
|
+
resolveTravelSnapshot: runtime.resolveTravelSnapshot
|
|
1464
|
+
? (personId) => runtime.resolveTravelSnapshot(txDb, personId, { kms })
|
|
1465
|
+
: undefined,
|
|
1466
|
+
});
|
|
1467
|
+
if (!result)
|
|
1468
|
+
return null;
|
|
1469
|
+
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1470
|
+
action: "create",
|
|
1471
|
+
travelerId: result.traveler.id,
|
|
1472
|
+
changedFields: [
|
|
1473
|
+
...new Set([
|
|
1474
|
+
...changedBookingTravelerFields(data, null, result.traveler),
|
|
1475
|
+
...changedBookingTravelDetailFields(data),
|
|
1476
|
+
]),
|
|
1477
|
+
].sort(),
|
|
1478
|
+
subject: "booking traveler with travel details",
|
|
1479
|
+
actionName: "booking.traveler_with_travel_details.create",
|
|
1480
|
+
routeOrToolName: "bookings.travelers.with-travel-details.create",
|
|
1481
|
+
evaluatedRisk: "high",
|
|
1482
|
+
});
|
|
1483
|
+
return result;
|
|
592
1484
|
});
|
|
593
1485
|
if (!result) {
|
|
594
1486
|
return c.json({ error: "Booking not found" }, 404);
|
|
@@ -619,7 +1511,27 @@ export const bookingRoutes = new Hono()
|
|
|
619
1511
|
}
|
|
620
1512
|
const data = await parseJsonBody(c, updateTravelerWithTravelDetailsSchema);
|
|
621
1513
|
const pii = await createAuditedBookingPiiService(c, bookingId);
|
|
622
|
-
const
|
|
1514
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1515
|
+
const result = await c.get("db").transaction(async (tx) => {
|
|
1516
|
+
const result = await bookingsService.updateTravelerWithTravelDetails(tx, travelerId, data, { pii, actorId: c.get("userId") });
|
|
1517
|
+
if (!result)
|
|
1518
|
+
return null;
|
|
1519
|
+
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1520
|
+
action: "update",
|
|
1521
|
+
travelerId: result.traveler.id,
|
|
1522
|
+
changedFields: [
|
|
1523
|
+
...new Set([
|
|
1524
|
+
...changedBookingTravelerFields(data, traveler, result.traveler),
|
|
1525
|
+
...changedBookingTravelDetailFields(data),
|
|
1526
|
+
]),
|
|
1527
|
+
].sort(),
|
|
1528
|
+
subject: "booking traveler with travel details",
|
|
1529
|
+
actionName: "booking.traveler_with_travel_details.update",
|
|
1530
|
+
routeOrToolName: "bookings.travelers.with-travel-details.update",
|
|
1531
|
+
evaluatedRisk: "high",
|
|
1532
|
+
});
|
|
1533
|
+
return result;
|
|
1534
|
+
});
|
|
623
1535
|
if (!result) {
|
|
624
1536
|
return c.json({ error: "Traveler not found" }, 404);
|
|
625
1537
|
}
|
|
@@ -651,7 +1563,23 @@ export const bookingRoutes = new Hono()
|
|
|
651
1563
|
}
|
|
652
1564
|
try {
|
|
653
1565
|
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
654
|
-
const
|
|
1566
|
+
const body = await parseJsonBody(c, upsertTravelerTravelDetailsSchema);
|
|
1567
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1568
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1569
|
+
const row = await pii.upsertTravelerTravelDetails(tx, traveler.id, body, c.get("userId"));
|
|
1570
|
+
if (!row)
|
|
1571
|
+
return null;
|
|
1572
|
+
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1573
|
+
action: "update",
|
|
1574
|
+
travelerId: traveler.id,
|
|
1575
|
+
changedFields: changedBookingTravelDetailFields(body),
|
|
1576
|
+
subject: "booking traveler travel details",
|
|
1577
|
+
actionName: "booking.traveler_travel_details.update",
|
|
1578
|
+
routeOrToolName: "bookings.travelers.travel-details.update",
|
|
1579
|
+
evaluatedRisk: "high",
|
|
1580
|
+
});
|
|
1581
|
+
return row;
|
|
1582
|
+
});
|
|
655
1583
|
if (!row) {
|
|
656
1584
|
return c.json({ error: "Traveler not found" }, 404);
|
|
657
1585
|
}
|
|
@@ -662,7 +1590,29 @@ export const bookingRoutes = new Hono()
|
|
|
662
1590
|
}
|
|
663
1591
|
})
|
|
664
1592
|
.patch("/:id/travelers/:travelerId", async (c) => {
|
|
665
|
-
const
|
|
1593
|
+
const bookingId = c.req.param("id");
|
|
1594
|
+
const travelerId = c.req.param("travelerId");
|
|
1595
|
+
const before = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
|
|
1596
|
+
if (!before) {
|
|
1597
|
+
return c.json({ error: "Traveler not found" }, 404);
|
|
1598
|
+
}
|
|
1599
|
+
const body = await parseJsonBody(c, updateTravelerSchema);
|
|
1600
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1601
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1602
|
+
const row = await bookingsService.updateTraveler(tx, travelerId, body);
|
|
1603
|
+
if (!row)
|
|
1604
|
+
return null;
|
|
1605
|
+
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1606
|
+
action: "update",
|
|
1607
|
+
travelerId: row.id,
|
|
1608
|
+
changedFields: changedBookingTravelerFields(body, before, row),
|
|
1609
|
+
subject: "booking traveler",
|
|
1610
|
+
actionName: "booking.traveler.update",
|
|
1611
|
+
routeOrToolName: "bookings.travelers.update",
|
|
1612
|
+
evaluatedRisk: "high",
|
|
1613
|
+
});
|
|
1614
|
+
return row;
|
|
1615
|
+
});
|
|
666
1616
|
if (!row) {
|
|
667
1617
|
return c.json({ error: "Traveler not found" }, 404);
|
|
668
1618
|
}
|
|
@@ -690,7 +1640,22 @@ export const bookingRoutes = new Hono()
|
|
|
690
1640
|
}
|
|
691
1641
|
try {
|
|
692
1642
|
const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
|
|
693
|
-
const
|
|
1643
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1644
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1645
|
+
const row = await pii.deleteTravelerTravelDetails(tx, traveler.id, c.get("userId"));
|
|
1646
|
+
if (!row)
|
|
1647
|
+
return null;
|
|
1648
|
+
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1649
|
+
action: "delete",
|
|
1650
|
+
travelerId: traveler.id,
|
|
1651
|
+
changedFields: [],
|
|
1652
|
+
subject: "booking traveler travel details",
|
|
1653
|
+
actionName: "booking.traveler_travel_details.delete",
|
|
1654
|
+
routeOrToolName: "bookings.travelers.travel-details.delete",
|
|
1655
|
+
evaluatedRisk: "high",
|
|
1656
|
+
});
|
|
1657
|
+
return row;
|
|
1658
|
+
});
|
|
694
1659
|
if (!row) {
|
|
695
1660
|
return c.json({ error: "Traveler travel details not found" }, 404);
|
|
696
1661
|
}
|
|
@@ -701,7 +1666,28 @@ export const bookingRoutes = new Hono()
|
|
|
701
1666
|
}
|
|
702
1667
|
})
|
|
703
1668
|
.delete("/:id/travelers/:travelerId", async (c) => {
|
|
704
|
-
const
|
|
1669
|
+
const bookingId = c.req.param("id");
|
|
1670
|
+
const travelerId = c.req.param("travelerId");
|
|
1671
|
+
const before = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
|
|
1672
|
+
if (!before) {
|
|
1673
|
+
return c.json({ error: "Traveler not found" }, 404);
|
|
1674
|
+
}
|
|
1675
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1676
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1677
|
+
const row = await bookingsService.deleteTraveler(tx, travelerId);
|
|
1678
|
+
if (!row)
|
|
1679
|
+
return null;
|
|
1680
|
+
await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1681
|
+
action: "delete",
|
|
1682
|
+
travelerId,
|
|
1683
|
+
changedFields: [],
|
|
1684
|
+
subject: "booking traveler",
|
|
1685
|
+
actionName: "booking.traveler.delete",
|
|
1686
|
+
routeOrToolName: "bookings.travelers.delete",
|
|
1687
|
+
evaluatedRisk: "high",
|
|
1688
|
+
});
|
|
1689
|
+
return row;
|
|
1690
|
+
});
|
|
705
1691
|
if (!row) {
|
|
706
1692
|
return c.json({ error: "Traveler not found" }, 404);
|
|
707
1693
|
}
|
|
@@ -716,7 +1702,25 @@ export const bookingRoutes = new Hono()
|
|
|
716
1702
|
})
|
|
717
1703
|
// 17. POST /:id/items — Add booking item
|
|
718
1704
|
.post("/:id/items", async (c) => {
|
|
719
|
-
const
|
|
1705
|
+
const body = await parseJsonBody(c, insertBookingItemSchema);
|
|
1706
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1707
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1708
|
+
const row = await bookingsService.createItem(tx, c.req.param("id"), body, c.get("userId"));
|
|
1709
|
+
if (!row)
|
|
1710
|
+
return null;
|
|
1711
|
+
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1712
|
+
action: "create",
|
|
1713
|
+
actionName: "booking.item.create",
|
|
1714
|
+
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1715
|
+
targetType: "booking_item",
|
|
1716
|
+
targetId: row.id,
|
|
1717
|
+
changedFields: changedBookingItemFields(body, null, row),
|
|
1718
|
+
subject: "booking item",
|
|
1719
|
+
routeOrToolName: "bookings.items.create",
|
|
1720
|
+
evaluatedRisk: "high",
|
|
1721
|
+
});
|
|
1722
|
+
return row;
|
|
1723
|
+
});
|
|
720
1724
|
if (!row) {
|
|
721
1725
|
return c.json({ error: "Booking not found" }, 404);
|
|
722
1726
|
}
|
|
@@ -724,7 +1728,31 @@ export const bookingRoutes = new Hono()
|
|
|
724
1728
|
})
|
|
725
1729
|
// 18. PATCH /:id/items/:itemId — Update booking item
|
|
726
1730
|
.patch("/:id/items/:itemId", async (c) => {
|
|
727
|
-
const
|
|
1731
|
+
const bookingId = c.req.param("id");
|
|
1732
|
+
const itemId = c.req.param("itemId");
|
|
1733
|
+
const before = (await bookingsService.listItems(c.get("db"), bookingId)).find((item) => item.id === itemId) ?? null;
|
|
1734
|
+
if (!before) {
|
|
1735
|
+
return c.json({ error: "Booking item not found" }, 404);
|
|
1736
|
+
}
|
|
1737
|
+
const body = await parseJsonBody(c, updateBookingItemSchema);
|
|
1738
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1739
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1740
|
+
const row = await bookingsService.updateItem(tx, itemId, body);
|
|
1741
|
+
if (!row)
|
|
1742
|
+
return null;
|
|
1743
|
+
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1744
|
+
action: "update",
|
|
1745
|
+
actionName: "booking.item.update",
|
|
1746
|
+
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1747
|
+
targetType: "booking_item",
|
|
1748
|
+
targetId: row.id,
|
|
1749
|
+
changedFields: changedBookingItemFields(body, before, row),
|
|
1750
|
+
subject: "booking item",
|
|
1751
|
+
routeOrToolName: "bookings.items.update",
|
|
1752
|
+
evaluatedRisk: "high",
|
|
1753
|
+
});
|
|
1754
|
+
return row;
|
|
1755
|
+
});
|
|
728
1756
|
if (!row) {
|
|
729
1757
|
return c.json({ error: "Booking item not found" }, 404);
|
|
730
1758
|
}
|
|
@@ -732,7 +1760,31 @@ export const bookingRoutes = new Hono()
|
|
|
732
1760
|
})
|
|
733
1761
|
// 19. DELETE /:id/items/:itemId — Delete booking item
|
|
734
1762
|
.delete("/:id/items/:itemId", async (c) => {
|
|
735
|
-
const
|
|
1763
|
+
const bookingId = c.req.param("id");
|
|
1764
|
+
const itemId = c.req.param("itemId");
|
|
1765
|
+
const before = (await bookingsService.listItems(c.get("db"), bookingId)).find((item) => item.id === itemId) ?? null;
|
|
1766
|
+
if (!before) {
|
|
1767
|
+
return c.json({ error: "Booking item not found" }, 404);
|
|
1768
|
+
}
|
|
1769
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1770
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1771
|
+
const row = await bookingsService.deleteItem(tx, itemId);
|
|
1772
|
+
if (!row)
|
|
1773
|
+
return null;
|
|
1774
|
+
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1775
|
+
action: "delete",
|
|
1776
|
+
actionName: "booking.item.delete",
|
|
1777
|
+
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1778
|
+
targetType: "booking_item",
|
|
1779
|
+
targetId: itemId,
|
|
1780
|
+
changedFields: [],
|
|
1781
|
+
subject: "booking item",
|
|
1782
|
+
routeOrToolName: "bookings.items.delete",
|
|
1783
|
+
evaluatedRisk: "high",
|
|
1784
|
+
summary: "Deleted booking item",
|
|
1785
|
+
});
|
|
1786
|
+
return row;
|
|
1787
|
+
});
|
|
736
1788
|
if (!row) {
|
|
737
1789
|
return c.json({ error: "Booking item not found" }, 404);
|
|
738
1790
|
}
|
|
@@ -746,7 +1798,33 @@ export const bookingRoutes = new Hono()
|
|
|
746
1798
|
})
|
|
747
1799
|
// 21. POST /:id/items/:itemId/travelers — Link traveler to item
|
|
748
1800
|
.post("/:id/items/:itemId/travelers", async (c) => {
|
|
749
|
-
const
|
|
1801
|
+
const bookingId = c.req.param("id");
|
|
1802
|
+
const itemId = c.req.param("itemId");
|
|
1803
|
+
const item = (await bookingsService.listItems(c.get("db"), bookingId)).find((row) => row.id === itemId) ??
|
|
1804
|
+
null;
|
|
1805
|
+
if (!item) {
|
|
1806
|
+
return c.json({ error: "Booking item not found" }, 404);
|
|
1807
|
+
}
|
|
1808
|
+
const body = await parseJsonBody(c, insertBookingItemTravelerSchema);
|
|
1809
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1810
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1811
|
+
const row = await bookingsService.addItemParticipant(tx, itemId, body);
|
|
1812
|
+
if (!row)
|
|
1813
|
+
return null;
|
|
1814
|
+
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1815
|
+
action: "create",
|
|
1816
|
+
actionName: "booking.item_traveler.create",
|
|
1817
|
+
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1818
|
+
targetType: "booking_item",
|
|
1819
|
+
targetId: itemId,
|
|
1820
|
+
changedFields: changedBookingItemFields(body, null, row),
|
|
1821
|
+
subject: "booking item traveler link",
|
|
1822
|
+
routeOrToolName: "bookings.items.travelers.create",
|
|
1823
|
+
evaluatedRisk: "high",
|
|
1824
|
+
summary: "Linked traveler to booking item",
|
|
1825
|
+
});
|
|
1826
|
+
return row;
|
|
1827
|
+
});
|
|
750
1828
|
if (!row) {
|
|
751
1829
|
return c.json({ error: "Booking item or traveler not found" }, 404);
|
|
752
1830
|
}
|
|
@@ -754,7 +1832,37 @@ export const bookingRoutes = new Hono()
|
|
|
754
1832
|
})
|
|
755
1833
|
// 22. DELETE /:id/items/:itemId/travelers/:linkId — Unlink traveler from item
|
|
756
1834
|
.delete("/:id/items/:itemId/travelers/:linkId", async (c) => {
|
|
757
|
-
const
|
|
1835
|
+
const bookingId = c.req.param("id");
|
|
1836
|
+
const itemId = c.req.param("itemId");
|
|
1837
|
+
const linkId = c.req.param("linkId");
|
|
1838
|
+
const item = (await bookingsService.listItems(c.get("db"), bookingId)).find((row) => row.id === itemId) ??
|
|
1839
|
+
null;
|
|
1840
|
+
if (!item) {
|
|
1841
|
+
return c.json({ error: "Booking item not found" }, 404);
|
|
1842
|
+
}
|
|
1843
|
+
const before = (await bookingsService.listItemParticipants(c.get("db"), itemId)).find((link) => link.id === linkId) ?? null;
|
|
1844
|
+
if (!before) {
|
|
1845
|
+
return c.json({ error: "Booking item traveler link not found" }, 404);
|
|
1846
|
+
}
|
|
1847
|
+
const ledgerContext = getActionLedgerRequestContext(c);
|
|
1848
|
+
const row = await c.get("db").transaction(async (tx) => {
|
|
1849
|
+
const row = await bookingsService.removeItemParticipant(tx, linkId);
|
|
1850
|
+
if (!row)
|
|
1851
|
+
return null;
|
|
1852
|
+
await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
|
|
1853
|
+
action: "delete",
|
|
1854
|
+
actionName: "booking.item_traveler.delete",
|
|
1855
|
+
actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
|
|
1856
|
+
targetType: "booking_item",
|
|
1857
|
+
targetId: itemId,
|
|
1858
|
+
changedFields: [],
|
|
1859
|
+
subject: "booking item traveler link",
|
|
1860
|
+
routeOrToolName: "bookings.items.travelers.delete",
|
|
1861
|
+
evaluatedRisk: "high",
|
|
1862
|
+
summary: "Unlinked traveler from booking item",
|
|
1863
|
+
});
|
|
1864
|
+
return row;
|
|
1865
|
+
});
|
|
758
1866
|
if (!row) {
|
|
759
1867
|
return c.json({ error: "Booking item traveler link not found" }, 404);
|
|
760
1868
|
}
|
|
@@ -839,10 +1947,23 @@ export const bookingRoutes = new Hono()
|
|
|
839
1947
|
// 28. POST /:id/notes — Add note
|
|
840
1948
|
.post("/:id/notes", async (c) => {
|
|
841
1949
|
const userId = requireUserId(c);
|
|
842
|
-
const
|
|
1950
|
+
const bookingId = c.req.param("id");
|
|
1951
|
+
const row = await bookingsService.createNote(c.get("db"), bookingId, userId, await parseJsonBody(c, insertBookingNoteSchema));
|
|
843
1952
|
if (!row) {
|
|
844
1953
|
return c.json({ error: "Booking not found" }, 404);
|
|
845
1954
|
}
|
|
1955
|
+
await appendBookingMutationLedgerEntry(c, {
|
|
1956
|
+
action: "create",
|
|
1957
|
+
actionName: "booking.note.create",
|
|
1958
|
+
actionVersion: BOOKING_NOTE_LEDGER_ACTION_VERSION,
|
|
1959
|
+
targetType: "booking",
|
|
1960
|
+
targetId: bookingId,
|
|
1961
|
+
changedFields: ["content"],
|
|
1962
|
+
subject: "booking note",
|
|
1963
|
+
routeOrToolName: "bookings.notes.create",
|
|
1964
|
+
evaluatedRisk: "medium",
|
|
1965
|
+
summary: "Created booking note",
|
|
1966
|
+
});
|
|
846
1967
|
return c.json({ data: row }, 201);
|
|
847
1968
|
})
|
|
848
1969
|
// 28b. DELETE /:id/notes/:noteId — Delete note
|
|
@@ -851,6 +1972,18 @@ export const bookingRoutes = new Hono()
|
|
|
851
1972
|
if (!row) {
|
|
852
1973
|
return c.json({ error: "Note not found" }, 404);
|
|
853
1974
|
}
|
|
1975
|
+
await appendBookingMutationLedgerEntry(c, {
|
|
1976
|
+
action: "delete",
|
|
1977
|
+
actionName: "booking.note.delete",
|
|
1978
|
+
actionVersion: BOOKING_NOTE_LEDGER_ACTION_VERSION,
|
|
1979
|
+
targetType: "booking",
|
|
1980
|
+
targetId: c.req.param("id"),
|
|
1981
|
+
changedFields: [],
|
|
1982
|
+
subject: "booking note",
|
|
1983
|
+
routeOrToolName: "bookings.notes.delete",
|
|
1984
|
+
evaluatedRisk: "medium",
|
|
1985
|
+
summary: "Deleted booking note",
|
|
1986
|
+
});
|
|
854
1987
|
return c.json({ success: true }, 200);
|
|
855
1988
|
})
|
|
856
1989
|
// ==========================================================================
|
|
@@ -880,3 +2013,12 @@ export const bookingRoutes = new Hono()
|
|
|
880
2013
|
// Booking Groups (shared-room / split-booking model)
|
|
881
2014
|
// ==========================================================================
|
|
882
2015
|
.route("/groups", bookingGroupRoutes);
|
|
2016
|
+
export const __test__ = {
|
|
2017
|
+
bookingActionLedgerQuerySchema,
|
|
2018
|
+
bookingMutationSummary,
|
|
2019
|
+
buildBookingActionLedgerPage,
|
|
2020
|
+
changedBookingItemFields,
|
|
2021
|
+
changedBookingMutationFields,
|
|
2022
|
+
changedBookingTravelDetailFields,
|
|
2023
|
+
changedBookingTravelerFields,
|
|
2024
|
+
};
|