Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit dbd5342

Browse files
authored
feat: add reschedule and booking update endpoints (#10)
* Add reschedule endpoint * Update openapi * Update docs * Add booking update endpoint * Remove unnecessary updatedAt value updates
1 parent b4dd95b commit dbd5342

14 files changed

Lines changed: 1454 additions & 13 deletions

File tree

apps/server/openapi.json

Lines changed: 482 additions & 0 deletions
Large diffs are not rendered by default.

apps/server/src/infra/event-bus.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export type InternalEventType =
99
| "booking.created"
1010
| "booking.confirmed"
1111
| "booking.canceled"
12-
| "booking.expired";
12+
| "booking.expired"
13+
| "booking.rescheduled";
1314

1415
export interface InternalEvent {
1516
id: string;

apps/server/src/operations/booking/cancel.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ export default createOperation({
4949
.set({
5050
status: "canceled",
5151
expiresAt: null,
52-
updatedAt: serverTime,
5352
})
5453
.where("id", "=", input.id)
5554
.returningAll()
@@ -58,7 +57,7 @@ export default createOperation({
5857
// 5. Deactivate allocations
5958
await trx
6059
.updateTable("allocations")
61-
.set({ active: false, expiresAt: null, updatedAt: serverTime })
60+
.set({ active: false, expiresAt: null })
6261
.where("bookingId", "=", input.id)
6362
.execute();
6463

apps/server/src/operations/booking/confirm.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ export default createOperation({
5757
.set({
5858
status: "confirmed",
5959
expiresAt: null,
60-
updatedAt: serverTime,
6160
})
6261
.where("id", "=", input.id)
6362
.returningAll()
@@ -66,7 +65,7 @@ export default createOperation({
6665
// 6. Update allocations
6766
await trx
6867
.updateTable("allocations")
69-
.set({ expiresAt: null, updatedAt: serverTime })
68+
.set({ expiresAt: null })
7069
.where("bookingId", "=", input.id)
7170
.execute();
7271

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import create from "./create";
22
import confirm from "./confirm";
33
import cancel from "./cancel";
4+
import reschedule from "./reschedule";
5+
import update from "./update";
46
import get from "./get";
57
import list from "./list";
68

79
export const booking = {
810
create,
911
confirm,
1012
cancel,
13+
reschedule,
14+
update,
1115
get,
1216
list,
1317
};
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { db, getServerTime } from "database";
2+
import { createOperation } from "lib/operation";
3+
import { bookingInput } from "@floyd-run/schema/inputs";
4+
import { ConflictError, NotFoundError } from "lib/errors";
5+
import { emitEvent } from "infra/event-bus";
6+
import { serializeBooking, serializeAllocation } from "routes/v1/serializers";
7+
import { evaluatePolicy, type PolicyConfig } from "domain/policy/evaluate";
8+
import { insertAllocation } from "../allocation/internal/insert";
9+
10+
const DEFAULT_HOLD_DURATION_MS = 15 * 60 * 1000; // 15 minutes
11+
12+
export default createOperation({
13+
input: bookingInput.reschedule,
14+
execute: async (input) => {
15+
return await db.transaction().execute(async (trx) => {
16+
// 1. Lock booking row
17+
const existing = await trx
18+
.selectFrom("bookings")
19+
.selectAll()
20+
.where("id", "=", input.id)
21+
.where("ledgerId", "=", input.ledgerId)
22+
.forUpdate()
23+
.executeTakeFirst();
24+
25+
if (!existing) {
26+
throw new NotFoundError("Booking not found");
27+
}
28+
29+
// 2. Capture server time
30+
const serverTime = await getServerTime(trx);
31+
32+
// 3. Validate state
33+
if (existing.status !== "hold" && existing.status !== "confirmed") {
34+
throw new ConflictError("booking.invalid_transition", {
35+
currentStatus: existing.status,
36+
requestedAction: "reschedule",
37+
});
38+
}
39+
40+
// 4. Check hold expiry
41+
if (existing.status === "hold" && existing.expiresAt && serverTime >= existing.expiresAt) {
42+
throw new ConflictError("booking.hold_expired", {
43+
expiresAt: existing.expiresAt,
44+
serverTime,
45+
});
46+
}
47+
48+
// 5. Snapshot current active allocations (for event payload) and derive resourceId
49+
const previousAllocations = await trx
50+
.selectFrom("allocations")
51+
.selectAll()
52+
.where("bookingId", "=", existing.id)
53+
.where("active", "=", true)
54+
.execute();
55+
56+
const resourceId = previousAllocations[0]!.resourceId;
57+
58+
// 6. Lock resource row (serializes concurrent allocation writes)
59+
const resource = await trx
60+
.selectFrom("resources")
61+
.selectAll()
62+
.where("id", "=", resourceId)
63+
.where("ledgerId", "=", input.ledgerId)
64+
.forUpdate()
65+
.executeTakeFirstOrThrow();
66+
67+
// 7. Load service + current policy version
68+
const service = await trx
69+
.selectFrom("services")
70+
.selectAll()
71+
.where("id", "=", existing.serviceId)
72+
.where("ledgerId", "=", input.ledgerId)
73+
.executeTakeFirst();
74+
75+
if (!service) {
76+
throw new NotFoundError("Service not found");
77+
}
78+
79+
if (!service.policyId) {
80+
throw new ConflictError("service.no_policy", {
81+
message: "Service must have a policy to reschedule bookings",
82+
});
83+
}
84+
85+
const policyRow = await trx
86+
.selectFrom("policies")
87+
.select("currentVersionId")
88+
.where("id", "=", service.policyId)
89+
.executeTakeFirstOrThrow();
90+
91+
const version = await trx
92+
.selectFrom("policyVersions")
93+
.selectAll()
94+
.where("id", "=", policyRow.currentVersionId)
95+
.executeTakeFirstOrThrow();
96+
97+
// 8. Evaluate policy against new times
98+
const result = evaluatePolicy(
99+
version.config as unknown as PolicyConfig,
100+
{ startTime: input.startTime, endTime: input.endTime },
101+
{ decisionTime: serverTime, timezone: resource.timezone },
102+
);
103+
104+
if (!result.allowed) {
105+
throw new ConflictError("policy.rejected", {
106+
code: result.code,
107+
message: result.message,
108+
...("details" in result ? { details: result.details } : {}),
109+
});
110+
}
111+
112+
const startTime = result.effectiveStartTime;
113+
const endTime = result.effectiveEndTime;
114+
const bufferBeforeMs = result.bufferBeforeMs;
115+
const bufferAfterMs = result.bufferAfterMs;
116+
117+
let holdDurationMs = DEFAULT_HOLD_DURATION_MS;
118+
if (result.resolvedConfig.hold?.duration_ms !== undefined) {
119+
holdDurationMs = result.resolvedConfig.hold.duration_ms;
120+
}
121+
122+
// 9. Compute new expiresAt
123+
const isHold = existing.status === "hold";
124+
const expiresAt = isHold ? new Date(serverTime.getTime() + holdDurationMs) : null;
125+
126+
// 10. Deactivate old allocations
127+
await trx
128+
.updateTable("allocations")
129+
.set({ active: false, expiresAt: null })
130+
.where("bookingId", "=", existing.id)
131+
.where("active", "=", true)
132+
.execute();
133+
134+
// 11. Insert new allocation (conflict check runs against other allocations only)
135+
await insertAllocation(trx, {
136+
ledgerId: input.ledgerId,
137+
resourceId,
138+
bookingId: existing.id,
139+
startTime,
140+
endTime,
141+
bufferBeforeMs,
142+
bufferAfterMs,
143+
expiresAt,
144+
metadata: {},
145+
serverTime,
146+
});
147+
148+
// 12. Update booking
149+
const booking = await trx
150+
.updateTable("bookings")
151+
.set({
152+
policyVersionId: version.id,
153+
expiresAt,
154+
})
155+
.where("id", "=", existing.id)
156+
.returningAll()
157+
.executeTakeFirstOrThrow();
158+
159+
// 13. Fetch all allocations for response
160+
const allocations = await trx
161+
.selectFrom("allocations")
162+
.selectAll()
163+
.where("bookingId", "=", existing.id)
164+
.execute();
165+
166+
// 14. Emit event
167+
await emitEvent(trx, "booking.rescheduled", booking.ledgerId, {
168+
booking: serializeBooking(booking, allocations),
169+
previousAllocations: previousAllocations.map((a) => serializeAllocation(a)),
170+
});
171+
172+
return { booking, allocations, serverTime };
173+
});
174+
},
175+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { db } from "database";
2+
import { createOperation } from "lib/operation";
3+
import { bookingInput } from "@floyd-run/schema/inputs";
4+
import { NotFoundError } from "lib/errors";
5+
6+
export default createOperation({
7+
input: bookingInput.update,
8+
execute: async (input) => {
9+
const booking = await db
10+
.updateTable("bookings")
11+
.set({
12+
metadata: input.metadata,
13+
})
14+
.where("id", "=", input.id)
15+
.where("ledgerId", "=", input.ledgerId)
16+
.returningAll()
17+
.executeTakeFirst();
18+
19+
if (!booking) {
20+
throw new NotFoundError("Booking not found");
21+
}
22+
23+
const allocations = await db
24+
.selectFrom("allocations")
25+
.selectAll()
26+
.where("bookingId", "=", booking.id)
27+
.execute();
28+
29+
return { booking, allocations };
30+
},
31+
});

apps/server/src/routes/v1/bookings.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,30 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>()
5656
const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } };
5757
await storeIdempotencyResponse(c, responseBody, 200);
5858
return c.json(responseBody);
59-
});
59+
})
60+
61+
.patch("/:id", async (c) => {
62+
const body = await c.req.json();
63+
const { booking, allocations } = await operations.booking.update({
64+
...(body as object),
65+
id: c.req.param("id"),
66+
ledgerId: c.req.param("ledgerId"),
67+
} as Parameters<typeof operations.booking.update>[0]);
68+
return c.json({ data: serializeBooking(booking, allocations) });
69+
})
70+
71+
.post(
72+
"/:id/reschedule",
73+
idempotent({ significantFields: ["startTime", "endTime"] }),
74+
async (c) => {
75+
const body = c.get("parsedBody") ?? (await c.req.json());
76+
const { booking, allocations, serverTime } = await operations.booking.reschedule({
77+
...(body as object),
78+
id: c.req.param("id"),
79+
ledgerId: c.req.param("ledgerId"),
80+
} as Parameters<typeof operations.booking.reschedule>[0]);
81+
const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } };
82+
await storeIdempotencyResponse(c, responseBody, 200);
83+
return c.json(responseBody);
84+
},
85+
);

apps/server/src/scripts/generate-openapi.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,46 @@ registry.registerPath({
666666
},
667667
});
668668

669+
registry.registerPath({
670+
method: "patch",
671+
path: "/v1/ledgers/{ledgerId}/bookings/{id}",
672+
tags: ["Bookings"],
673+
summary: "Update booking metadata",
674+
description: "Replaces the booking's metadata object. Works on bookings in any status.",
675+
request: {
676+
params: z.object({ ledgerId: z.string(), id: z.string() }),
677+
body: {
678+
content: {
679+
"application/json": {
680+
schema: z.object({
681+
metadata: z.record(z.string(), z.unknown()).openapi({
682+
example: {
683+
customerName: "Alice",
684+
partySize: 2,
685+
notes: "Needs wheelchair accessible room",
686+
},
687+
}),
688+
}),
689+
},
690+
},
691+
},
692+
},
693+
responses: {
694+
200: {
695+
description: "Booking updated",
696+
content: { "application/json": { schema: booking.get } },
697+
},
698+
404: {
699+
description: "Booking not found",
700+
content: { "application/json": { schema: error.schema } },
701+
},
702+
422: {
703+
description: "Invalid input",
704+
content: { "application/json": { schema: error.schema } },
705+
},
706+
},
707+
});
708+
669709
registry.registerPath({
670710
method: "post",
671711
path: "/v1/ledgers/{ledgerId}/bookings/{id}/confirm",
@@ -718,6 +758,50 @@ registry.registerPath({
718758
},
719759
});
720760

761+
registry.registerPath({
762+
method: "post",
763+
path: "/v1/ledgers/{ledgerId}/bookings/{id}/reschedule",
764+
tags: ["Bookings"],
765+
summary: "Reschedule a booking",
766+
description:
767+
"Changes the time of an existing booking while preserving its identity. " +
768+
"Re-evaluates the service's current policy version against the new time. " +
769+
"The old allocation is deactivated and a new one is created atomically. " +
770+
"Hold bookings get a fresh hold timer. Confirmed bookings stay confirmed. " +
771+
"Supports idempotency via the Idempotency-Key header.",
772+
request: {
773+
params: z.object({ ledgerId: z.string(), id: z.string() }),
774+
body: {
775+
content: {
776+
"application/json": {
777+
schema: z.object({
778+
startTime: z.iso.datetime().openapi({ example: "2026-01-15T14:00:00Z" }),
779+
endTime: z.iso.datetime().openapi({ example: "2026-01-15T15:00:00Z" }),
780+
}),
781+
},
782+
},
783+
},
784+
},
785+
responses: {
786+
200: {
787+
description: "Booking rescheduled",
788+
content: { "application/json": { schema: booking.get } },
789+
},
790+
404: {
791+
description: "Booking not found",
792+
content: { "application/json": { schema: error.schema } },
793+
},
794+
409: {
795+
description: "Conflict (overlap, policy rejected, expired hold, or invalid state)",
796+
content: { "application/json": { schema: error.schema } },
797+
},
798+
422: {
799+
description: "Invalid input",
800+
content: { "application/json": { schema: error.schema } },
801+
},
802+
},
803+
});
804+
721805
// Policy routes
722806
registry.registerPath({
723807
method: "get",

0 commit comments

Comments
 (0)