diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 6b1eb5c..45679a6 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -3140,6 +3140,229 @@ } } } + }, + "patch": { + "tags": ["Bookings"], + "summary": "Update booking metadata", + "description": "Replaces the booking's metadata object. Works on bookings in any status.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "additionalProperties": {}, + "example": { + "customerName": "Alice", + "partySize": 2, + "notes": "Needs wheelchair accessible room" + } + } + }, + "required": ["metadata"] + } + } + } + }, + "responses": { + "200": { + "description": "Booking updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyVersionId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["hold", "confirmed", "canceled", "expired"] + }, + "expiresAt": { + "type": ["string", "null"] + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "id", + "resourceId", + "startTime", + "endTime", + "buffer", + "active" + ] + } + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyVersionId", + "status", + "expiresAt", + "allocations", + "metadata", + "createdAt", + "updatedAt" + ] + }, + "meta": { + "type": "object", + "properties": { + "serverTime": { + "type": "string" + } + }, + "required": ["serverTime"] + } + }, + "required": ["data"] + } + } + } + }, + "404": { + "description": "Booking not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + } + } + }, + "422": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + } + } + } + } } }, "/v1/ledgers/{ledgerId}/bookings/{id}/confirm": { @@ -3550,6 +3773,265 @@ } } }, + "/v1/ledgers/{ledgerId}/bookings/{id}/reschedule": { + "post": { + "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.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "startTime": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T14:00:00Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T15:00:00Z" + } + }, + "required": ["startTime", "endTime"] + } + } + } + }, + "responses": { + "200": { + "description": "Booking rescheduled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyVersionId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["hold", "confirmed", "canceled", "expired"] + }, + "expiresAt": { + "type": ["string", "null"] + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "id", + "resourceId", + "startTime", + "endTime", + "buffer", + "active" + ] + } + }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyVersionId", + "status", + "expiresAt", + "allocations", + "metadata", + "createdAt", + "updatedAt" + ] + }, + "meta": { + "type": "object", + "properties": { + "serverTime": { + "type": "string" + } + }, + "required": ["serverTime"] + } + }, + "required": ["data"] + } + } + } + }, + "404": { + "description": "Booking not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "Conflict (overlap, policy rejected, expired hold, or invalid state)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + } + } + }, + "422": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/v1/ledgers/{ledgerId}/policies": { "get": { "tags": ["Policies"], diff --git a/apps/server/src/infra/event-bus.ts b/apps/server/src/infra/event-bus.ts index 59cdf45..a3d9bd9 100644 --- a/apps/server/src/infra/event-bus.ts +++ b/apps/server/src/infra/event-bus.ts @@ -9,7 +9,8 @@ export type InternalEventType = | "booking.created" | "booking.confirmed" | "booking.canceled" - | "booking.expired"; + | "booking.expired" + | "booking.rescheduled"; export interface InternalEvent { id: string; diff --git a/apps/server/src/operations/booking/cancel.ts b/apps/server/src/operations/booking/cancel.ts index 0dbb89c..bcce61a 100644 --- a/apps/server/src/operations/booking/cancel.ts +++ b/apps/server/src/operations/booking/cancel.ts @@ -49,7 +49,6 @@ export default createOperation({ .set({ status: "canceled", expiresAt: null, - updatedAt: serverTime, }) .where("id", "=", input.id) .returningAll() @@ -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(); diff --git a/apps/server/src/operations/booking/confirm.ts b/apps/server/src/operations/booking/confirm.ts index 7baac4e..40b66a9 100644 --- a/apps/server/src/operations/booking/confirm.ts +++ b/apps/server/src/operations/booking/confirm.ts @@ -57,7 +57,6 @@ export default createOperation({ .set({ status: "confirmed", expiresAt: null, - updatedAt: serverTime, }) .where("id", "=", input.id) .returningAll() @@ -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(); diff --git a/apps/server/src/operations/booking/index.ts b/apps/server/src/operations/booking/index.ts index 9cfd9c5..c87db0a 100644 --- a/apps/server/src/operations/booking/index.ts +++ b/apps/server/src/operations/booking/index.ts @@ -1,6 +1,8 @@ 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"; @@ -8,6 +10,8 @@ export const booking = { create, confirm, cancel, + reschedule, + update, get, list, }; diff --git a/apps/server/src/operations/booking/reschedule.ts b/apps/server/src/operations/booking/reschedule.ts new file mode 100644 index 0000000..70c2582 --- /dev/null +++ b/apps/server/src/operations/booking/reschedule.ts @@ -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 }; + }); + }, +}); diff --git a/apps/server/src/operations/booking/update.ts b/apps/server/src/operations/booking/update.ts new file mode 100644 index 0000000..81360c9 --- /dev/null +++ b/apps/server/src/operations/booking/update.ts @@ -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 }; + }, +}); diff --git a/apps/server/src/routes/v1/bookings.ts b/apps/server/src/routes/v1/bookings.ts index edcdb90..5343700 100644 --- a/apps/server/src/routes/v1/bookings.ts +++ b/apps/server/src/routes/v1/bookings.ts @@ -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[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[0]); + const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } }; + await storeIdempotencyResponse(c, responseBody, 200); + return c.json(responseBody); + }, + ); diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 6772d0a..f0ddc86 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -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", @@ -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", diff --git a/apps/server/test/integration/v1/bookings/reschedule.spec.ts b/apps/server/test/integration/v1/bookings/reschedule.spec.ts new file mode 100644 index 0000000..a1548e5 --- /dev/null +++ b/apps/server/test/integration/v1/bookings/reschedule.spec.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createResource, createService, createPolicy } from "../../setup/factories"; +import type { Booking, Allocation } from "@floyd-run/schema/types"; +import { db } from "database"; + +describe("POST /v1/ledgers/:ledgerId/bookings/:id/reschedule", () => { + async function createHoldBooking(ledgerId: string) { + const { resource } = await createResource({ ledgerId }); + const { policy } = await createPolicy({ + ledgerId, + config: { schema_version: 1, default_availability: "open", constraints: {} }, + }); + const { service } = await createService({ + ledgerId, + policyId: policy.id, + resourceIds: [resource.id], + }); + + const response = await client.post(`/v1/ledgers/${ledgerId}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + status: "hold", + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + return { booking: data, resource, service, policy }; + } + + it("returns 200 when rescheduling a hold booking", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(200); + const { data, meta } = (await response.json()) as { + data: Booking; + meta: { serverTime: string }; + }; + expect(data.id).toBe(holdBooking.id); + expect(data.status).toBe("hold"); + expect(data.expiresAt).not.toBeNull(); + expect(meta.serverTime).toBeDefined(); + + // Should have 2 allocations: old (inactive) + new (active) + expect(data.allocations).toHaveLength(2); + const activeAlloc = data.allocations.find((a) => a.active); + const inactiveAlloc = data.allocations.find((a) => !a.active); + expect(activeAlloc).toBeDefined(); + expect(inactiveAlloc).toBeDefined(); + expect(activeAlloc!.startTime).toBe("2026-06-01T14:00:00.000Z"); + expect(activeAlloc!.endTime).toBe("2026-06-01T15:00:00.000Z"); + expect(inactiveAlloc!.startTime).toBe("2026-06-01T10:00:00.000Z"); + expect(inactiveAlloc!.endTime).toBe("2026-06-01T11:00:00.000Z"); + }); + + it("returns 200 when rescheduling a confirmed booking", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + // Confirm first + const confirmResp = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/confirm`, + ); + expect(confirmResp.status).toBe(200); + + // Reschedule + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.id).toBe(holdBooking.id); + expect(data.status).toBe("confirmed"); + expect(data.expiresAt).toBeNull(); + + const activeAlloc = data.allocations.find((a) => a.active); + expect(activeAlloc).toBeDefined(); + expect(activeAlloc!.startTime).toBe("2026-06-01T14:00:00.000Z"); + }); + + it("creates booking.rescheduled event in outbox", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + + const event = await db + .selectFrom("outboxEvents") + .selectAll() + .where("ledgerId", "=", ledger.id) + .where("eventType", "=", "booking.rescheduled") + .executeTakeFirst(); + + expect(event).toBeDefined(); + expect(event?.eventType).toBe("booking.rescheduled"); + expect(event?.ledgerId).toBe(ledger.id); + expect(event?.publishedAt).toBeNull(); + expect(event?.publishAttempts).toBe(0); + + const payload = event?.payload as { + id: string; + type: string; + data: { booking: Booking; previousAllocations: Allocation[] }; + }; + expect(payload.type).toBe("booking.rescheduled"); + expect(payload.data.booking.id).toBe(data.id); + expect(payload.data.previousAllocations).toHaveLength(1); + expect(payload.data.previousAllocations[0]!.startTime).toBe("2026-06-01T10:00:00.000Z"); + }); + + it("re-evaluates policy and rejects invalid new time", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default_availability: "open", + constraints: { + duration: { min_ms: 3600000, max_ms: 3600000 }, + }, + }, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + // Create a 1-hour booking (valid) + const createResp = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + status: "hold", + }); + expect(createResp.status).toBe(201); + const { data: booking } = (await createResp.json()) as { data: Booking }; + + // Try to reschedule to 30 minutes (invalid duration) + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${booking.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T14:30:00.000Z", + }, + ); + + expect(response.status).toBe(409); + }); + + it("updates policyVersionId to current version", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking, policy } = await createHoldBooking(ledger.id); + + const originalPolicyVersionId = holdBooking.policyVersionId; + + // Update the policy (creates new version) + await client.put(`/v1/ledgers/${ledger.id}/policies/${policy.id}`, { + name: "Updated Policy", + config: { schema_version: 1, default_availability: "open", constraints: {} }, + }); + + // Reschedule + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.policyVersionId).not.toBe(originalPolicyVersionId); + }); + + it("returns 409 when rescheduling a canceled booking", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + // Cancel first + await client.post(`/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(409); + }); + + it("returns 404 for non-existent booking", async () => { + const { ledger } = await createLedger(); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/bkg_00000000000000000000000000/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(404); + }); + + it("returns 422 when endTime is before startTime", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T15:00:00.000Z", + endTime: "2026-06-01T14:00:00.000Z", + }, + ); + + expect(response.status).toBe(422); + }); + + it("returns 409 when new time conflicts with another booking", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { schema_version: 1, default_availability: "open", constraints: {} }, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + // Create booking A at 10:00-11:00 + const respA = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + status: "confirmed", + }); + expect(respA.status).toBe(201); + const { data: bookingA } = (await respA.json()) as { data: Booking }; + + // Create booking B at 14:00-15:00 + const respB = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + status: "confirmed", + }); + expect(respB.status).toBe(201); + + // Reschedule A to overlap with B + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${bookingA.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(409); + }); + + it("does not conflict with its own current allocation", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + // Reschedule to overlapping time (shift 30 min later) + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T10:30:00.000Z", + endTime: "2026-06-01T11:30:00.000Z", + }, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + const activeAlloc = data.allocations.find((a) => a.active); + expect(activeAlloc!.startTime).toBe("2026-06-01T10:30:00.000Z"); + expect(activeAlloc!.endTime).toBe("2026-06-01T11:30:00.000Z"); + }); + + it("frees original time slot after reschedule", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { schema_version: 1, default_availability: "open", constraints: {} }, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + // Create booking A at 10:00-11:00 + const respA = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + status: "confirmed", + }); + expect(respA.status).toBe(201); + const { data: bookingA } = (await respA.json()) as { data: Booking }; + + // Reschedule A to 14:00-15:00 + const rescheduleResp = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${bookingA.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + expect(rescheduleResp.status).toBe(200); + + // Create booking B at 10:00-11:00 (should succeed, slot is free) + const respB = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + status: "confirmed", + }); + expect(respB.status).toBe(201); + }); + + it("preserves booking identity after reschedule", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.id).toBe(holdBooking.id); + expect(data.serviceId).toBe(holdBooking.serviceId); + expect(data.ledgerId).toBe(holdBooking.ledgerId); + }); + + it("handles Idempotency-Key header", async () => { + const { ledger } = await createLedger(); + const { booking: holdBooking } = await createHoldBooking(ledger.id); + + const body = { + startTime: "2026-06-01T14:00:00.000Z", + endTime: "2026-06-01T15:00:00.000Z", + }; + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + body, + { headers: { "Idempotency-Key": "test-reschedule-idem-123" } }, + ); + + expect(response.status).toBe(200); + + // Second request with same key returns cached response + const response2 = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/reschedule`, + body, + { headers: { "Idempotency-Key": "test-reschedule-idem-123" } }, + ); + + expect(response2.status).toBe(200); + }); +}); diff --git a/apps/server/test/integration/v1/bookings/update.spec.ts b/apps/server/test/integration/v1/bookings/update.spec.ts new file mode 100644 index 0000000..32c8b81 --- /dev/null +++ b/apps/server/test/integration/v1/bookings/update.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createResource, createService, createPolicy } from "../../setup/factories"; +import type { Booking } from "@floyd-run/schema/types"; + +describe("PATCH /v1/ledgers/:ledgerId/bookings/:id", () => { + async function createHoldBooking(ledgerId: string) { + const { resource } = await createResource({ ledgerId }); + const { policy } = await createPolicy({ + ledgerId, + config: { schema_version: 1, default_availability: "open", constraints: {} }, + }); + const { service } = await createService({ + ledgerId, + policyId: policy.id, + resourceIds: [resource.id], + }); + + const response = await client.post(`/v1/ledgers/${ledgerId}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + status: "hold", + metadata: { customerName: "Alice" }, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + return data; + } + + it("returns 200 and replaces metadata", async () => { + const { ledger } = await createLedger(); + const booking = await createHoldBooking(ledger.id); + + const response = await client.patch(`/v1/ledgers/${ledger.id}/bookings/${booking.id}`, { + metadata: { customerName: "Alice", notes: "Mom is also coming" }, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.id).toBe(booking.id); + expect(data.metadata).toEqual({ customerName: "Alice", notes: "Mom is also coming" }); + }); + + it("works on confirmed bookings", async () => { + const { ledger } = await createLedger(); + const booking = await createHoldBooking(ledger.id); + + await client.post(`/v1/ledgers/${ledger.id}/bookings/${booking.id}/confirm`); + + const response = await client.patch(`/v1/ledgers/${ledger.id}/bookings/${booking.id}`, { + metadata: { updated: true }, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.status).toBe("confirmed"); + expect(data.metadata).toEqual({ updated: true }); + }); + + it("works on canceled bookings", async () => { + const { ledger } = await createLedger(); + const booking = await createHoldBooking(ledger.id); + + await client.post(`/v1/ledgers/${ledger.id}/bookings/${booking.id}/cancel`); + + const response = await client.patch(`/v1/ledgers/${ledger.id}/bookings/${booking.id}`, { + metadata: { cancelReason: "customer request" }, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.status).toBe("canceled"); + expect(data.metadata).toEqual({ cancelReason: "customer request" }); + }); + + it("preserves allocations in response", async () => { + const { ledger } = await createLedger(); + const booking = await createHoldBooking(ledger.id); + + const response = await client.patch(`/v1/ledgers/${ledger.id}/bookings/${booking.id}`, { + metadata: { foo: "bar" }, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.allocations).toHaveLength(1); + expect(data.allocations[0]!.active).toBe(true); + }); + + it("returns 404 for non-existent booking", async () => { + const { ledger } = await createLedger(); + + const response = await client.patch( + `/v1/ledgers/${ledger.id}/bookings/bkg_00000000000000000000000000`, + { metadata: { foo: "bar" } }, + ); + + expect(response.status).toBe(404); + }); + + it("returns 422 for missing metadata field", async () => { + const { ledger } = await createLedger(); + const booking = await createHoldBooking(ledger.id); + + const response = await client.patch(`/v1/ledgers/${ledger.id}/bookings/${booking.id}`, {}); + + expect(response.status).toBe(422); + }); +}); diff --git a/docs/bookings.md b/docs/bookings.md index 986b13d..b97c145 100644 --- a/docs/bookings.md +++ b/docs/bookings.md @@ -31,6 +31,8 @@ A booking moves through these states: | `hold` | `canceled` | `POST /bookings/:id/cancel` | | `hold` | `expired` | `expiresAt` elapsed (automatic) | | `confirmed` | `canceled` | `POST /bookings/:id/cancel` | +| `hold` | `hold` | `POST /bookings/:id/reschedule` | +| `confirmed` | `confirmed` | `POST /bookings/:id/reschedule` | ## Creating a booking @@ -75,6 +77,24 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ This creates the booking without an expiration. +## Update metadata + +Agents often learn new information mid-conversation — a customer mentions extra guests, dietary requirements, or a reason for cancellation. Use PATCH to attach this context to the booking: + +```bash +curl -X PATCH "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "metadata": { + "customerName": "Alice", + "partySize": 2, + "notes": "Needs wheelchair accessible room" + } + }' +``` + +This replaces the entire `metadata` object. Works on bookings in any status — you can add a cancellation reason to a canceled booking, or update notes on a confirmed one. + ## Confirm (commit) When the user says "yes", confirm the hold: @@ -109,6 +129,38 @@ Cancel works on both `hold` and `confirmed` bookings. Returns `409 Conflict` wit Cancel is safe to retry with the same `Idempotency-Key` header. +## Reschedule (change time) + +Move a booking to a new time without losing its identity: + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/reschedule" \ + -H "Content-Type: application/json" \ + -d '{ + "startTime": "2026-03-01T14:00:00Z", + "endTime": "2026-03-01T15:00:00Z" + }' +``` + +This: + +- Re-evaluates the service's **current** policy version against the new time +- Deactivates the old allocation and creates a new one atomically +- Updates `policyVersionId` to the current version +- For `hold` bookings: resets `expiresAt` (fresh hold timer) +- For `confirmed` bookings: stays confirmed (no expiry) + +Returns `409 Conflict` with: + +- `policy.rejected` if the new time violates the policy +- `allocation.overlap` if the new time conflicts with another booking +- `booking.hold_expired` if the hold expired before rescheduling +- `booking.invalid_transition` if the booking is `canceled` or `expired` + +After rescheduling, the response includes both the old (inactive) and new (active) allocations. The original time slot is freed for new bookings. + +Reschedule is safe to retry with the same `Idempotency-Key` header. + ## Expiration Hold bookings expire automatically when `expiresAt` elapses. The expiration worker: @@ -154,7 +206,7 @@ After expiration, the time slot is available for new bookings. } ``` -Mutating endpoints (`create`, `confirm`, `cancel`) return `meta.serverTime`. +Mutating endpoints (`create`, `confirm`, `cancel`, `reschedule`) return `meta.serverTime`. ## Buffers diff --git a/docs/events.md b/docs/events.md index fc40039..23804da 100644 --- a/docs/events.md +++ b/docs/events.md @@ -6,12 +6,13 @@ Floyd Engine emits events when state changes occur (bookings created, allocation ### Booking events -| Event | Description | -| ------------------- | ---------------------------- | -| `booking.created` | A new booking was created | -| `booking.confirmed` | A hold booking was confirmed | -| `booking.canceled` | A booking was canceled | -| `booking.expired` | A hold booking expired | +| Event | Description | +| --------------------- | --------------------------------- | +| `booking.created` | A new booking was created | +| `booking.confirmed` | A hold booking was confirmed | +| `booking.canceled` | A booking was canceled | +| `booking.expired` | A hold booking expired | +| `booking.rescheduled` | A booking was moved to a new time | ### Allocation events @@ -107,6 +108,7 @@ interface Event { data: { booking?: Booking; // Present for booking events allocation?: Allocation; // Present for allocation events + previousAllocations?: Allocation[]; // Present for booking.rescheduled }; } ``` @@ -141,6 +143,55 @@ interface Event { } ``` +### Example: booking.rescheduled + +```json +{ + "id": "evt_01abc123...", + "type": "booking.rescheduled", + "ledgerId": "ldg_01xyz789...", + "timestamp": "2026-01-15T11:00:00Z", + + "schemaVersion": 1, + "data": { + "booking": { + "id": "bkg_01def456...", + "serviceId": "svc_01ghi789...", + "status": "confirmed", + "expiresAt": null, + "allocations": [ + { + "id": "alc_01old...", + "resourceId": "rsc_01mno345...", + "startTime": "2026-01-15T14:00:00Z", + "endTime": "2026-01-15T15:00:00Z", + "active": false + }, + { + "id": "alc_01new...", + "resourceId": "rsc_01mno345...", + "startTime": "2026-01-15T16:00:00Z", + "endTime": "2026-01-15T17:00:00Z", + "active": true + } + ], + "createdAt": "2026-01-15T10:00:00Z" + }, + "previousAllocations": [ + { + "id": "alc_01old...", + "resourceId": "rsc_01mno345...", + "startTime": "2026-01-15T14:00:00Z", + "endTime": "2026-01-15T15:00:00Z", + "active": true + } + ] + } +} +``` + +The `previousAllocations` array contains the allocations as they were before the reschedule (with `active: true`). The `booking.allocations` array shows the current state (old inactive, new active). + ### Example: allocation.deleted ```json diff --git a/packages/schema/inputs/booking.ts b/packages/schema/inputs/booking.ts index c7d16bd..7903131 100644 --- a/packages/schema/inputs/booking.ts +++ b/packages/schema/inputs/booking.ts @@ -35,3 +35,21 @@ export const cancel = z.object({ id: z.string().refine((id) => isValidId(id, "bkg"), { message: "Invalid booking ID" }), ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); + +export const update = z.object({ + id: z.string().refine((id) => isValidId(id, "bkg"), { message: "Invalid booking ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + metadata: z.record(z.string(), z.unknown()), +}); + +export const reschedule = z + .object({ + id: z.string().refine((id) => isValidId(id, "bkg"), { message: "Invalid booking ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + startTime: z.coerce.date(), + endTime: z.coerce.date(), + }) + .refine((data) => data.endTime > data.startTime, { + message: "endTime must be after startTime", + path: ["endTime"], + });