Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
482 changes: 482 additions & 0 deletions apps/server/openapi.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion apps/server/src/infra/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export type InternalEventType =
| "booking.created"
| "booking.confirmed"
| "booking.canceled"
| "booking.expired";
| "booking.expired"
| "booking.rescheduled";

export interface InternalEvent {
id: string;
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/operations/booking/cancel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export default createOperation({
.set({
status: "canceled",
expiresAt: null,
updatedAt: serverTime,
})
.where("id", "=", input.id)
.returningAll()
Expand All @@ -58,7 +57,7 @@ export default createOperation({
// 5. Deactivate allocations
await trx
.updateTable("allocations")
.set({ active: false, expiresAt: null, updatedAt: serverTime })
.set({ active: false, expiresAt: null })
.where("bookingId", "=", input.id)
.execute();

Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/operations/booking/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ export default createOperation({
.set({
status: "confirmed",
expiresAt: null,
updatedAt: serverTime,
})
.where("id", "=", input.id)
.returningAll()
Expand All @@ -66,7 +65,7 @@ export default createOperation({
// 6. Update allocations
await trx
.updateTable("allocations")
.set({ expiresAt: null, updatedAt: serverTime })
.set({ expiresAt: null })
.where("bookingId", "=", input.id)
.execute();

Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/operations/booking/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import create from "./create";
import confirm from "./confirm";
import cancel from "./cancel";
import reschedule from "./reschedule";
import update from "./update";
import get from "./get";
import list from "./list";

export const booking = {
create,
confirm,
cancel,
reschedule,
update,
get,
list,
};
175 changes: 175 additions & 0 deletions apps/server/src/operations/booking/reschedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { db, getServerTime } from "database";
import { createOperation } from "lib/operation";
import { bookingInput } from "@floyd-run/schema/inputs";
import { ConflictError, NotFoundError } from "lib/errors";
import { emitEvent } from "infra/event-bus";
import { serializeBooking, serializeAllocation } from "routes/v1/serializers";
import { evaluatePolicy, type PolicyConfig } from "domain/policy/evaluate";
import { insertAllocation } from "../allocation/internal/insert";

const DEFAULT_HOLD_DURATION_MS = 15 * 60 * 1000; // 15 minutes

export default createOperation({
input: bookingInput.reschedule,
execute: async (input) => {
return await db.transaction().execute(async (trx) => {
// 1. Lock booking row
const existing = await trx
.selectFrom("bookings")
.selectAll()
.where("id", "=", input.id)
.where("ledgerId", "=", input.ledgerId)
.forUpdate()
.executeTakeFirst();

if (!existing) {
throw new NotFoundError("Booking not found");
}

// 2. Capture server time
const serverTime = await getServerTime(trx);

// 3. Validate state
if (existing.status !== "hold" && existing.status !== "confirmed") {
throw new ConflictError("booking.invalid_transition", {
currentStatus: existing.status,
requestedAction: "reschedule",
});
}

// 4. Check hold expiry
if (existing.status === "hold" && existing.expiresAt && serverTime >= existing.expiresAt) {
throw new ConflictError("booking.hold_expired", {
expiresAt: existing.expiresAt,
serverTime,
});
}

// 5. Snapshot current active allocations (for event payload) and derive resourceId
const previousAllocations = await trx
.selectFrom("allocations")
.selectAll()
.where("bookingId", "=", existing.id)
.where("active", "=", true)
.execute();

const resourceId = previousAllocations[0]!.resourceId;

// 6. Lock resource row (serializes concurrent allocation writes)
const resource = await trx
.selectFrom("resources")
.selectAll()
.where("id", "=", resourceId)
.where("ledgerId", "=", input.ledgerId)
.forUpdate()
.executeTakeFirstOrThrow();

// 7. Load service + current policy version
const service = await trx
.selectFrom("services")
.selectAll()
.where("id", "=", existing.serviceId)
.where("ledgerId", "=", input.ledgerId)
.executeTakeFirst();

if (!service) {
throw new NotFoundError("Service not found");
}

if (!service.policyId) {
throw new ConflictError("service.no_policy", {
message: "Service must have a policy to reschedule bookings",
});
}

const policyRow = await trx
.selectFrom("policies")
.select("currentVersionId")
.where("id", "=", service.policyId)
.executeTakeFirstOrThrow();

const version = await trx
.selectFrom("policyVersions")
.selectAll()
.where("id", "=", policyRow.currentVersionId)
.executeTakeFirstOrThrow();

// 8. Evaluate policy against new times
const result = evaluatePolicy(
version.config as unknown as PolicyConfig,
{ startTime: input.startTime, endTime: input.endTime },
{ decisionTime: serverTime, timezone: resource.timezone },
);

if (!result.allowed) {
throw new ConflictError("policy.rejected", {
code: result.code,
message: result.message,
...("details" in result ? { details: result.details } : {}),
});
}

const startTime = result.effectiveStartTime;
const endTime = result.effectiveEndTime;
const bufferBeforeMs = result.bufferBeforeMs;
const bufferAfterMs = result.bufferAfterMs;

let holdDurationMs = DEFAULT_HOLD_DURATION_MS;
if (result.resolvedConfig.hold?.duration_ms !== undefined) {
holdDurationMs = result.resolvedConfig.hold.duration_ms;
}

// 9. Compute new expiresAt
const isHold = existing.status === "hold";
const expiresAt = isHold ? new Date(serverTime.getTime() + holdDurationMs) : null;

// 10. Deactivate old allocations
await trx
.updateTable("allocations")
.set({ active: false, expiresAt: null })
.where("bookingId", "=", existing.id)
.where("active", "=", true)
.execute();

// 11. Insert new allocation (conflict check runs against other allocations only)
await insertAllocation(trx, {
ledgerId: input.ledgerId,
resourceId,
bookingId: existing.id,
startTime,
endTime,
bufferBeforeMs,
bufferAfterMs,
expiresAt,
metadata: {},
serverTime,
});

// 12. Update booking
const booking = await trx
.updateTable("bookings")
.set({
policyVersionId: version.id,
expiresAt,
})
.where("id", "=", existing.id)
.returningAll()
.executeTakeFirstOrThrow();

// 13. Fetch all allocations for response
const allocations = await trx
.selectFrom("allocations")
.selectAll()
.where("bookingId", "=", existing.id)
.execute();

// 14. Emit event
await emitEvent(trx, "booking.rescheduled", booking.ledgerId, {
booking: serializeBooking(booking, allocations),
previousAllocations: previousAllocations.map((a) => serializeAllocation(a)),
});

return { booking, allocations, serverTime };
});
},
});
31 changes: 31 additions & 0 deletions apps/server/src/operations/booking/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { db } from "database";
import { createOperation } from "lib/operation";
import { bookingInput } from "@floyd-run/schema/inputs";
import { NotFoundError } from "lib/errors";

export default createOperation({
input: bookingInput.update,
execute: async (input) => {
const booking = await db
.updateTable("bookings")
.set({
metadata: input.metadata,
})
.where("id", "=", input.id)
.where("ledgerId", "=", input.ledgerId)
.returningAll()
.executeTakeFirst();

if (!booking) {
throw new NotFoundError("Booking not found");
}

const allocations = await db
.selectFrom("allocations")
.selectAll()
.where("bookingId", "=", booking.id)
.execute();

return { booking, allocations };
},
});
28 changes: 27 additions & 1 deletion apps/server/src/routes/v1/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,30 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>()
const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } };
await storeIdempotencyResponse(c, responseBody, 200);
return c.json(responseBody);
});
})

.patch("/:id", async (c) => {
const body = await c.req.json();
const { booking, allocations } = await operations.booking.update({
...(body as object),
id: c.req.param("id"),
ledgerId: c.req.param("ledgerId"),
} as Parameters<typeof operations.booking.update>[0]);
return c.json({ data: serializeBooking(booking, allocations) });
})

.post(
"/:id/reschedule",
idempotent({ significantFields: ["startTime", "endTime"] }),
async (c) => {
const body = c.get("parsedBody") ?? (await c.req.json());
const { booking, allocations, serverTime } = await operations.booking.reschedule({
...(body as object),
id: c.req.param("id"),
ledgerId: c.req.param("ledgerId"),
} as Parameters<typeof operations.booking.reschedule>[0]);
const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } };
await storeIdempotencyResponse(c, responseBody, 200);
return c.json(responseBody);
},
);
84 changes: 84 additions & 0 deletions apps/server/src/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,46 @@ registry.registerPath({
},
});

registry.registerPath({
method: "patch",
path: "/v1/ledgers/{ledgerId}/bookings/{id}",
tags: ["Bookings"],
summary: "Update booking metadata",
description: "Replaces the booking's metadata object. Works on bookings in any status.",
request: {
params: z.object({ ledgerId: z.string(), id: z.string() }),
body: {
content: {
"application/json": {
schema: z.object({
metadata: z.record(z.string(), z.unknown()).openapi({
example: {
customerName: "Alice",
partySize: 2,
notes: "Needs wheelchair accessible room",
},
}),
}),
},
},
},
},
responses: {
200: {
description: "Booking updated",
content: { "application/json": { schema: booking.get } },
},
404: {
description: "Booking not found",
content: { "application/json": { schema: error.schema } },
},
422: {
description: "Invalid input",
content: { "application/json": { schema: error.schema } },
},
},
});

registry.registerPath({
method: "post",
path: "/v1/ledgers/{ledgerId}/bookings/{id}/confirm",
Expand Down Expand Up @@ -718,6 +758,50 @@ registry.registerPath({
},
});

registry.registerPath({
method: "post",
path: "/v1/ledgers/{ledgerId}/bookings/{id}/reschedule",
tags: ["Bookings"],
summary: "Reschedule a booking",
description:
"Changes the time of an existing booking while preserving its identity. " +
"Re-evaluates the service's current policy version against the new time. " +
"The old allocation is deactivated and a new one is created atomically. " +
"Hold bookings get a fresh hold timer. Confirmed bookings stay confirmed. " +
"Supports idempotency via the Idempotency-Key header.",
request: {
params: z.object({ ledgerId: z.string(), id: z.string() }),
body: {
content: {
"application/json": {
schema: z.object({
startTime: z.iso.datetime().openapi({ example: "2026-01-15T14:00:00Z" }),
endTime: z.iso.datetime().openapi({ example: "2026-01-15T15:00:00Z" }),
}),
},
},
},
},
responses: {
200: {
description: "Booking rescheduled",
content: { "application/json": { schema: booking.get } },
},
404: {
description: "Booking not found",
content: { "application/json": { schema: error.schema } },
},
409: {
description: "Conflict (overlap, policy rejected, expired hold, or invalid state)",
content: { "application/json": { schema: error.schema } },
},
422: {
description: "Invalid input",
content: { "application/json": { schema: error.schema } },
},
},
});

// Policy routes
registry.registerPath({
method: "get",
Expand Down
Loading