diff --git a/README.md b/README.md index be9cd5f..4078dde 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ > **Dev Preview** - APIs may change. Not recommended for production use yet. -Headless booking infrastructure for AI agents. +Booking engine for AI agents. +- **Services & Policies** - Define bookable offerings with scheduling rules - **Hold → Confirm** - Two-phase booking for async workflows - **Race-safe** - Database-level conflict detection - **Retry-friendly** - Idempotent operations with automatic deduplication @@ -22,21 +23,29 @@ Floyd handles all of this so you can focus on your agent. ## Example ```bash -# 1. Create a hold (reserves the slot until confirmed or expired) -curl -X POST http://localhost:4000/v1/ledgers/$LEDGER_ID/allocations \ +# 1. Create a service (groups resources with a policy) +curl -X POST http://localhost:4000/v1/ledgers/$LEDGER_ID/services \ -H "Content-Type: application/json" \ -d '{ - "resourceId": "doctor-alice", - "startAt": "2024-01-15T10:00:00Z", - "endAt": "2024-01-15T11:00:00Z", - "expiresAt": "2024-01-15T09:55:00Z" + "name": "Haircut", + "resourceIds": ["rsc_stylist1"] }' -# 2. Confirm when user says "yes" -curl -X POST http://localhost:4000/v1/ledgers/$LEDGER_ID/allocations/$ALLOC_ID/confirm +# 2. Create a hold (reserves the slot until confirmed or expired) +curl -X POST http://localhost:4000/v1/ledgers/$LEDGER_ID/bookings \ + -H "Content-Type: application/json" \ + -d '{ + "serviceId": "svc_...", + "resourceId": "rsc_stylist1", + "startAt": "2026-01-15T10:00:00Z", + "endAt": "2026-01-15T11:00:00Z" + }' + +# 3. Confirm when user says "yes" +curl -X POST http://localhost:4000/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/confirm -# 3. Or cancel if they change their mind -curl -X POST http://localhost:4000/v1/ledgers/$LEDGER_ID/allocations/$ALLOC_ID/cancel +# 4. Or cancel if they change their mind +curl -X POST http://localhost:4000/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/cancel # Overlapping requests get 409 Conflict - double-booking is impossible ``` diff --git a/apps/server/eslint.config.js b/apps/server/eslint.config.js index 0cc4414..89caffe 100644 --- a/apps/server/eslint.config.js +++ b/apps/server/eslint.config.js @@ -1,3 +1,21 @@ import app from "@floyd-run/eslint/app"; -export default app; +export default [ + ...app, + { + files: ["src/domain/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["database/*", "infra/*", "operations/*", "config/*", "routes/*", "workers/*"], + message: "domain/ must be pure — no imports from app infrastructure layers.", + }, + ], + }, + ], + }, + }, +]; diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 72b023b..5393a08 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Floyd Engine API", "version": "1.0.0", - "description": "Resource scheduling and allocation engine" + "description": "Booking engine for AI agents" }, "servers": [ { @@ -43,6 +43,9 @@ "ledgerId": { "type": "string" }, + "timezone": { + "type": ["string", "null"] + }, "createdAt": { "type": "string" }, @@ -50,7 +53,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] }, "Allocation": { "type": "object", @@ -64,16 +67,30 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] + }, + "active": { + "type": "boolean" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] + }, "expiresAt": { "type": ["string", "null"] }, @@ -92,9 +109,11 @@ "id", "ledgerId", "resourceId", - "status", - "startAt", - "endAt", + "bookingId", + "active", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -133,10 +152,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -144,7 +163,7 @@ "enum": ["free", "busy"] } }, - "required": ["startAt", "endAt", "status"] + "required": ["startTime", "endTime", "status"] } } }, @@ -153,10 +172,10 @@ "TimelineBlock": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -164,33 +183,274 @@ "enum": ["free", "busy"] } }, - "required": ["startAt", "endAt", "status"] + "required": ["startTime", "endTime", "status"] + }, + "Slot": { + "type": "object", + "properties": { + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startTime", "endTime"] + }, + "ResourceSlots": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "slots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startTime", "endTime"] + } + } + }, + "required": ["resourceId", "timezone", "slots"] + }, + "Window": { + "type": "object", + "properties": { + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startTime", "endTime"] + }, + "ResourceWindows": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startTime", "endTime"] + } + } + }, + "required": ["resourceId", "timezone", "windows"] + }, + "Policy": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "configHash": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "ledgerId", "config", "configHash", "createdAt", "updatedAt"] + }, + "Service": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "name", + "policyId", + "resourceIds", + "metadata", + "createdAt", + "updatedAt" + ] + }, + "Booking": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "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", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyId", + "status", + "expiresAt", + "allocations", + "metadata", + "createdAt", + "updatedAt" + ] }, "Error": { "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { "type": "string" }, - { + "message": { + "type": "string" + }, + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -335,27 +595,24 @@ "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { "type": "string" }, - { + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -399,6 +656,9 @@ "ledgerId": { "type": "string" }, + "timezone": { + "type": ["string", "null"] + }, "createdAt": { "type": "string" }, @@ -406,7 +666,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] } } }, @@ -435,7 +695,14 @@ "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "timezone": { + "type": "string", + "description": "IANA timezone for the resource (e.g. America/New_York)", + "example": "America/New_York" + } + }, + "required": ["timezone"] } } } @@ -457,6 +724,9 @@ "ledgerId": { "type": "string" }, + "timezone": { + "type": ["string", "null"] + }, "createdAt": { "type": "string" }, @@ -464,7 +734,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] } }, "required": ["data"] @@ -514,6 +784,9 @@ "ledgerId": { "type": "string" }, + "timezone": { + "type": ["string", "null"] + }, "createdAt": { "type": "string" }, @@ -521,7 +794,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] } }, "required": ["data"] @@ -537,27 +810,24 @@ "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { "type": "string" }, - { + "message": { + "type": "string" + }, + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -600,27 +870,24 @@ "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { "type": "string" }, - { + "message": { + "type": "string" + }, + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -660,20 +927,20 @@ "description": "Resource IDs to query", "example": ["rsc_01abc123def456ghi789jkl012"] }, - "startAt": { + "startTime": { "type": "string", "format": "date-time", "description": "Start of the time window (ISO 8601)", "example": "2026-01-04T10:00:00Z" }, - "endAt": { + "endTime": { "type": "string", "format": "date-time", "description": "End of the time window (ISO 8601)", "example": "2026-01-04T18:00:00Z" } }, - "required": ["resourceIds", "startAt", "endAt"] + "required": ["resourceIds", "startTime", "endTime"] } } } @@ -699,10 +966,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -710,7 +977,7 @@ "enum": ["free", "busy"] } }, - "required": ["startAt", "endAt", "status"] + "required": ["startTime", "endTime", "status"] } } }, @@ -762,16 +1029,30 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] + }, + "active": { + "type": "boolean" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] + }, "expiresAt": { "type": ["string", "null"] }, @@ -790,9 +1071,11 @@ "id", "ledgerId", "resourceId", - "status", - "startAt", - "endAt", + "bookingId", + "active", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -811,7 +1094,7 @@ "post": { "tags": ["Allocations"], "summary": "Create a new allocation", - "description": "Creates a new allocation for a resource. Supports idempotency via the Idempotency-Key header.", + "description": "Creates a raw allocation for a resource. Use bookings for policy-evaluated reservations with lifecycle management. Supports idempotency via the Idempotency-Key header.", "parameters": [ { "schema": { @@ -830,31 +1113,27 @@ "properties": { "resourceId": { "type": "string", - "example": "res_01abc123def456ghi789jkl012" - }, - "status": { - "type": "string", - "enum": ["hold", "confirmed"], - "default": "hold" + "example": "rsc_01abc123def456ghi789jkl012" }, - "startAt": { + "startTime": { "type": "string", "format": "date-time" }, - "endAt": { + "endTime": { "type": "string", "format": "date-time" }, "expiresAt": { "type": ["string", "null"], - "format": "date-time" + "format": "date-time", + "description": "If set, the allocation auto-expires after this time" }, "metadata": { "type": ["object", "null"], "additionalProperties": {} } }, - "required": ["resourceId", "startAt", "endAt"] + "required": ["resourceId", "startTime", "endTime"] } } } @@ -879,16 +1158,30 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] + }, + "active": { + "type": "boolean" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] + }, "expiresAt": { "type": ["string", "null"] }, @@ -907,9 +1200,11 @@ "id", "ledgerId", "resourceId", - "status", - "startAt", - "endAt", + "bookingId", + "active", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -939,27 +1234,24 @@ "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { "type": "string" }, - { + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -1012,16 +1304,30 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] }, - "startAt": { + "active": { + "type": "boolean" + }, + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] + }, "expiresAt": { "type": ["string", "null"] }, @@ -1040,9 +1346,11 @@ "id", "ledgerId", "resourceId", - "status", - "startAt", - "endAt", + "bookingId", + "active", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -1072,27 +1380,24 @@ "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { "type": "string" }, - { + "message": { + "type": "string" + }, + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -1101,13 +1406,11 @@ } } } - } - }, - "/v1/ledgers/{ledgerId}/allocations/{id}/confirm": { - "post": { + }, + "delete": { "tags": ["Allocations"], - "summary": "Confirm a held allocation", - "description": "Confirms an allocation that is currently in HOLD status.", + "summary": "Delete an allocation", + "description": "Deletes a raw allocation. Allocations that belong to a booking cannot be deleted directly — cancel the booking instead.", "parameters": [ { "schema": { @@ -1127,157 +1430,158 @@ } ], "responses": { - "200": { - "description": "Allocation confirmed", + "204": { + "description": "Allocation deleted" + }, + "404": { + "description": "Allocation not found", "content": { "application/json": { "schema": { "type": "object", "properties": { - "data": { + "error": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "ledgerId": { - "type": "string" - }, - "resourceId": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] - }, - "startAt": { + "code": { "type": "string" }, - "endAt": { + "message": { "type": "string" }, - "expiresAt": { - "type": ["string", "null"] - }, - "metadata": { - "type": ["object", "null"], + "details": { + "type": "object", "additionalProperties": {} }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - }, - "required": [ - "id", - "ledgerId", - "resourceId", - "status", - "startAt", - "endAt", - "expiresAt", - "metadata", - "createdAt", - "updatedAt" - ] - }, - "meta": { - "type": "object", - "properties": { - "serverTime": { - "type": "string" + "issues": { + "type": "array", + "items": {} } }, - "required": ["serverTime"] + "required": ["code", "message"] } }, - "required": ["data"] + "required": ["error"] } } } }, - "404": { - "description": "Allocation not found", + "409": { + "description": "Allocation belongs to a booking", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { "type": "string" }, - { + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] } } } - }, - "409": { - "description": "Allocation cannot be confirmed", + } + } + } + }, + "/v1/ledgers/{ledgerId}/services": { + "get": { + "tags": ["Services"], + "summary": "List all services in a ledger", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "List of services", "content": { "application/json": { "schema": { "type": "object", "properties": { - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "resourceIds": { + "type": "array", + "items": { "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} } }, - "required": ["code"] - } - ] + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "name", + "policyId", + "resourceIds", + "metadata", + "createdAt", + "updatedAt" + ] + } } }, - "required": ["error"] + "required": ["data"] } } } } } - } - }, - "/v1/ledgers/{ledgerId}/allocations/{id}/cancel": { + }, "post": { - "tags": ["Allocations"], - "summary": "Cancel an allocation", - "description": "Cancels an allocation that is in HOLD or CONFIRMED status.", + "tags": ["Services"], + "summary": "Create a new service", + "description": "Creates a service that groups resources with an optional scheduling policy.", "parameters": [ { "schema": { @@ -1286,49 +1590,70 @@ "required": true, "name": "ledgerId", "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" } ], - "responses": { - "200": { - "description": "Allocation cancelled", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "ledgerId": { - "type": "string" - }, - "resourceId": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] - }, - "startAt": { - "type": "string" + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Service name", + "example": "Haircut" + }, + "policyId": { + "type": ["string", "null"], + "description": "Policy to enforce on bookings" + }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resources that belong to this service", + "example": ["rsc_01abc123def456ghi789jkl012"] + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + } + }, + "required": ["name"] + } + } + } + }, + "responses": { + "201": { + "description": "Service created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "endAt": { + "ledgerId": { "type": "string" }, - "expiresAt": { + "name": { + "type": "string" + }, + "policyId": { "type": ["string", "null"] }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + } + }, "metadata": { "type": ["object", "null"], "additionalProperties": {} @@ -1343,24 +1668,128 @@ "required": [ "id", "ledgerId", - "resourceId", - "status", - "startAt", - "endAt", - "expiresAt", + "name", + "policyId", + "resourceIds", "metadata", "createdAt", "updatedAt" ] - }, - "meta": { + } + }, + "required": ["data"] + } + } + } + }, + "404": { + "description": "Policy or resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "object", "properties": { - "serverTime": { + "code": { + "type": "string" + }, + "message": { "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } }, - "required": ["serverTime"] + "required": ["code", "message"] + } + }, + "required": ["error"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/services/{id}": { + "get": { + "tags": ["Services"], + "summary": "Get a service by ID", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Service details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "name", + "policyId", + "resourceIds", + "metadata", + "createdAt", + "updatedAt" + ] } }, "required": ["data"] @@ -1369,70 +1798,1973 @@ } }, "404": { - "description": "Allocation not found", + "description": "Service not found", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { "type": "string" }, - { + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] } } } + } + } + }, + "put": { + "tags": ["Services"], + "summary": "Update a service", + "description": "Replaces the full service definition including name, policy, and resource assignments.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" }, - "409": { - "description": "Allocation cannot be cancelled", + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Service name", + "example": "Haircut" + }, + "policyId": { + "type": ["string", "null"], + "description": "Policy to enforce on bookings" + }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resources that belong to this service", + "example": ["rsc_01abc123def456ghi789jkl012"] + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + } + }, + "required": ["name"] + } + } + } + }, + "responses": { + "200": { + "description": "Service updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "name", + "policyId", + "resourceIds", + "metadata", + "createdAt", + "updatedAt" + ] + } + }, + "required": ["data"] + } + } + } + }, + "404": { + "description": "Service, policy, or resource 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"] + } + } + } + } + } + }, + "delete": { + "tags": ["Services"], + "summary": "Delete a service", + "description": "Deletes a service. Fails if the service has bookings in hold or confirmed status.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Service deleted" + }, + "404": { + "description": "Service 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": "Service has active bookings", + "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}/services/{id}/availability/slots": { + "post": { + "tags": ["Service Availability"], + "summary": "Query available booking slots", + "description": "Returns discrete grid-aligned time slots for appointment-style booking. Applies the service's policy (schedule, duration, grid, buffers, lead time) and filters by existing allocations. Pass includeUnavailable: true to get the full grid with available/unavailable 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": { + "startTime": { + "type": "string", + "format": "date-time", + "description": "Start of the query window (ISO 8601)", + "example": "2026-03-02T00:00:00Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "End of the query window (ISO 8601). Max 7 days from startTime.", + "example": "2026-03-07T00:00:00Z" + }, + "durationMs": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Desired booking duration in milliseconds", + "example": 3600000 + }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter to specific resources. Defaults to all resources in the service." + }, + "includeUnavailable": { + "type": "boolean", + "description": "Return all grid positions with status. Defaults to false." + } + }, + "required": ["startTime", "endTime", "durationMs"] + } + } + } + }, + "responses": { + "200": { + "description": "Available slots per resource", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "slots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startTime", "endTime"] + } + } + }, + "required": ["resourceId", "timezone", "slots"] + } + }, + "meta": { + "type": "object", + "properties": { + "serverTime": { + "type": "string" + } + }, + "required": ["serverTime"] + } + }, + "required": ["data", "meta"] + } + } + } + }, + "404": { + "description": "Service 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 (range too large, invalid resourceIds, etc.)", + "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}/services/{id}/availability/windows": { + "post": { + "tags": ["Service Availability"], + "summary": "Query available time windows", + "description": "Returns continuous available time ranges for rental-style booking. Applies the service's policy and subtracts existing allocations (with buffer-aware gap shrinkage). Pass includeUnavailable: true to get the full schedule with available/unavailable 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": { + "startTime": { + "type": "string", + "format": "date-time", + "description": "Start of the query window (ISO 8601)", + "example": "2026-03-02T00:00:00Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "End of the query window (ISO 8601). Max 31 days from startTime.", + "example": "2026-03-07T00:00:00Z" + }, + "resourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter to specific resources. Defaults to all resources in the service." + }, + "includeUnavailable": { + "type": "boolean", + "description": "Return full schedule with status. Defaults to false." + } + }, + "required": ["startTime", "endTime"] + } + } + } + }, + "responses": { + "200": { + "description": "Available windows per resource", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startTime": { + "type": "string" + }, + "endTime": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startTime", "endTime"] + } + } + }, + "required": ["resourceId", "timezone", "windows"] + } + }, + "meta": { + "type": "object", + "properties": { + "serverTime": { + "type": "string" + } + }, + "required": ["serverTime"] + } + }, + "required": ["data", "meta"] + } + } + } + }, + "404": { + "description": "Service 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 (range too large, invalid resourceIds, etc.)", + "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": { + "get": { + "tags": ["Bookings"], + "summary": "List all bookings in a ledger", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "List of bookings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "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", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyId", + "status", + "expiresAt", + "allocations", + "metadata", + "createdAt", + "updatedAt" + ] + } + } + }, + "required": ["data"] + } + } + } + } + } + }, + "post": { + "tags": ["Bookings"], + "summary": "Create a new booking", + "description": "Creates a booking for a service. Evaluates the service's policy, checks for conflicts, and creates the underlying allocation. When the policy defines buffers, the allocation's startTime/endTime represent the buffer-expanded blocked window. The original customer time can be derived using buffer.beforeMs and buffer.afterMs on the allocation. Supports idempotency via the Idempotency-Key header.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "example": "svc_01abc123def456ghi789jkl012" + }, + "resourceId": { + "type": "string", + "example": "rsc_01abc123def456ghi789jkl012" + }, + "startTime": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T10:00:00Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + }, + "status": { + "type": "string", + "enum": ["hold", "confirmed"], + "default": "hold", + "description": "Initial status. Hold creates a temporary reservation." + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + } + }, + "required": ["serviceId", "resourceId", "startTime", "endTime"] + } + } + } + }, + "responses": { + "201": { + "description": "Booking created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "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", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyId", + "status", + "expiresAt", + "allocations", + "metadata", + "createdAt", + "updatedAt" + ] + }, + "meta": { + "type": "object", + "properties": { + "serverTime": { + "type": "string" + } + }, + "required": ["serverTime"] + } + }, + "required": ["data"] + } + } + } + }, + "404": { + "description": "Service or resource 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, or resource not in service)", + "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}": { + "get": { + "tags": ["Bookings"], + "summary": "Get a booking by ID", + "description": "Returns the booking with its nested allocations.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Booking details with allocations", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "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", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyId", + "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"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/bookings/{id}/confirm": { + "post": { + "tags": ["Bookings"], + "summary": "Confirm a held booking", + "description": "Confirms a booking that is in hold status. Idempotent — confirming an already confirmed booking returns success. 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" + } + ], + "responses": { + "200": { + "description": "Booking confirmed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "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", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyId", + "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": "Booking cannot be confirmed (expired 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"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/bookings/{id}/cancel": { + "post": { + "tags": ["Bookings"], + "summary": "Cancel a booking", + "description": "Cancels a booking in hold or confirmed status. Idempotent — canceling an already canceled booking returns success. 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" + } + ], + "responses": { + "200": { + "description": "Booking canceled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { + "type": "string" + }, + "policyId": { + "type": ["string", "null"] + }, + "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", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "policyId", + "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": "Booking cannot be canceled (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"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/webhooks": { + "get": { + "tags": ["Webhooks"], + "summary": "List webhook subscriptions", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "List of webhook subscriptions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "url": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] + } + } + }, + "required": ["data"] + } + } + } + } + } + }, + "post": { + "tags": ["Webhooks"], + "summary": "Create a webhook subscription", + "description": "Creates a new webhook subscription. The secret is only returned once at creation time.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "example": "https://example.com/webhook" + } + }, + "required": ["url"] + } + } + } + }, + "responses": { + "201": { + "description": "Webhook subscription created (includes secret)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "url": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "secret": { + "type": "string" + } + }, + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] + } + }, + "required": ["data"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/webhooks/{id}": { + "patch": { + "tags": ["Webhooks"], + "summary": "Update a webhook subscription", + "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": { + "url": { + "type": "string", + "format": "uri" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Webhook subscription updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "url": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] + } + }, + "required": ["data"] + } + } + } + }, + "404": { + "description": "Webhook subscription 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"] + } + } + } + } + } + }, + "delete": { + "tags": ["Webhooks"], + "summary": "Delete a webhook subscription", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Webhook subscription deleted" + }, + "404": { + "description": "Webhook subscription 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"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/webhooks/{id}/rotate-secret": { + "post": { + "tags": ["Webhooks"], + "summary": "Rotate webhook secret", + "description": "Generates a new secret for the webhook subscription. The old secret is invalidated immediately.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "New secret generated (includes secret)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "url": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "secret": { + "type": "string" + } + }, + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] + } + }, + "required": ["data"] + } + } + } + }, + "404": { + "description": "Webhook subscription not found", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { "type": "string" }, - { + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -1443,10 +3775,10 @@ } } }, - "/v1/ledgers/{ledgerId}/webhooks": { + "/v1/ledgers/{ledgerId}/policies": { "get": { - "tags": ["Webhooks"], - "summary": "List webhook subscriptions", + "tags": ["Policies"], + "summary": "List policies for a ledger", "parameters": [ { "schema": { @@ -1459,7 +3791,7 @@ ], "responses": { "200": { - "description": "List of webhook subscriptions", + "description": "List of policies", "content": { "application/json": { "schema": { @@ -1476,7 +3808,11 @@ "ledgerId": { "type": "string" }, - "url": { + "config": { + "type": "object", + "additionalProperties": {} + }, + "configHash": { "type": "string" }, "createdAt": { @@ -1486,7 +3822,14 @@ "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "config", + "configHash", + "createdAt", + "updatedAt" + ] } } }, @@ -1498,9 +3841,9 @@ } }, "post": { - "tags": ["Webhooks"], - "summary": "Create a webhook subscription", - "description": "Creates a new webhook subscription. The secret is only returned once at creation time.", + "tags": ["Policies"], + "summary": "Create a policy", + "description": "Creates a new scheduling policy for the ledger. The config is provided in authoring format (friendly units like minutes/hours) and stored in canonical format (milliseconds).", "parameters": [ { "schema": { @@ -1517,20 +3860,44 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri", - "example": "https://example.com/webhook" + "config": { + "type": "object", + "properties": { + "schema_version": { + "type": "number", + "enum": [1] + }, + "default": { + "type": "string", + "enum": ["open", "closed"] + }, + "config": { + "type": "object", + "properties": {}, + "additionalProperties": {} + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "additionalProperties": {} + } + } + }, + "required": ["schema_version", "default", "config"], + "additionalProperties": {}, + "description": "Policy configuration in authoring format" } }, - "required": ["url"] + "required": ["config"] } } } }, "responses": { "201": { - "description": "Webhook subscription created (includes secret)", + "description": "Policy created (may include warnings)", "content": { "application/json": { "schema": { @@ -1545,7 +3912,11 @@ "ledgerId": { "type": "string" }, - "url": { + "config": { + "type": "object", + "additionalProperties": {} + }, + "configHash": { "type": "string" }, "createdAt": { @@ -1553,12 +3924,16 @@ }, "updatedAt": { "type": "string" - }, - "secret": { - "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] + "required": [ + "id", + "ledgerId", + "config", + "configHash", + "createdAt", + "updatedAt" + ] } }, "required": ["data"] @@ -1569,10 +3944,10 @@ } } }, - "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}": { - "patch": { - "tags": ["Webhooks"], - "summary": "Update a webhook subscription", + "/v1/ledgers/{ledgerId}/policies/{id}": { + "get": { + "tags": ["Policies"], + "summary": "Get a policy by ID", "parameters": [ { "schema": { @@ -1587,28 +3962,13 @@ "type": "string" }, "required": true, - "name": "subscriptionId", + "name": "id", "in": "path" } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - } - } - } - } - } - }, "responses": { "200": { - "description": "Webhook subscription updated", + "description": "Policy details", "content": { "application/json": { "schema": { @@ -1623,7 +3983,11 @@ "ledgerId": { "type": "string" }, - "url": { + "config": { + "type": "object", + "additionalProperties": {} + }, + "configHash": { "type": "string" }, "createdAt": { @@ -1633,7 +3997,14 @@ "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "config", + "configHash", + "createdAt", + "updatedAt" + ] } }, "required": ["data"] @@ -1642,34 +4013,31 @@ } }, "404": { - "description": "Webhook subscription not found", + "description": "Policy not found", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { "type": "string" }, - { + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -1679,9 +4047,10 @@ } } }, - "delete": { - "tags": ["Webhooks"], - "summary": "Delete a webhook subscription", + "put": { + "tags": ["Policies"], + "summary": "Update a policy", + "description": "Replaces the full policy configuration. The config is re-normalized, re-validated, and re-hashed.", "parameters": [ { "schema": { @@ -1696,43 +4065,123 @@ "type": "string" }, "required": true, - "name": "subscriptionId", + "name": "id", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "schema_version": { + "type": "number", + "enum": [1] + }, + "default": { + "type": "string", + "enum": ["open", "closed"] + }, + "config": { + "type": "object", + "properties": {}, + "additionalProperties": {} + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "additionalProperties": {} + } + } + }, + "required": ["schema_version", "default", "config"], + "additionalProperties": {}, + "description": "Policy configuration in authoring format" + } + }, + "required": ["config"] + } + } + } + }, "responses": { - "204": { - "description": "Webhook subscription deleted" + "200": { + "description": "Policy updated (may include warnings)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "configHash": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "config", + "configHash", + "createdAt", + "updatedAt" + ] + } + }, + "required": ["data"] + } + } + } }, "404": { - "description": "Webhook subscription not found", + "description": "Policy not found", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { - "anyOf": [ - { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { "type": "string" }, - { + "details": { "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} } - ] + }, + "required": ["code", "message"] } }, "required": ["error"] @@ -1741,13 +4190,10 @@ } } } - } - }, - "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}/rotate-secret": { - "post": { - "tags": ["Webhooks"], - "summary": "Rotate webhook secret", - "description": "Generates a new secret for the webhook subscription. The old secret is invalidated immediately.", + }, + "delete": { + "tags": ["Policies"], + "summary": "Delete a policy", "parameters": [ { "schema": { @@ -1762,77 +4208,40 @@ "type": "string" }, "required": true, - "name": "subscriptionId", + "name": "id", "in": "path" } ], "responses": { - "200": { - "description": "New secret generated (includes secret)", + "204": { + "description": "Policy deleted" + }, + "404": { + "description": "Policy not found", "content": { "application/json": { "schema": { "type": "object", "properties": { - "data": { + "error": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "ledgerId": { - "type": "string" - }, - "url": { + "code": { "type": "string" }, - "createdAt": { + "message": { "type": "string" }, - "updatedAt": { - "type": "string" + "details": { + "type": "object", + "additionalProperties": {} }, - "secret": { - "type": "string" + "issues": { + "type": "array", + "items": {} } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] - } - }, - "required": ["data"] - } - } - } - }, - "404": { - "description": "Webhook subscription not found", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": ["code"] - } - ] + "required": ["code", "message"] } }, "required": ["error"] diff --git a/apps/server/package.json b/apps/server/package.json index c342791..f2fe727 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,6 +7,8 @@ "start": "tsx src/index.ts", "dev": "tsx watch -r dotenv/config src/index.ts", "test": "vitest", + "test:unit": "vitest --project unit", + "test:integration": "vitest --project integration", "migrate": "tsx -r dotenv/config src/scripts/migrate.ts", "migrate:down": "tsx -r dotenv/config src/scripts/migrate.ts down", "migrate:redo": "tsx -r dotenv/config src/scripts/migrate.ts redo", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 965aff3..31d08bc 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -18,15 +18,39 @@ app.route("/", routes); app.onError((err, c) => { if (err instanceof InputError) { - return c.json({ error: err.message }, 422); + return c.json( + { error: { code: "invalid_input", message: err.message, issues: err.issues } }, + 422, + ); } if (err instanceof NotFoundError) { - return c.json({ error: err.message }, 404); + const details: Record = {}; + if (err.resourceType) details["resourceType"] = err.resourceType; + if (err.resourceId) details["resourceId"] = err.resourceId; + return c.json( + { + error: { + code: "not_found", + message: err.message, + ...(Object.keys(details).length > 0 ? { details } : {}), + }, + }, + 404, + ); } if (err instanceof ConflictError) { - return c.json({ error: { code: err.reasonCode, details: err.details } }, 409); + return c.json( + { + error: { + code: err.reasonCode, + message: err.message, + ...(err.details ? { details: err.details } : {}), + }, + }, + 409, + ); } if (err instanceof IdempotencyError) { @@ -38,13 +62,14 @@ app.onError((err, c) => { } if (err.name === "SyntaxError") { - return c.json({ error: "Invalid JSON" }, 400); + return c.json({ error: { code: "invalid_json", message: "Invalid JSON" } }, 400); } + logger.error(err); + if (config.NODE_ENV === "production") { - return c.json({ message: "Internal server error" }, 500); + return c.json({ error: { code: "internal_error", message: "Internal server error" } }, 500); } else { - logger.error(err); return c.json([err, err.stack], 500); } }); diff --git a/apps/server/src/database/client.ts b/apps/server/src/database/client.ts index 8dc21eb..380b3b2 100644 --- a/apps/server/src/database/client.ts +++ b/apps/server/src/database/client.ts @@ -5,4 +5,7 @@ import { config } from "config"; const pool = new Pool({ connectionString: config.DATABASE_URL }); const dialect = new PostgresDialect({ pool }); -export const db = new Kysely({ dialect, plugins: [new CamelCasePlugin()] }); +export const db = new Kysely({ + dialect, + plugins: [new CamelCasePlugin({ maintainNestedObjectKeys: true })], +}); diff --git a/apps/server/src/database/index.ts b/apps/server/src/database/index.ts index 0f91441..0fb9de1 100644 --- a/apps/server/src/database/index.ts +++ b/apps/server/src/database/index.ts @@ -1,3 +1,4 @@ import { db } from "./client"; export { db }; +export { getServerTime } from "./server-time"; diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index 643dfec..91cd242 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -1,6 +1,6 @@ import type { Generated, Insertable, Selectable, Updateable } from "kysely"; -import { - AllocationStatus, +import type { + BookingStatus, IdempotencyStatus, WebhookDeliveryStatus, } from "@floyd-run/schema/types"; @@ -14,6 +14,7 @@ export interface LedgersTable { export interface ResourcesTable { id: string; ledgerId: string; + timezone: string; createdAt: Generated; updatedAt: Generated; } @@ -22,9 +23,39 @@ export interface AllocationsTable { id: string; ledgerId: string; resourceId: string; - status: AllocationStatus; - startAt: Date; - endAt: Date; + bookingId: string | null; + active: boolean; + startTime: Date; + endTime: Date; + bufferBeforeMs: number; + bufferAfterMs: number; + expiresAt: Date | null; + metadata: Record | null; + createdAt: Generated; + updatedAt: Generated; +} + +export interface ServicesTable { + id: string; + ledgerId: string; + policyId: string | null; + name: string; + metadata: Record | null; + createdAt: Generated; + updatedAt: Generated; +} + +export interface ServiceResourcesTable { + serviceId: string; + resourceId: string; +} + +export interface BookingsTable { + id: string; + ledgerId: string; + serviceId: string; + policyId: string | null; + status: BookingStatus; expiresAt: Date | null; metadata: Record | null; createdAt: Generated; @@ -67,11 +98,24 @@ export interface WebhookDeliveriesTable { createdAt: Generated; } +export interface PoliciesTable { + id: string; + ledgerId: string; + config: Record; + configHash: string; + createdAt: Generated; + updatedAt: Generated; +} + export interface Database { allocations: AllocationsTable; + bookings: BookingsTable; + services: ServicesTable; + serviceResources: ServiceResourcesTable; idempotencyKeys: IdempotencyKeysTable; resources: ResourcesTable; ledgers: LedgersTable; + policies: PoliciesTable; webhookSubscriptions: WebhookSubscriptionsTable; webhookDeliveries: WebhookDeliveriesTable; } @@ -88,6 +132,16 @@ export type AllocationRow = Selectable; export type NewAllocation = Insertable; export type AllocationUpdate = Updateable; +export type ServiceRow = Selectable; +export type NewService = Insertable; +export type ServiceUpdate = Updateable; + +export type ServiceResourceRow = Selectable; + +export type BookingRow = Selectable; +export type NewBooking = Insertable; +export type BookingUpdate = Updateable; + export type IdempotencyKeyRow = Selectable; export type NewIdempotencyKey = Insertable; @@ -97,3 +151,7 @@ export type WebhookSubscriptionUpdate = Updateable; export type WebhookDeliveryRow = Selectable; export type NewWebhookDelivery = Insertable; + +export type PolicyRow = Selectable; +export type NewPolicy = Insertable; +export type PolicyUpdate = Updateable; diff --git a/apps/server/src/database/server-time.ts b/apps/server/src/database/server-time.ts new file mode 100644 index 0000000..f5cbc37 --- /dev/null +++ b/apps/server/src/database/server-time.ts @@ -0,0 +1,9 @@ +import { sql, type Kysely } from "kysely"; +import type { Database } from "./schema"; + +export async function getServerTime(trx: Kysely): Promise { + const result = await sql<{ + serverTime: Date; + }>`SELECT clock_timestamp() AS server_time`.execute(trx); + return result.rows[0]!.serverTime; +} diff --git a/apps/server/src/domain/policy/canonicalize.ts b/apps/server/src/domain/policy/canonicalize.ts new file mode 100644 index 0000000..a5df252 --- /dev/null +++ b/apps/server/src/domain/policy/canonicalize.ts @@ -0,0 +1,111 @@ +/** + * Canonicalizes a policy config for deterministic hashing. + * + * Rules: + * - Sort object keys alphabetically at all nesting levels + * - Preserve `rules` array order (semantic — first-match-wins) + * - Sort `days` in canonical day order (monday → sunday) + * - Sort `windows` by (start, end) ascending + * - Sort `allowed_ms` ascending and deduplicate + * - No extra whitespace + */ + +import { createHash } from "crypto"; + +const CANONICAL_DAY_ORDER: Record = { + monday: 0, + tuesday: 1, + wednesday: 2, + thursday: 3, + friday: 4, + saturday: 5, + sunday: 6, +}; + +function sortDays(days: string[]): string[] { + return [...days].sort((a, b) => (CANONICAL_DAY_ORDER[a] ?? 99) - (CANONICAL_DAY_ORDER[b] ?? 99)); +} + +function sortWindows(windows: { start: string; end: string }[]): typeof windows { + return [...windows].sort((a, b) => { + const cmp = a.start.localeCompare(b.start); + if (cmp !== 0) return cmp; + return a.end.localeCompare(b.end); + }); +} + +function sortAllowedMs(arr: number[]): number[] { + return [...new Set(arr)].sort((a, b) => a - b); +} + +function canonicalizeValue(key: string, value: unknown): unknown { + if (value === null || value === undefined) return value; + + if (Array.isArray(value)) { + // Special sorting for specific array keys + if (key === "days") { + return sortDays(value as string[]); + } + if (key === "windows") { + return sortWindows(value as { start: string; end: string }[]).map((w) => + canonicalizeObject(w), + ); + } + if (key === "allowed_ms") { + return sortAllowedMs(value as number[]); + } + if (key === "rules") { + // Preserve order! Rules are semantic (first-match-wins) + return value.map((item): unknown => { + if (typeof item === "object" && item !== null) { + return canonicalizeObject(item as Record); + } + return item as unknown; + }); + } + return value.map((item): unknown => { + if (typeof item === "object" && item !== null) { + return canonicalizeObject(item as Record); + } + return item as unknown; + }); + } + + if (typeof value === "object") { + return canonicalizeObject(value as Record); + } + + return value; +} + +function canonicalizeObject(obj: Record): Record { + const sorted: Record = {}; + const keys = Object.keys(obj).sort(); + + for (const key of keys) { + const value = obj[key]; + if (value !== undefined) { + sorted[key] = canonicalizeValue(key, value); + } + } + + return sorted; +} + +/** + * Returns the canonical JSON string of a policy config. + * Two configs that differ only in key order, days order, windows order, + * or whitespace produce the same canonical string. + * Two configs with different rules order produce different strings. + */ +export function canonicalizePolicyConfig(config: Record): string { + const canonical = canonicalizeObject(config); + return JSON.stringify(canonical); +} + +/** + * Returns the SHA-256 hex digest of a canonical JSON string. + */ +export function hashPolicyConfig(canonicalJson: string): string { + return createHash("sha256").update(canonicalJson).digest("hex"); +} diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts new file mode 100644 index 0000000..807c0c2 --- /dev/null +++ b/apps/server/src/domain/policy/evaluate.ts @@ -0,0 +1,488 @@ +/** + * Pure policy evaluator. + * + * Takes a canonical policy config, a booking request, and evaluation context. + * Returns whether the booking is allowed and the resolved config + effective windows. + * + * Conflict detection is NOT here — it stays in the allocation service. + */ + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export const REASON_CODES = { + BLACKOUT_WINDOW: "policy.blackout", + CLOSED_BY_SCHEDULE: "policy.closed", + OVERNIGHT_NOT_SUPPORTED: "policy.overnight_not_supported", + INVALID_DURATION: "policy.invalid_duration", + MISALIGNED_START_TIME: "policy.misaligned_start", + LEAD_TIME_VIOLATION: "policy.lead_time_violation", + HORIZON_EXCEEDED: "policy.horizon_exceeded", + POLICY_INVALID_CONFIG: "policy.invalid_config", + POLICY_EVAL_ERROR: "policy.eval_error", +} as const; + +export type ReasonCode = (typeof REASON_CODES)[keyof typeof REASON_CODES]; + +export interface EvaluationInput { + startTime: Date; + endTime: Date; +} + +export interface EvaluationContext { + decisionTime: Date; + timezone: string; + skipPolicy?: boolean; +} + +export interface DurationConfig { + min_ms?: number; + max_ms?: number; + allowed_ms?: number[]; +} + +export interface GridConfig { + interval_ms: number; +} + +export interface LeadTimeConfig { + min_ms?: number; + max_ms?: number; +} + +export interface BuffersConfig { + before_ms?: number; + after_ms?: number; +} + +export interface HoldConfig { + duration_ms?: number; +} + +export interface ResolvedConfig { + duration?: DurationConfig | undefined; + grid?: GridConfig | undefined; + lead_time?: LeadTimeConfig | undefined; + buffers?: BuffersConfig | undefined; + hold?: HoldConfig | undefined; +} + +interface TimeWindow { + start: string; + end: string; +} + +interface RuleMatch { + type: "weekly" | "date" | "date_range"; + days?: string[]; + date?: string; + from?: string; + to?: string; +} + +interface Rule { + match: RuleMatch; + closed?: true; + windows?: TimeWindow[]; + config?: Record; +} + +export interface PolicyConfig { + schema_version: number; + default: "open" | "closed"; + config: Record; + rules?: Rule[]; + metadata?: Record; +} + +export type EvaluationResult = + | { + allowed: true; + resolvedConfig: ResolvedConfig; + effectiveStartTime: Date; + effectiveEndTime: Date; + bufferBeforeMs: number; + bufferAfterMs: number; + } + | { + allowed: false; + code: ReasonCode; + message: string; + details?: Record; + }; + +// ─── Timezone Helpers ──────────────────────────────────────────────────────── + +const DAY_NAMES = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +] as const; + +/** + * Get the local date string (YYYY-MM-DD) for an instant in a timezone. + */ +export function toLocalDate(instant: Date, timezone: string): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(instant); + + const year = parts.find((p) => p.type === "year")!.value; + const month = parts.find((p) => p.type === "month")!.value; + const day = parts.find((p) => p.type === "day")!.value; + return `${year}-${month}-${day}`; +} + +/** + * Get milliseconds since local midnight for an instant in a timezone. + */ +export function msSinceLocalMidnight(instant: Date, timezone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour: "numeric", + minute: "numeric", + second: "numeric", + fractionalSecondDigits: 3, + hour12: false, + }).formatToParts(instant); + + const hour = parseInt(parts.find((p) => p.type === "hour")!.value); + const minute = parseInt(parts.find((p) => p.type === "minute")!.value); + const second = parseInt(parts.find((p) => p.type === "second")!.value); + const fraction = parts.find((p) => p.type === "fractionalSecond"); + const ms = fraction ? parseInt(fraction.value) : 0; + + return hour * 3_600_000 + minute * 60_000 + second * 1_000 + ms; +} + +/** + * Get the day of week for a date string (YYYY-MM-DD). + */ +export function getDayOfWeek( + dateStr: string, +): "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" { + // Parse as UTC to avoid timezone issues with the date itself + const [year, month, day] = dateStr.split("-").map(Number); + const d = new Date(Date.UTC(year!, month! - 1, day)); + return DAY_NAMES[d.getUTCDay()]!; +} + +/** + * Generate an inclusive date range (YYYY-MM-DD strings). + */ +export function dateRange(from: string, to: string): string[] { + const dates: string[] = []; + const [fy, fm, fd] = from.split("-").map(Number); + const [ty, tm, td] = to.split("-").map(Number); + const current = new Date(Date.UTC(fy!, fm! - 1, fd)); + const end = new Date(Date.UTC(ty!, tm! - 1, td)); + + while (current <= end) { + dates.push( + `${current.getUTCFullYear()}-${String(current.getUTCMonth() + 1).padStart(2, "0")}-${String(current.getUTCDate()).padStart(2, "0")}`, + ); + current.setUTCDate(current.getUTCDate() + 1); + } + + return dates; +} + +/** + * Convert time string (HH:MM) to milliseconds since midnight. + */ +export function timeToMs(time: string): number { + if (time === "24:00") return 1440 * 60_000; + const [h, m] = time.split(":").map(Number); + return h! * 3_600_000 + m! * 60_000; +} + +// ─── Match Logic ───────────────────────────────────────────────────────────── + +export function matchesCondition(match: RuleMatch, dateStr: string, dayOfWeek: string): boolean { + switch (match.type) { + case "weekly": + return match.days?.includes(dayOfWeek) ?? false; + + case "date": + return dateStr === match.date; + + case "date_range": { + if (dateStr < match.from! || dateStr > match.to!) return false; + if (match.days && match.days.length > 0) { + return match.days.includes(dayOfWeek); + } + return true; + } + + default: + return false; + } +} + +// ─── Evaluator ─────────────────────────────────────────────────────────────── + +export function evaluatePolicy( + policy: PolicyConfig, + request: EvaluationInput, + context: EvaluationContext, +): EvaluationResult { + try { + // Validate schema_version + if (policy.schema_version !== 1) { + return { + allowed: false, + code: REASON_CODES.POLICY_INVALID_CONFIG, + message: "Unsupported schema version", + details: { reason: "unsupported_schema_version" }, + }; + } + + const rules = policy.rules ?? []; + + // Computed values + const localStartDate = toLocalDate(request.startTime, context.timezone); + const localEndDate = toLocalDate(request.endTime, context.timezone); + const localStartMs = msSinceLocalMidnight(request.startTime, context.timezone); + const localEndMs = msSinceLocalMidnight(request.endTime, context.timezone); + const dayOfWeek = getDayOfWeek(localStartDate); + const durationMs = request.endTime.getTime() - request.startTime.getTime(); + + // Midnight normalization + let spansMultipleDays = localEndDate !== localStartDate; + let effectiveEndMinutesMs: number; + + if (spansMultipleDays && localEndMs === 0) { + // End is exactly midnight next day → treat as same-day ending at 24:00 + spansMultipleDays = false; + effectiveEndMinutesMs = 1440 * 60_000; + } else { + effectiveEndMinutesMs = localEndMs; + } + + // Admin override: skip Steps 1-8 + if (context.skipPolicy) { + const bufferBeforeMs = + (policy.config["buffers"] as BuffersConfig | undefined)?.before_ms ?? 0; + const bufferAfterMs = (policy.config["buffers"] as BuffersConfig | undefined)?.after_ms ?? 0; + + return { + allowed: true, + resolvedConfig: policy.config as unknown as ResolvedConfig, + effectiveStartTime: new Date(request.startTime.getTime() - bufferBeforeMs), + effectiveEndTime: new Date(request.endTime.getTime() + bufferAfterMs), + bufferBeforeMs, + bufferAfterMs, + }; + } + + // ─── Step 1: Blackout pre-pass ───────────────────────────────────────── + + // Compute all local dates the booking overlaps (half-open: end exclusive) + const overlapEndInstant = new Date(request.endTime.getTime() - 1); + const lastOverlapDate = toLocalDate(overlapEndInstant, context.timezone); + + const overlapDates = dateRange(localStartDate, lastOverlapDate); + + for (const date of overlapDates) { + const dow = getDayOfWeek(date); + for (const rule of rules) { + if (rule.closed !== true) continue; + if (matchesCondition(rule.match, date, dow)) { + return { + allowed: false, + code: REASON_CODES.BLACKOUT_WINDOW, + message: `Date ${date} is closed`, + details: { date }, + }; + } + } + } + + // ─── Step 2: Rule resolution (first-match-wins, start date only) ─────── + + let matchedRule: Rule | null = null; + for (const rule of rules) { + if (rule.closed === true) continue; // Already handled in Step 1 + if (matchesCondition(rule.match, localStartDate, dayOfWeek)) { + matchedRule = rule; + break; + } + } + + // ─── Step 3: Open/closed determination ───────────────────────────────── + + if (matchedRule) { + if (matchedRule.windows && matchedRule.windows.length > 0) { + // Overnight rejection for windowed rules + if (spansMultipleDays) { + return { + allowed: false, + code: REASON_CODES.OVERNIGHT_NOT_SUPPORTED, + message: "Booking spans midnight but matched rule has windows", + }; + } + + // Check if booking fits within any window + const fits = matchedRule.windows.some((w) => { + const windowStartMs = timeToMs(w.start); + const windowEndMs = timeToMs(w.end); + return localStartMs >= windowStartMs && effectiveEndMinutesMs <= windowEndMs; + }); + + if (!fits) { + return { + allowed: false, + code: REASON_CODES.CLOSED_BY_SCHEDULE, + message: "Booking does not fit within any schedule window", + }; + } + } + // No windows on matched rule → day is open 24h + } else { + // No rule matched → use default + if (policy.default === "closed") { + return { + allowed: false, + code: REASON_CODES.CLOSED_BY_SCHEDULE, + message: "No matching rule and default is closed", + }; + } + // default: "open" → proceed + } + + // ─── Step 4: Config resolution ───────────────────────────────────────── + + const baseConfig = policy.config; + const ruleConfig = matchedRule?.config ?? {}; + const resolvedRaw = { ...baseConfig, ...ruleConfig }; + const resolved = { + duration: resolvedRaw["duration"] as DurationConfig | undefined, + grid: resolvedRaw["grid"] as GridConfig | undefined, + lead_time: resolvedRaw["lead_time"] as LeadTimeConfig | undefined, + buffers: resolvedRaw["buffers"] as BuffersConfig | undefined, + hold: resolvedRaw["hold"] as HoldConfig | undefined, + }; + + // ─── Step 5: Duration check ──────────────────────────────────────────── + + if (resolved.duration) { + if (durationMs <= 0) { + return { + allowed: false, + code: REASON_CODES.INVALID_DURATION, + message: "Duration must be positive", + }; + } + + if (resolved.duration.allowed_ms && resolved.duration.allowed_ms.length > 0) { + // allowed_ms takes precedence — only these durations are valid + if (!resolved.duration.allowed_ms.includes(durationMs)) { + return { + allowed: false, + code: REASON_CODES.INVALID_DURATION, + message: `Duration ${durationMs}ms is not in allowed list`, + details: { durationMs, allowed_ms: resolved.duration.allowed_ms }, + }; + } + } else { + // Fall back to min/max + if (resolved.duration.min_ms !== undefined && durationMs < resolved.duration.min_ms) { + return { + allowed: false, + code: REASON_CODES.INVALID_DURATION, + message: `Duration ${durationMs}ms is below minimum ${resolved.duration.min_ms}ms`, + details: { durationMs, min_ms: resolved.duration.min_ms }, + }; + } + if (resolved.duration.max_ms !== undefined && durationMs > resolved.duration.max_ms) { + return { + allowed: false, + code: REASON_CODES.INVALID_DURATION, + message: `Duration ${durationMs}ms exceeds maximum ${resolved.duration.max_ms}ms`, + details: { durationMs, max_ms: resolved.duration.max_ms }, + }; + } + } + } + + // ─── Step 6: Grid alignment ──────────────────────────────────────────── + + if (resolved.grid) { + const msSinceMidnight = msSinceLocalMidnight(request.startTime, context.timezone); + if (msSinceMidnight % resolved.grid.interval_ms !== 0) { + return { + allowed: false, + code: REASON_CODES.MISALIGNED_START_TIME, + message: `Start time is not aligned to ${resolved.grid.interval_ms}ms grid`, + details: { + msSinceMidnight, + interval_ms: resolved.grid.interval_ms, + remainder: msSinceMidnight % resolved.grid.interval_ms, + }, + }; + } + } + + // ─── Step 7: Lead time ───────────────────────────────────────────────── + + if (resolved.lead_time?.min_ms !== undefined) { + const leadTime = request.startTime.getTime() - context.decisionTime.getTime(); + if (leadTime < resolved.lead_time.min_ms) { + return { + allowed: false, + code: REASON_CODES.LEAD_TIME_VIOLATION, + message: "Booking too close to current time", + details: { + leadTimeMs: leadTime, + min_ms: resolved.lead_time.min_ms, + }, + }; + } + } + + // ─── Step 8: Horizon ─────────────────────────────────────────────────── + + if (resolved.lead_time?.max_ms !== undefined) { + const leadTime = request.startTime.getTime() - context.decisionTime.getTime(); + if (leadTime > resolved.lead_time.max_ms) { + return { + allowed: false, + code: REASON_CODES.HORIZON_EXCEEDED, + message: "Booking too far in the future", + details: { + leadTimeMs: leadTime, + max_ms: resolved.lead_time.max_ms, + }, + }; + } + } + + // ─── Step 9: Buffer computation (never rejects) ──────────────────────── + + const bufferBeforeMs = resolved.buffers?.before_ms ?? 0; + const bufferAfterMs = resolved.buffers?.after_ms ?? 0; + const effectiveStartTime = new Date(request.startTime.getTime() - bufferBeforeMs); + const effectiveEndTime = new Date(request.endTime.getTime() + bufferAfterMs); + + return { + allowed: true, + resolvedConfig: resolved, + effectiveStartTime, + effectiveEndTime, + bufferBeforeMs, + bufferAfterMs, + }; + } catch (error) { + return { + allowed: false, + code: REASON_CODES.POLICY_EVAL_ERROR, + message: error instanceof Error ? error.message : "Unexpected evaluation error", + }; + } +} diff --git a/apps/server/src/domain/policy/index.ts b/apps/server/src/domain/policy/index.ts new file mode 100644 index 0000000..adb09c3 --- /dev/null +++ b/apps/server/src/domain/policy/index.ts @@ -0,0 +1,20 @@ +export { normalizePolicyConfig } from "./normalize"; +export { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; +export { validatePolicyConfig, type PolicyWarning } from "./validate"; +export { preparePolicyConfig } from "./prepare"; +export { + evaluatePolicy, + toLocalDate, + msSinceLocalMidnight, + getDayOfWeek, + dateRange, + timeToMs, + matchesCondition, + REASON_CODES, + type PolicyConfig, + type EvaluationInput, + type EvaluationContext, + type EvaluationResult, + type ResolvedConfig, + type ReasonCode, +} from "./evaluate"; diff --git a/apps/server/src/domain/policy/normalize.ts b/apps/server/src/domain/policy/normalize.ts new file mode 100644 index 0000000..57bb80d --- /dev/null +++ b/apps/server/src/domain/policy/normalize.ts @@ -0,0 +1,151 @@ +/** + * Normalizes a policy config from authoring format (human-friendly units) + * to canonical format (all durations in milliseconds, explicit day names). + */ + +const CANONICAL_DAY_ORDER = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +] as const; + +const DAY_SHORTHANDS: Record = { + weekdays: ["monday", "tuesday", "wednesday", "thursday", "friday"], + weekends: ["saturday", "sunday"], + everyday: CANONICAL_DAY_ORDER, +}; + +const UNIT_MULTIPLIERS: Record = { + _minutes: 60_000, + _hours: 3_600_000, + _days: 86_400_000, +}; + +/** + * Converts friendly duration fields to _ms equivalents within an object. + * If both _ms and a friendly unit exist for the same field, _ms takes precedence. + */ +function normalizeDurations(obj: Record): Record { + const result: Record = {}; + const msKeys = new Set(); + + // First pass: collect all _ms keys and copy them + for (const [key, value] of Object.entries(obj)) { + if (key.endsWith("_ms")) { + result[key] = value; + msKeys.add(key.slice(0, -3)); + } + } + + // Second pass: convert friendly units, skip if _ms already present + for (const [key, value] of Object.entries(obj)) { + if (key.endsWith("_ms")) continue; + + let converted = false; + for (const [suffix, multiplier] of Object.entries(UNIT_MULTIPLIERS)) { + if (key.endsWith(suffix)) { + const fieldPrefix = key.slice(0, -suffix.length); + const msKey = `${fieldPrefix}_ms`; + + if (!msKeys.has(fieldPrefix)) { + if (typeof value === "number") { + result[msKey] = Math.round(value * multiplier); + } else if (Array.isArray(value)) { + result[msKey] = value.map((v: number) => Math.round(v * multiplier)); + } + } + converted = true; + break; + } + } + + if (!converted) { + result[key] = value; + } + } + + return result; +} + +/** + * Expands day shorthands (weekdays, weekends, everyday) to explicit day names. + */ +function expandDays(days: string[]): string[] { + const expanded: string[] = []; + for (const day of days) { + const shorthand = DAY_SHORTHANDS[day]; + if (shorthand) { + expanded.push(...shorthand); + } else { + expanded.push(day); + } + } + // Deduplicate while preserving order + return [...new Set(expanded)]; +} + +function normalizeConfigSection(config: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(config)) { + if ( + (key === "duration" || + key === "grid" || + key === "lead_time" || + key === "buffers" || + key === "hold") && + typeof value === "object" && + value !== null + ) { + result[key] = normalizeDurations(value as Record); + } else { + result[key] = value; + } + } + + return result; +} + +function normalizeMatch(match: Record): Record { + const result = { ...match }; + if (Array.isArray(result["days"])) { + result["days"] = expandDays(result["days"] as string[]); + } + return result; +} + +function normalizeRule(rule: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(rule)) { + if (key === "match" && typeof value === "object" && value !== null) { + result[key] = normalizeMatch(value as Record); + } else if (key === "config" && typeof value === "object" && value !== null) { + result[key] = normalizeConfigSection(value as Record); + } else { + result[key] = value; + } + } + + return result; +} + +export function normalizePolicyConfig(authoring: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(authoring)) { + if (key === "config" && typeof value === "object" && value !== null) { + result[key] = normalizeConfigSection(value as Record); + } else if (key === "rules" && Array.isArray(value)) { + result[key] = value.map((rule) => normalizeRule(rule as Record)); + } else { + result[key] = value; + } + } + + return result; +} diff --git a/apps/server/src/domain/policy/prepare.ts b/apps/server/src/domain/policy/prepare.ts new file mode 100644 index 0000000..14ebd91 --- /dev/null +++ b/apps/server/src/domain/policy/prepare.ts @@ -0,0 +1,15 @@ +import { normalizePolicyConfig } from "./normalize"; +import { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; +import { validatePolicyConfig, type PolicyWarning } from "./validate"; + +export function preparePolicyConfig(raw: Record): { + normalized: Record; + configHash: string; + warnings: PolicyWarning[]; +} { + const normalized = normalizePolicyConfig(raw); + const { warnings } = validatePolicyConfig(normalized); + const canonicalJson = canonicalizePolicyConfig(normalized); + const configHash = hashPolicyConfig(canonicalJson); + return { normalized, configHash, warnings }; +} diff --git a/apps/server/src/domain/policy/validate.ts b/apps/server/src/domain/policy/validate.ts new file mode 100644 index 0000000..fa0e1ef --- /dev/null +++ b/apps/server/src/domain/policy/validate.ts @@ -0,0 +1,97 @@ +/** + * Semantic validation of a normalized (canonical) policy config. + * Produces non-fatal warnings for likely misconfigurations. + * + * Hard rejections (invalid values, types, ranges) are handled by Zod + * at the input schema level. This function handles semantic checks + * that Zod can't express. + */ + +export interface PolicyWarning { + code: string; + message: string; + details?: Record; +} + +interface RuleMatch { + type: string; + days?: string[]; + date?: string; +} + +interface Rule { + match: RuleMatch; + closed?: true; + windows?: { start: string; end: string }[]; + config?: Record; +} + +export function validatePolicyConfig(config: Record): { + warnings: PolicyWarning[]; +} { + const warnings: PolicyWarning[] = []; + const rules = (config["rules"] as Rule[] | undefined) ?? []; + const defaultValue = config["default"] as string; + + // Track which days have been seen in weekly rules (for unreachable detection) + const seenWeeklyDays = new Set(); + + // Track which dates have been seen in date rules + const seenDates = new Set(); + + let hasWindowedRule = false; + + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]!; + const match = rule.match; + + if (match.type === "weekly" && match.days) { + const duplicateDays = match.days.filter((d) => seenWeeklyDays.has(d)); + if (duplicateDays.length > 0) { + warnings.push({ + code: "unreachable_weekly_rule", + message: `Rule ${i}: days [${duplicateDays.join(", ")}] already matched by an earlier weekly rule`, + details: { ruleIndex: i, days: duplicateDays }, + }); + } + for (const d of match.days) { + seenWeeklyDays.add(d); + } + } + + if (match.type === "date" && match.date) { + if (seenDates.has(match.date)) { + warnings.push({ + code: "unreachable_date_rule", + message: `Rule ${i}: date ${match.date} already matched by an earlier date rule`, + details: { ruleIndex: i, date: match.date }, + }); + } + seenDates.add(match.date); + } + + if (rule.windows && rule.windows.length > 0) { + hasWindowedRule = true; + } + + // Config-only rule (no windows, no closed) with default: "closed" + if (!rule.closed && !rule.windows && rule.config && defaultValue === "closed") { + warnings.push({ + code: "config_only_with_closed_default", + message: `Rule ${i}: has config overrides but no windows. Matched dates will be open 24h, which may be unintentional with default:"closed"`, + details: { ruleIndex: i }, + }); + } + } + + // default: "open" with windowed rules + if (defaultValue === "open" && hasWindowedRule) { + warnings.push({ + code: "open_default_with_windows", + message: + 'default is "open" but some rules have windows. Matched dates follow rule windows; unmatched dates are open 24h', + }); + } + + return { warnings }; +} diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts new file mode 100644 index 0000000..23cbd87 --- /dev/null +++ b/apps/server/src/domain/scheduling/availability.ts @@ -0,0 +1,640 @@ +/** + * Service availability — shared pipeline + endpoint-specific generators. + * + * resolveDay: extracts per-day policy resolution (steps 1-4 of evaluatePolicy) + * generateSlots: discrete grid-aligned time positions for appointment-style flows + * computeWindows: continuous available time ranges for rental-style flows + */ + +import { + type PolicyConfig, + type ResolvedConfig, + type DurationConfig, + type GridConfig, + type LeadTimeConfig, + type BuffersConfig, + type HoldConfig, + matchesCondition, + timeToMs, + toLocalDate, + getDayOfWeek, + dateRange, +} from "domain/policy/evaluate"; +import { mergeIntervals, type Interval } from "./timeline"; + +export interface ResolvedDay { + date: string; + windows: { startMs: number; endMs: number }[]; + config: ResolvedConfig; +} + +export interface BlockingAllocation { + resourceId: string; + startTime: Date; + endTime: Date; +} + +export interface SlotResult { + startTime: string; + endTime: string; + status?: "available" | "unavailable"; +} + +export interface WindowResult { + startTime: string; + endTime: string; + status?: "available" | "unavailable"; +} + +// ─── Day Resolution ────────────────────────────────────────────────────────── + +/** + * Resolve a single day against a policy config. + * Extracts steps 1-4 from evaluatePolicy: blackout check, rule matching, + * open/closed, config resolution. + * + * Returns null if the day is closed (blackout, default closed, or closed rule). + */ +export function resolveDay( + policy: PolicyConfig | null, + dateStr: string, + dayOfWeek: string, +): ResolvedDay | null { + // No policy → fully open, no constraints + if (!policy) { + return { + date: dateStr, + windows: [{ startMs: 0, endMs: 24 * 60 * 60_000 }], + config: {}, + }; + } + + const rules = policy.rules ?? []; + + // Step 1: Blackout check — any closed rule matching this date + for (const rule of rules) { + if (rule.closed !== true) continue; + if (matchesCondition(rule.match, dateStr, dayOfWeek)) { + return null; + } + } + + // Step 2: Rule matching — first non-closed rule that matches + let matchedRule: (typeof rules)[number] | null = null; + for (const rule of rules) { + if (rule.closed === true) continue; + if (matchesCondition(rule.match, dateStr, dayOfWeek)) { + matchedRule = rule; + break; + } + } + + // Step 3: Open/closed determination + if (matchedRule) { + if (matchedRule.windows && matchedRule.windows.length > 0) { + // Rule has windows — use them + const windows = matchedRule.windows.map((w) => ({ + startMs: timeToMs(w.start), + endMs: timeToMs(w.end), + })); + + // Step 4: Config resolution + const baseConfig = policy.config; + const ruleConfig = matchedRule.config ?? {}; + const resolvedRaw = { ...baseConfig, ...ruleConfig }; + const config: ResolvedConfig = { + duration: resolvedRaw["duration"] as DurationConfig | undefined, + grid: resolvedRaw["grid"] as GridConfig | undefined, + lead_time: resolvedRaw["lead_time"] as LeadTimeConfig | undefined, + buffers: resolvedRaw["buffers"] as BuffersConfig | undefined, + hold: resolvedRaw["hold"] as HoldConfig | undefined, + }; + + return { date: dateStr, windows, config }; + } + // No windows on matched rule → day is open 24h + } else { + // No rule matched → use default + if (policy.default === "closed") { + return null; + } + // default: "open" → proceed with 24h + } + + // Open 24h (matched rule without windows, or default "open" with no matching rule) + const baseConfig = policy.config; + const ruleConfig = matchedRule?.config ?? {}; + const resolvedRaw = { ...baseConfig, ...ruleConfig }; + const config: ResolvedConfig = { + duration: resolvedRaw["duration"] as DurationConfig | undefined, + grid: resolvedRaw["grid"] as GridConfig | undefined, + lead_time: resolvedRaw["lead_time"] as LeadTimeConfig | undefined, + buffers: resolvedRaw["buffers"] as BuffersConfig | undefined, + hold: resolvedRaw["hold"] as HoldConfig | undefined, + }; + + return { + date: dateStr, + windows: [{ startMs: 0, endMs: 24 * 60 * 60_000 }], + config, + }; +} + +/** + * Resolve all days in a date range against a policy. + */ +export function resolveServiceDays( + policy: PolicyConfig | null, + startTime: Date, + endTime: Date, + timezone: string, +): ResolvedDay[] { + const startDate = toLocalDate(startTime, timezone); + const endInstant = new Date(endTime.getTime() - 1); + const endDate = toLocalDate(endInstant, timezone); + const dates = dateRange(startDate, endDate); + + const days: ResolvedDay[] = []; + for (const dateStr of dates) { + const dayOfWeek = getDayOfWeek(dateStr); + const resolved = resolveDay(policy, dateStr, dayOfWeek); + if (resolved) { + days.push(resolved); + } + } + return days; +} + +// ─── Timezone Conversion ───────────────────────────────────────────────────── + +/** + * Convert a local date string (YYYY-MM-DD) + ms-since-midnight to an absolute Date. + * Handles DST transitions correctly. + */ +export function localToAbsolute(dateStr: string, msFromMidnight: number, timezone: string): Date { + const [year, month, day] = dateStr.split("-").map(Number); + const hours = Math.floor(msFromMidnight / 3_600_000); + const remaining = msFromMidnight % 3_600_000; + const minutes = Math.floor(remaining / 60_000); + const seconds = Math.floor((remaining % 60_000) / 1_000); + const ms = remaining % 1_000; + + // Handle 24:00 → next day 00:00 + if (hours >= 24) { + const nextDay = new Date(Date.UTC(year!, month! - 1, day! + 1)); + const nextDateStr = `${nextDay.getUTCFullYear()}-${String(nextDay.getUTCMonth() + 1).padStart(2, "0")}-${String(nextDay.getUTCDate()).padStart(2, "0")}`; + return localToAbsolute(nextDateStr, msFromMidnight - 24 * 3_600_000, timezone); + } + + // Build an ISO-like string in the target timezone + // Use Intl.DateTimeFormat to resolve the UTC offset for this local time + // Create roughUtc with only hours:minutes to avoid double-counting seconds/ms + const roughUtcStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:00.000Z`; + + // Create a rough UTC estimate, then adjust for timezone offset + const roughUtc = new Date(roughUtcStr); + + // Get the timezone offset at this rough time + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + + // Binary-search style: find the UTC instant that corresponds to this local time + // Start with rough estimate and refine + const parts = formatter.formatToParts(roughUtc); + const localHour = parseInt(parts.find((p) => p.type === "hour")!.value); + const localMinute = parseInt(parts.find((p) => p.type === "minute")!.value); + const localDay = parseInt(parts.find((p) => p.type === "day")!.value); + const localMonth = parseInt(parts.find((p) => p.type === "month")!.value); + const localYear = parseInt(parts.find((p) => p.type === "year")!.value); + + // Compute the difference between what we wanted and what we got + const roughLocalMs = localHour * 3_600_000 + localMinute * 60_000; + const wantedLocalMs = hours * 3_600_000 + minutes * 60_000 + seconds * 1_000 + ms; + + // Day difference handling + let dayDiffMs = 0; + if (localDay !== day || localMonth !== month) { + // Rough UTC landed on a different local day — calculate day offset + const wantedDate = new Date(Date.UTC(year!, month! - 1, day)); + const gotDate = new Date(Date.UTC(localYear, localMonth - 1, localDay)); + dayDiffMs = wantedDate.getTime() - gotDate.getTime(); + } + + const offsetMs = wantedLocalMs - roughLocalMs + dayDiffMs; + return new Date(roughUtc.getTime() + offsetMs); +} + +// ─── Slot Generation ───────────────────────────────────────────────────────── + +function isDurationValid(durationMs: number, config: ResolvedConfig): boolean { + const dur = config.duration; + if (!dur) return true; + + if (dur.allowed_ms && dur.allowed_ms.length > 0) { + return dur.allowed_ms.includes(durationMs); + } + + if (dur.min_ms !== undefined && durationMs < dur.min_ms) return false; + if (dur.max_ms !== undefined && durationMs > dur.max_ms) return false; + return true; +} + +function overlapsAny( + start: number, + end: number, + allocations: { start: number; end: number }[], +): boolean { + for (const allocation of allocations) { + if (start < allocation.end && end > allocation.start) return true; + } + return false; +} + +/** + * Generate discrete grid-aligned slots for a resource. + */ +export function generateSlots( + resolvedDays: ResolvedDay[], + allocations: BlockingAllocation[], + durationMs: number, + serverTime: Date, + timezone: string, + includeUnavailable: boolean, + queryStart: Date, + queryEnd: Date, +): SlotResult[] { + const serverTimeMs = serverTime.getTime(); + const queryStartMs = queryStart.getTime(); + const queryEndMs = queryEnd.getTime(); + + // Pre-convert allocations to ms timestamps for fast comparison + const allocMs = allocations.map((a) => ({ + start: a.startTime.getTime(), + end: a.endTime.getTime(), + })); + + const slots: SlotResult[] = []; + + for (const day of resolvedDays) { + // Skip day if duration is invalid for this day's config + if (!isDurationValid(durationMs, day.config)) continue; + + const gridInterval = day.config.grid?.interval_ms ?? durationMs; + const beforeMs = day.config.buffers?.before_ms ?? 0; + const afterMs = day.config.buffers?.after_ms ?? 0; + const minLeadMs = day.config.lead_time?.min_ms; + const maxLeadMs = day.config.lead_time?.max_ms; + + for (const window of day.windows) { + // Round up to next grid-aligned time (grid is aligned to midnight) + const firstAlignedMs = Math.ceil(window.startMs / gridInterval) * gridInterval; + + // Generate candidates at grid steps within this window + for ( + let startMs = firstAlignedMs; + startMs + durationMs <= window.endMs; + startMs += gridInterval + ) { + const candidateStart = localToAbsolute(day.date, startMs, timezone); + const candidateEnd = localToAbsolute(day.date, startMs + durationMs, timezone); + + const candidateStartMs = candidateStart.getTime(); + const candidateEndMs = candidateEnd.getTime(); + + // Skip candidates outside query range + if (candidateStartMs < queryStartMs || candidateEndMs > queryEndMs) continue; + + // Check if slot is available + let available = true; + + // Buffer-expanded conflict check + const effectiveStart = candidateStartMs - beforeMs; + const effectiveEnd = candidateEndMs + afterMs; + if (overlapsAny(effectiveStart, effectiveEnd, allocMs)) { + available = false; + } + + // Lead time check + if (available && minLeadMs !== undefined) { + const leadTime = candidateStartMs - serverTimeMs; + if (leadTime < minLeadMs) available = false; + } + + // Horizon check + if (available && maxLeadMs !== undefined) { + const leadTime = candidateStartMs - serverTimeMs; + if (leadTime > maxLeadMs) available = false; + } + + if (available) { + const slot: SlotResult = { + startTime: candidateStart.toISOString(), + endTime: candidateEnd.toISOString(), + }; + if (includeUnavailable) slot.status = "available"; + slots.push(slot); + } else if (includeUnavailable) { + slots.push({ + startTime: candidateStart.toISOString(), + endTime: candidateEnd.toISOString(), + status: "unavailable", + }); + } + } + } + } + + return slots; +} + +// ─── Window Computation ────────────────────────────────────────────────────── + +/** + * Subtract busy intervals from free intervals. + * Returns the remaining free portions. + */ +function subtractIntervals(free: Interval[], busy: Interval[]): Interval[] { + const result: Interval[] = []; + + for (const f of free) { + let remaining: Interval[] = [ + { start: new Date(f.start.getTime()), end: new Date(f.end.getTime()) }, + ]; + + for (const b of busy) { + const next: Interval[] = []; + for (const r of remaining) { + // No overlap + if (b.start >= r.end || b.end <= r.start) { + next.push(r); + continue; + } + // Left portion + if (b.start > r.start) { + next.push({ start: r.start, end: b.start }); + } + // Right portion + if (b.end < r.end) { + next.push({ start: b.end, end: r.end }); + } + } + remaining = next; + } + + result.push(...remaining); + } + + return result; +} + +/** + * Compute continuous available time windows for a resource. + */ +export function computeWindows( + resolvedDays: ResolvedDay[], + allocations: BlockingAllocation[], + serverTime: Date, + timezone: string, + includeUnavailable: boolean, + queryStart: Date, + queryEnd: Date, +): WindowResult[] { + const serverTimeMs = serverTime.getTime(); + + // Convert allocations to Interval format + const busyIntervals: Interval[] = allocations.map((a) => ({ + start: a.startTime, + end: a.endTime, + })); + + // Step 1-2: Convert schedule windows to absolute intervals, clamped to query range + const scheduleIntervals: Interval[] = []; + // Track per-day config for buffer/lead/horizon/min-duration (use first day's config as base) + // Since config can vary per day, we need to process per-day + const dayConfigs: { interval: Interval; config: ResolvedConfig }[] = []; + + for (const day of resolvedDays) { + for (const window of day.windows) { + let start = localToAbsolute(day.date, window.startMs, timezone); + let end = localToAbsolute(day.date, window.endMs, timezone); + + // Clamp to query range + if (start < queryStart) start = queryStart; + if (end > queryEnd) end = queryEnd; + if (start >= end) continue; + + const interval: Interval = { start, end }; + scheduleIntervals.push(interval); + dayConfigs.push({ interval, config: day.config }); + } + } + + if (scheduleIntervals.length === 0) { + return []; + } + + // Sort schedule intervals + const sortedSchedule = [...scheduleIntervals].sort( + (a, b) => a.start.getTime() - b.start.getTime(), + ); + + // Helper to check if two intervals have the same config + function haveSameConfig(interval1: Interval, interval2: Interval): boolean { + const config1 = dayConfigs.find( + (dc) => + interval1.start.getTime() >= dc.interval.start.getTime() && + interval1.start.getTime() < dc.interval.end.getTime(), + )?.config; + const config2 = dayConfigs.find( + (dc) => + interval2.start.getTime() >= dc.interval.start.getTime() && + interval2.start.getTime() < dc.interval.end.getTime(), + )?.config; + // Deep equality check on config objects + return JSON.stringify(config1) === JSON.stringify(config2); + } + + // Merge only contiguous intervals with identical configs + const mergedSchedule: Interval[] = []; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < sortedSchedule.length; i++) { + const current = sortedSchedule[i]!; + if (mergedSchedule.length === 0) { + mergedSchedule.push(current); + continue; + } + + const last = mergedSchedule[mergedSchedule.length - 1]!; + // Merge if contiguous and same config + if (last.end.getTime() === current.start.getTime() && haveSameConfig(last, current)) { + last.end = current.end; // Extend the last interval + } else { + mergedSchedule.push(current); + } + } + + // Sort busy intervals + const sortedBusy = [...busyIntervals].sort((a, b) => a.start.getTime() - b.start.getTime()); + const mergedBusy = mergeIntervals(sortedBusy); + + // Step 3: Subtract allocations from schedule windows + const gapIntervals = subtractIntervals(mergedSchedule, mergedBusy); + + // Determine config for each gap — use the config of the first overlapping day window + function getConfigForTime(timeMs: number): ResolvedConfig { + for (const dc of dayConfigs) { + if (timeMs >= dc.interval.start.getTime() && timeMs < dc.interval.end.getTime()) { + return dc.config; + } + } + // Fallback to first day config + return dayConfigs[0]?.config ?? {}; + } + + // Step 4: Apply asymmetric buffer shrinkage + const availableWindows: Interval[] = []; + for (const gap of gapIntervals) { + const config = getConfigForTime(gap.start.getTime()); + const beforeMs = config.buffers?.before_ms ?? 0; + const afterMs = config.buffers?.after_ms ?? 0; + + let shrunkStart = gap.start.getTime(); + let shrunkEnd = gap.end.getTime(); + + // Check if gap start is adjacent to an allocation (not a schedule boundary) + const isStartAdjacentToAlloc = mergedBusy.some((b) => b.end.getTime() === gap.start.getTime()); + if (isStartAdjacentToAlloc) { + shrunkStart += beforeMs; + } + + // Check if gap end is adjacent to an allocation (not a schedule boundary) + const isEndAdjacentToAlloc = mergedBusy.some((b) => b.start.getTime() === gap.end.getTime()); + if (isEndAdjacentToAlloc) { + shrunkEnd -= afterMs; + } + + if (shrunkStart >= shrunkEnd) continue; + + availableWindows.push({ + start: new Date(shrunkStart), + end: new Date(shrunkEnd), + }); + } + + // Step 5: Discard windows shorter than min_ms + const filteredWindows = availableWindows.filter((w) => { + const config = getConfigForTime(w.start.getTime()); + const minMs = config.duration?.min_ms; + if (minMs === undefined) return true; + return w.end.getTime() - w.start.getTime() >= minMs; + }); + + // Step 6: Filter by lead time and horizon + const leadFiltered = filteredWindows.filter((w) => { + const config = getConfigForTime(w.start.getTime()); + const ltConfig = config.lead_time; + + // For windows, we check if the window START is within the lead time window + const leadTime = w.start.getTime() - serverTimeMs; + + if (ltConfig?.min_ms !== undefined && leadTime < ltConfig.min_ms) { + // Trim the window start to meet lead time, rather than discarding entirely + const minStart = serverTimeMs + ltConfig.min_ms; + if (minStart < w.end.getTime()) { + w.start = new Date(minStart); + } else { + return false; + } + } + + if (ltConfig?.max_ms !== undefined) { + const maxStart = serverTimeMs + ltConfig.max_ms; + if (w.start.getTime() > maxStart) return false; + // Trim end if needed + if (w.end.getTime() > maxStart) { + w.end = new Date(maxStart); + } + } + + return true; + }); + + // Post-trim validation: re-check min_ms since trimming may have shrunk windows + const validWindows = leadFiltered.filter((w) => { + const durationMs = w.end.getTime() - w.start.getTime(); + + // Discard zero-length windows + if (durationMs === 0) return false; + + // Re-check duration constraints after trimming + const config = getConfigForTime(w.start.getTime()); + const minMs = config.duration?.min_ms; + return minMs === undefined || durationMs >= minMs; + }); + + // Step 7: Merge contiguous windows + const sortedFiltered = [...validWindows].sort((a, b) => a.start.getTime() - b.start.getTime()); + const finalWindows = mergeIntervals(sortedFiltered); + + // Build results + const results: WindowResult[] = []; + + if (includeUnavailable) { + // Step 8: Return both available and unavailable within schedule hours + // Unavailable = allocation effective times within schedule + const unavailableInSchedule = busyIntervalsWithinSchedule(mergedBusy, mergedSchedule); + + // Combine and sort all intervals + const allEntries: { interval: Interval; status: "available" | "unavailable" }[] = [ + ...finalWindows.map((w) => ({ interval: w, status: "available" as const })), + ...unavailableInSchedule.map((w) => ({ interval: w, status: "unavailable" as const })), + ]; + allEntries.sort((a, b) => a.interval.start.getTime() - b.interval.start.getTime()); + + for (const entry of allEntries) { + results.push({ + startTime: entry.interval.start.toISOString(), + endTime: entry.interval.end.toISOString(), + status: entry.status, + }); + } + } else { + for (const w of finalWindows) { + results.push({ + startTime: w.start.toISOString(), + endTime: w.end.toISOString(), + }); + } + } + + return results; +} + +/** + * Get busy intervals clamped to within schedule hours. + */ +function busyIntervalsWithinSchedule(busy: Interval[], schedule: Interval[]): Interval[] { + const result: Interval[] = []; + + for (const b of busy) { + for (const s of schedule) { + // Find overlap between busy and schedule + const overlapStart = b.start > s.start ? b.start : s.start; + const overlapEnd = b.end < s.end ? b.end : s.end; + + if (overlapStart < overlapEnd) { + result.push({ start: overlapStart, end: overlapEnd }); + } + } + } + + return mergeIntervals(result.sort((a, b) => a.start.getTime() - b.start.getTime())); +} diff --git a/apps/server/src/domain/scheduling/index.ts b/apps/server/src/domain/scheduling/index.ts new file mode 100644 index 0000000..e9bb79d --- /dev/null +++ b/apps/server/src/domain/scheduling/index.ts @@ -0,0 +1,11 @@ +export { clampInterval, mergeIntervals, buildTimeline, type Interval } from "./timeline"; +export { + resolveDay, + resolveServiceDays, + generateSlots, + computeWindows, + type ResolvedDay, + type BlockingAllocation, + type SlotResult, + type WindowResult, +} from "./availability"; diff --git a/apps/server/src/lib/timeline.ts b/apps/server/src/domain/scheduling/timeline.ts similarity index 88% rename from apps/server/src/lib/timeline.ts rename to apps/server/src/domain/scheduling/timeline.ts index 07486f1..beb0a66 100644 --- a/apps/server/src/lib/timeline.ts +++ b/apps/server/src/domain/scheduling/timeline.ts @@ -59,16 +59,16 @@ export function buildTimeline( // Add free block before this busy interval (if gap exists) if (busy.start > cursor) { timeline.push({ - startAt: cursor.toISOString(), - endAt: busy.start.toISOString(), + startTime: cursor.toISOString(), + endTime: busy.start.toISOString(), status: "free", }); } // Add busy block timeline.push({ - startAt: busy.start.toISOString(), - endAt: busy.end.toISOString(), + startTime: busy.start.toISOString(), + endTime: busy.end.toISOString(), status: "busy", }); @@ -78,8 +78,8 @@ export function buildTimeline( // Add trailing free block if window extends past last busy if (cursor < windowEnd) { timeline.push({ - startAt: cursor.toISOString(), - endAt: windowEnd.toISOString(), + startTime: cursor.toISOString(), + endTime: windowEnd.toISOString(), status: "free", }); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7008b23..773fede 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -33,4 +33,6 @@ async function main() { process.on("SIGINT", shutdown); } -main().catch((err) => logger.error(err)); +main().catch((err: unknown) => { + logger.error(err); +}); diff --git a/apps/server/src/infra/auth.ts b/apps/server/src/infra/auth.ts index 1adc1d9..8d5d190 100644 --- a/apps/server/src/infra/auth.ts +++ b/apps/server/src/infra/auth.ts @@ -1,4 +1,4 @@ -import { Context, Next } from "hono"; +import type { Context, Next } from "hono"; import { config } from "config"; export class AuthError extends Error { diff --git a/apps/server/src/infra/idempotency.ts b/apps/server/src/infra/idempotency.ts index 22eb9f2..b7f96be 100644 --- a/apps/server/src/infra/idempotency.ts +++ b/apps/server/src/infra/idempotency.ts @@ -1,4 +1,4 @@ -import { Context, Next } from "hono"; +import type { Context, Next } from "hono"; import { createHash } from "crypto"; import { db } from "database"; @@ -101,7 +101,15 @@ export function idempotent(options: IdempotencyOptions = {}) { const method = c.req.method; // Clone body for hashing (body can only be read once) - const body = await c.req.json(); + // Handle empty bodies gracefully for body-less POST endpoints + let body: Record = {}; + const contentType = c.req.header("content-type"); + if (contentType?.includes("application/json")) { + const text = await c.req.text(); + if (text.trim()) { + body = JSON.parse(text) as Record; + } + } const payloadHash = computePayloadHash(body, significantFields); // Store body for later use by handler @@ -138,14 +146,24 @@ export function idempotent(options: IdempotencyOptions = {}) { ttlHours, }); - await next(); + try { + await next(); + } catch (handlerError: unknown) { + // Handler failed — clean up the in_progress record so client can retry + await db + .deleteFrom("idempotencyKeys") + .where("ledgerId", "=", ledgerId) + .where("key", "=", idempotencyKey) + .where("status", "=", "in_progress") + .execute(); + throw handlerError; + } } catch (error: unknown) { - // Check if it's a unique constraint violation + // Check if it's a unique constraint violation (PG error code 23505) const isConflict = error instanceof Error && - (error.message.includes("duplicate key") || - error.message.includes("unique constraint") || - error.message.includes("UNIQUE constraint")); + "code" in error && + (error as Error & { code: string }).code === "23505"; if (!isConflict) { throw error; @@ -259,22 +277,3 @@ export async function storeIdempotencyResponse( .where("key", "=", ctx.key) .execute(); } - -/** - * Call this if the handler fails to clean up the in_progress record. - * This allows the client to retry with the same idempotency key. - */ -export async function clearIdempotencyKey(c: Context): Promise { - const ctx = c.get("idempotencyContext") as IdempotencyContext | undefined; - - if (!ctx) { - return; - } - - await db - .deleteFrom("idempotencyKeys") - .where("ledgerId", "=", ctx.ledgerId) - .where("key", "=", ctx.key) - .where("status", "=", "in_progress") - .execute(); -} diff --git a/apps/server/src/infra/webhooks.ts b/apps/server/src/infra/webhooks.ts index 0162369..c8adae6 100644 --- a/apps/server/src/infra/webhooks.ts +++ b/apps/server/src/infra/webhooks.ts @@ -1,37 +1,36 @@ import { createHmac } from "crypto"; import type { Kysely, Transaction } from "kysely"; -import type { Database, AllocationRow } from "database/schema"; -import type { Allocation } from "@floyd-run/schema/types"; +import type { Database } from "database/schema"; import { db } from "database"; import { generateId } from "@floyd-run/utils"; -import { serializeAllocation } from "routes/v1/serializers"; // Event types for webhooks export type WebhookEventType = | "allocation.created" - | "allocation.confirmed" - | "allocation.cancelled" - | "allocation.expired"; + | "allocation.deleted" + | "booking.created" + | "booking.confirmed" + | "booking.canceled" + | "booking.expired"; export interface WebhookEvent { id: string; type: WebhookEventType; ledgerId: string; createdAt: string; - data: { - allocation: Allocation; - }; + data: Record; } /** * Enqueue webhook deliveries for all subscriptions that match the event. * MUST be called within the same transaction as the data mutation. + * Call sites are responsible for serializing their own payload data. */ export async function enqueueWebhookEvent( trx: Transaction | Kysely, eventType: WebhookEventType, ledgerId: string, - allocation: AllocationRow, + data: Record, ): Promise { // Find all subscriptions for this ledger const subscriptions = await trx @@ -55,9 +54,7 @@ export async function enqueueWebhookEvent( type: eventType, ledgerId, createdAt: now.toISOString(), - data: { - allocation: serializeAllocation(allocation), - }, + data, }; return { id: deliveryId, @@ -93,28 +90,35 @@ export function computeWebhookSignature(payload: string, secret: string): string export async function processPendingDeliveries(batchSize = 10): Promise { const now = new Date(); - // Claim a batch of pending deliveries - const deliveries = await db - .selectFrom("webhookDeliveries") - .selectAll() - .where((eb) => eb.or([eb("status", "=", "pending"), eb("status", "=", "failed")])) - .where((eb) => eb.or([eb("nextAttemptAt", "is", null), eb("nextAttemptAt", "<=", now)])) - .orderBy("nextAttemptAt", "asc") - .limit(batchSize) - .execute(); + // Atomically claim a batch of pending deliveries using FOR UPDATE SKIP LOCKED + const deliveries = await db.transaction().execute(async (trx) => { + const rows = await trx + .selectFrom("webhookDeliveries") + .selectAll() + .where((eb) => eb.or([eb("status", "=", "pending"), eb("status", "=", "failed")])) + .where((eb) => eb.or([eb("nextAttemptAt", "is", null), eb("nextAttemptAt", "<=", now)])) + .orderBy("nextAttemptAt", "asc") + .limit(batchSize) + .forUpdate() + .skipLocked() + .execute(); + + if (rows.length === 0) return []; + + const ids = rows.map((d) => d.id); + await trx + .updateTable("webhookDeliveries") + .set({ status: "in_flight" }) + .where("id", "in", ids) + .execute(); + + return rows; + }); if (deliveries.length === 0) { return 0; } - // Mark as in_flight - const deliveryIds = deliveries.map((d) => d.id); - await db - .updateTable("webhookDeliveries") - .set({ status: "in_flight" }) - .where("id", "in", deliveryIds) - .execute(); - let processed = 0; for (const delivery of deliveries) { diff --git a/apps/server/src/lib/errors.ts b/apps/server/src/lib/errors.ts index 617fc1b..d2d90ba 100644 --- a/apps/server/src/lib/errors.ts +++ b/apps/server/src/lib/errors.ts @@ -1,9 +1,9 @@ -import { ZodError } from "zod"; +import type { ZodError } from "zod"; export class AppError extends Error { constructor( message: string, - public statusCode: number = 500, + public statusCode = 500, public code?: string, ) { super(message); @@ -12,14 +12,18 @@ export class AppError extends Error { } export class NotFoundError extends AppError { - constructor(message = "Not found") { - super(message, 404, "NOT_FOUND"); + constructor( + message = "Not found", + public resourceType?: string, + public resourceId?: string, + ) { + super(message, 404, "not_found"); } } export class InputError extends AppError { constructor(public issues: ZodError["issues"]) { - super("Invalid input", 422, "INVALID_INPUT"); + super("Invalid input", 422, "invalid_input"); } } diff --git a/apps/server/src/lib/service.ts b/apps/server/src/lib/operation.ts similarity index 73% rename from apps/server/src/lib/service.ts rename to apps/server/src/lib/operation.ts index 4809fd6..5b92549 100644 --- a/apps/server/src/lib/service.ts +++ b/apps/server/src/lib/operation.ts @@ -1,17 +1,17 @@ -import { z, ZodError } from "zod"; +import { type z, ZodError } from "zod"; import { InputError } from "./errors"; -export function createService(config: { +export function createOperation(config: { input: T; execute: (input: z.infer) => Promise; }): (input: z.input) => Promise; -export function createService(config: { +export function createOperation(config: { input?: undefined; execute: () => Promise; }): () => Promise; -export function createService(config: { +export function createOperation(config: { input?: T; execute: (input?: z.infer) => Promise; }) { diff --git a/apps/server/src/migrations/20260121211622_create-resources-table.ts b/apps/server/src/migrations/20260121211622_create-resources-table.ts index ac92eaf..a93fd40 100644 --- a/apps/server/src/migrations/20260121211622_create-resources-table.ts +++ b/apps/server/src/migrations/20260121211622_create-resources-table.ts @@ -1,5 +1,5 @@ import type { Database } from "database/schema"; -import { Kysely, sql } from "kysely"; +import { type Kysely, sql } from "kysely"; import { addUpdatedAtTrigger } from "./utils"; export async function up(db: Kysely): Promise { diff --git a/apps/server/src/migrations/20260121215945_create-allocations-table.ts b/apps/server/src/migrations/20260121215945_create-allocations-table.ts index 30d64fd..beeed78 100644 --- a/apps/server/src/migrations/20260121215945_create-allocations-table.ts +++ b/apps/server/src/migrations/20260121215945_create-allocations-table.ts @@ -1,5 +1,5 @@ import type { Database } from "database/schema"; -import { Kysely, sql } from "kysely"; +import { type Kysely, sql } from "kysely"; import { addUpdatedAtTrigger } from "./utils"; export async function up(db: Kysely): Promise { diff --git a/apps/server/src/migrations/20260122142829_create-idempotency-keys-table.ts b/apps/server/src/migrations/20260122142829_create-idempotency-keys-table.ts index ac98ecc..f11a3e2 100644 --- a/apps/server/src/migrations/20260122142829_create-idempotency-keys-table.ts +++ b/apps/server/src/migrations/20260122142829_create-idempotency-keys-table.ts @@ -1,5 +1,5 @@ import type { Database } from "database/schema"; -import { Kysely, sql } from "kysely"; +import { type Kysely, sql } from "kysely"; export async function up(db: Kysely): Promise { await db.schema diff --git a/apps/server/src/migrations/20260123215648_create-webhooks-tables.ts b/apps/server/src/migrations/20260123215648_create-webhooks-tables.ts index b4e44f3..9075305 100644 --- a/apps/server/src/migrations/20260123215648_create-webhooks-tables.ts +++ b/apps/server/src/migrations/20260123215648_create-webhooks-tables.ts @@ -1,5 +1,5 @@ import type { Database } from "database/schema"; -import { Kysely, sql } from "kysely"; +import { type Kysely, sql } from "kysely"; import { addUpdatedAtTrigger } from "./utils"; export async function up(db: Kysely): Promise { diff --git a/apps/server/src/migrations/20260206235234_remove-timezone.ts b/apps/server/src/migrations/20260206235234_remove-timezone.ts index 97999cc..c5ec99a 100644 --- a/apps/server/src/migrations/20260206235234_remove-timezone.ts +++ b/apps/server/src/migrations/20260206235234_remove-timezone.ts @@ -1,5 +1,5 @@ import type { Database } from "database/schema"; -import { Kysely } from "kysely"; +import type { Kysely } from "kysely"; export async function up(db: Kysely): Promise { await db.schema.alterTable("resources").dropColumn("timezone").execute(); diff --git a/apps/server/src/migrations/20260212000920_create-policies-table.ts b/apps/server/src/migrations/20260212000920_create-policies-table.ts new file mode 100644 index 0000000..89cb172 --- /dev/null +++ b/apps/server/src/migrations/20260212000920_create-policies-table.ts @@ -0,0 +1,29 @@ +import type { Database } from "database/schema"; +import { type Kysely, sql } from "kysely"; +import { addUpdatedAtTrigger } from "./utils"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("policies") + .addColumn("id", "varchar(32)", (col) => col.primaryKey().notNull()) + .addColumn("ledger_id", "varchar(32)", (col) => col.notNull().references("ledgers.id")) + .addColumn("config", "jsonb", (col) => col.notNull()) + .addColumn("config_hash", "varchar(64)", (col) => col.notNull()) + .addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)) + .addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)) + .execute(); + + await db.schema.createIndex("idx_policies_ledger").on("policies").column("ledger_id").execute(); + + await db.schema + .createIndex("idx_policies_config_hash") + .on("policies") + .columns(["ledger_id", "config_hash"]) + .execute(); + + await addUpdatedAtTrigger(db, "policies"); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("policies").execute(); +} diff --git a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts new file mode 100644 index 0000000..7dd7de5 --- /dev/null +++ b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts @@ -0,0 +1,197 @@ +import type { Database } from "database/schema"; +import { type Kysely, sql } from "kysely"; +import { addUpdatedAtTrigger } from "./utils"; + +export async function up(db: Kysely): Promise { + // 1. Add timezone to resources (required, default UTC for existing rows) + await db.schema + .alterTable("resources") + .addColumn("timezone", "varchar(64)", (col) => col.notNull().defaultTo("UTC")) + .execute(); + + // 2. Create services table + await db.schema + .createTable("services") + .addColumn("id", "varchar(32)", (col) => col.primaryKey().notNull()) + .addColumn("ledger_id", "varchar(32)", (col) => col.notNull().references("ledgers.id")) + .addColumn("policy_id", "varchar(32)", (col) => col.references("policies.id")) + .addColumn("name", "varchar(255)", (col) => col.notNull()) + .addColumn("metadata", "jsonb") + .addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)) + .addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)) + .execute(); + + await db.schema.createIndex("idx_services_ledger").on("services").column("ledger_id").execute(); + + await addUpdatedAtTrigger(db, "services"); + + // 3. Create service_resources join table + await db.schema + .createTable("service_resources") + .addColumn("service_id", "varchar(32)", (col) => + col.notNull().references("services.id").onDelete("cascade"), + ) + .addColumn("resource_id", "varchar(32)", (col) => col.notNull().references("resources.id")) + .addPrimaryKeyConstraint("service_resources_pk", ["service_id", "resource_id"]) + .execute(); + + await db.schema + .createIndex("idx_service_resources_resource") + .on("service_resources") + .column("resource_id") + .execute(); + + // 4. Create bookings table + await db.schema + .createTable("bookings") + .addColumn("id", "varchar(32)", (col) => col.primaryKey().notNull()) + .addColumn("ledger_id", "varchar(32)", (col) => col.notNull().references("ledgers.id")) + .addColumn("service_id", "varchar(32)", (col) => col.notNull().references("services.id")) + .addColumn("policy_id", "varchar(32)", (col) => col.references("policies.id")) + .addColumn("status", "varchar(50)", (col) => + col.notNull().check(sql`status IN ('hold', 'confirmed', 'canceled', 'expired')`), + ) + .addColumn("expires_at", "timestamptz") + .addCheckConstraint( + "bookings_expires_at_consistency", + sql`(status = 'hold' AND expires_at IS NOT NULL) OR (status IN ('confirmed', 'canceled', 'expired') AND expires_at IS NULL)`, + ) + .addColumn("metadata", "jsonb") + .addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)) + .addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)) + .execute(); + + await db.schema.createIndex("idx_bookings_ledger").on("bookings").column("ledger_id").execute(); + + await db.schema.createIndex("idx_bookings_service").on("bookings").column("service_id").execute(); + + await db.schema.createIndex("idx_bookings_status").on("bookings").column("status").execute(); + + await db.schema + .createIndex("idx_bookings_expires_at") + .on("bookings") + .column("expires_at") + .where("expires_at", "is not", null) + .execute(); + + await addUpdatedAtTrigger(db, "bookings"); + + // 5. Modify allocations table + // Rename time columns: start_at → start_time, end_at → end_time + await sql`DROP INDEX IF EXISTS idx_allocations_time_range`.execute(db); + await sql`ALTER TABLE allocations DROP CONSTRAINT IF EXISTS allocations_time_order`.execute(db); + await db.schema.alterTable("allocations").renameColumn("start_at", "start_time").execute(); + await db.schema.alterTable("allocations").renameColumn("end_at", "end_time").execute(); + await sql`ALTER TABLE allocations ADD CONSTRAINT allocations_time_order CHECK (start_time < end_time)`.execute( + db, + ); + + // Add new columns + await db.schema + .alterTable("allocations") + .addColumn("booking_id", "varchar(32)", (col) => col.references("bookings.id")) + .execute(); + + await db.schema + .alterTable("allocations") + .addColumn("active", "boolean", (col) => col.notNull().defaultTo(true)) + .execute(); + + // Data migration: set active based on current status + await sql`UPDATE allocations SET active = (status IN ('hold', 'confirmed'))`.execute(db); + + // Drop old constraints and status column + await sql`ALTER TABLE allocations DROP CONSTRAINT IF EXISTS allocations_expires_at_consistency`.execute( + db, + ); + await sql`ALTER TABLE allocations DROP CONSTRAINT IF EXISTS allocations_status_check`.execute(db); + await db.schema.alterTable("allocations").dropColumn("status").execute(); + + // Add buffer columns + await db.schema + .alterTable("allocations") + .addColumn("buffer_before_ms", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + + await db.schema + .alterTable("allocations") + .addColumn("buffer_after_ms", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + + // Drop old indexes + await db.schema.dropIndex("idx_allocations_status").ifExists().execute(); + + // Add new indexes + await sql` + CREATE INDEX idx_allocations_active + ON allocations(resource_id, start_time, end_time) + WHERE active = true + `.execute(db); + + await sql` + CREATE INDEX idx_allocations_booking + ON allocations(booking_id) + WHERE booking_id IS NOT NULL + `.execute(db); +} + +export async function down(db: Kysely): Promise { + // Drop new indexes + await sql`DROP INDEX IF EXISTS idx_allocations_booking`.execute(db); + await sql`DROP INDEX IF EXISTS idx_allocations_active`.execute(db); + + // Re-add status column + await db.schema + .alterTable("allocations") + .addColumn("status", "varchar(50)", (col) => + col + .notNull() + .defaultTo("confirmed") + .check(sql`status IN ('hold', 'confirmed', 'cancelled', 'expired')`), + ) + .execute(); + + // Migrate data back + await sql`UPDATE allocations SET status = CASE WHEN active = true THEN 'confirmed' ELSE 'expired' END`.execute( + db, + ); + + // Re-add constraints + await sql` + ALTER TABLE allocations ADD CONSTRAINT allocations_expires_at_consistency + CHECK ((status = 'hold' AND expires_at IS NOT NULL) OR (status IN ('confirmed', 'cancelled', 'expired') AND expires_at IS NULL)) + `.execute(db); + + // Re-add old indexes + await db.schema + .createIndex("idx_allocations_status") + .on("allocations") + .column("status") + .execute(); + + // Drop new columns + await db.schema.alterTable("allocations").dropColumn("buffer_after_ms").execute(); + await db.schema.alterTable("allocations").dropColumn("buffer_before_ms").execute(); + await db.schema.alterTable("allocations").dropColumn("active").execute(); + await db.schema.alterTable("allocations").dropColumn("booking_id").execute(); + + // Drop new tables (reverse order) + await db.schema.dropTable("bookings").execute(); + await db.schema.dropTable("service_resources").execute(); + await db.schema.dropTable("services").execute(); + + // Reverse column renames: start_time → start_at, end_time → end_at + await sql`ALTER TABLE allocations DROP CONSTRAINT IF EXISTS allocations_time_order`.execute(db); + await db.schema.alterTable("allocations").renameColumn("start_time", "start_at").execute(); + await db.schema.alterTable("allocations").renameColumn("end_time", "end_at").execute(); + await sql`ALTER TABLE allocations ADD CONSTRAINT allocations_time_order CHECK (start_at < end_at)`.execute( + db, + ); + await sql` + CREATE INDEX idx_allocations_time_range ON allocations + USING GIST (resource_id, tstzrange(start_at, end_at, '[)')) + `.execute(db); + + // Remove timezone from resources + await db.schema.alterTable("resources").dropColumn("timezone").execute(); +} diff --git a/apps/server/src/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts new file mode 100644 index 0000000..b4db451 --- /dev/null +++ b/apps/server/src/operations/allocation/create.ts @@ -0,0 +1,51 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { allocationInput } from "@floyd-run/schema/inputs"; +import { NotFoundError } from "lib/errors"; +import { enqueueWebhookEvent } from "infra/webhooks"; +import { serializeAllocation } from "routes/v1/serializers"; +import { insertAllocation } from "./internal/insert"; + +export default createOperation({ + input: allocationInput.create, + execute: async (input) => { + return await db.transaction().execute(async (trx) => { + // 1. Lock the resource row (FOR UPDATE) - serializes concurrent writes + const resource = await trx + .selectFrom("resources") + .selectAll() + .where("id", "=", input.resourceId) + .where("ledgerId", "=", input.ledgerId) + .forUpdate() + .executeTakeFirst(); + + if (!resource) { + throw new NotFoundError("Resource not found"); + } + + // 2. Capture server time immediately after acquiring lock + const serverTime = await getServerTime(trx); + + // 3. Check conflicts + insert allocation + const allocation = await insertAllocation(trx, { + ledgerId: input.ledgerId, + resourceId: input.resourceId, + bookingId: null, + startTime: input.startTime, + endTime: input.endTime, + bufferBeforeMs: 0, + bufferAfterMs: 0, + expiresAt: input.expiresAt ?? null, + metadata: input.metadata ?? null, + serverTime, + }); + + // 4. Enqueue webhook event (in same transaction) + await enqueueWebhookEvent(trx, "allocation.created", input.ledgerId, { + allocation: serializeAllocation(allocation), + }); + + return { allocation, serverTime }; + }); + }, +}); diff --git a/apps/server/src/services/allocation/get.ts b/apps/server/src/operations/allocation/get.ts similarity index 52% rename from apps/server/src/services/allocation/get.ts rename to apps/server/src/operations/allocation/get.ts index f36294e..34e7d4f 100644 --- a/apps/server/src/services/allocation/get.ts +++ b/apps/server/src/operations/allocation/get.ts @@ -1,13 +1,14 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { allocation } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { allocationInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: allocation.getSchema, +export default createOperation({ + input: allocationInput.get, execute: async (input) => { const allocation = await db .selectFrom("allocations") .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .selectAll() .executeTakeFirst(); diff --git a/apps/server/src/operations/allocation/index.ts b/apps/server/src/operations/allocation/index.ts new file mode 100644 index 0000000..ba8b4df --- /dev/null +++ b/apps/server/src/operations/allocation/index.ts @@ -0,0 +1,11 @@ +import create from "./create"; +import get from "./get"; +import list from "./list"; +import remove from "./remove"; + +export const allocation = { + create, + get, + list, + remove, +}; diff --git a/apps/server/src/operations/allocation/internal/insert.ts b/apps/server/src/operations/allocation/internal/insert.ts new file mode 100644 index 0000000..829056b --- /dev/null +++ b/apps/server/src/operations/allocation/internal/insert.ts @@ -0,0 +1,60 @@ +import type { Transaction } from "kysely"; +import type { Database, AllocationRow } from "database/schema"; +import { generateId } from "@floyd-run/utils"; +import { ConflictError } from "lib/errors"; + +interface InsertAllocationParams { + ledgerId: string; + resourceId: string; + bookingId: string | null; + startTime: Date; + endTime: Date; + bufferBeforeMs: number; + bufferAfterMs: number; + expiresAt: Date | null; + metadata: Record | null; + serverTime: Date; +} + +export async function insertAllocation( + trx: Transaction, + params: InsertAllocationParams, +): Promise { + // 1. Check for overlapping active allocations (not expired) + const conflicting = await trx + .selectFrom("allocations") + .select("id") + .where("resourceId", "=", params.resourceId) + .where("active", "=", true) + .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", params.serverTime)])) + .where("startTime", "<", params.endTime) + .where("endTime", ">", params.startTime) + .execute(); + + if (conflicting.length > 0) { + throw new ConflictError("allocation.overlap", { + conflictingAllocationIds: conflicting.map((a) => a.id), + }); + } + + // 2. Insert the allocation + const allocation = await trx + .insertInto("allocations") + .values({ + id: generateId("alc"), + ledgerId: params.ledgerId, + resourceId: params.resourceId, + bookingId: params.bookingId, + active: true, + startTime: params.startTime, + endTime: params.endTime, + bufferBeforeMs: params.bufferBeforeMs, + bufferAfterMs: params.bufferAfterMs, + expiresAt: params.expiresAt, + metadata: params.metadata, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return allocation; +} diff --git a/apps/server/src/services/allocation/list.ts b/apps/server/src/operations/allocation/list.ts similarity index 58% rename from apps/server/src/services/allocation/list.ts rename to apps/server/src/operations/allocation/list.ts index 8ddcca9..b7920d7 100644 --- a/apps/server/src/services/allocation/list.ts +++ b/apps/server/src/operations/allocation/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { allocation } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { allocationInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: allocation.listSchema, +export default createOperation({ + input: allocationInput.list, execute: async (input) => { const allocations = await db .selectFrom("allocations") diff --git a/apps/server/src/operations/allocation/remove.ts b/apps/server/src/operations/allocation/remove.ts new file mode 100644 index 0000000..91d6b18 --- /dev/null +++ b/apps/server/src/operations/allocation/remove.ts @@ -0,0 +1,43 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { allocationInput } from "@floyd-run/schema/inputs"; +import { ConflictError, NotFoundError } from "lib/errors"; +import { enqueueWebhookEvent } from "infra/webhooks"; +import { serializeAllocation } from "routes/v1/serializers"; + +export default createOperation({ + input: allocationInput.remove, + execute: async (input) => { + return await db.transaction().execute(async (trx) => { + // 1. Lock the allocation row + const existing = await trx + .selectFrom("allocations") + .selectAll() + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .forUpdate() + .executeTakeFirst(); + + if (!existing) { + throw new NotFoundError("Allocation not found"); + } + + // 2. Cannot delete booking-owned allocations + if (existing.bookingId !== null) { + throw new ConflictError("allocation.managed_by_booking", { + bookingId: existing.bookingId, + }); + } + + // 3. Hard delete the allocation + await trx.deleteFrom("allocations").where("id", "=", input.id).execute(); + + // 4. Enqueue webhook event + await enqueueWebhookEvent(trx, "allocation.deleted", existing.ledgerId, { + allocation: serializeAllocation(existing), + }); + + return { deleted: true }; + }); + }, +}); diff --git a/apps/server/src/operations/availability/index.ts b/apps/server/src/operations/availability/index.ts new file mode 100644 index 0000000..63b70f3 --- /dev/null +++ b/apps/server/src/operations/availability/index.ts @@ -0,0 +1,9 @@ +import query from "./query"; +import slots from "./slots"; +import windows from "./windows"; + +export const availability = { + query, + slots, + windows, +}; diff --git a/apps/server/src/services/availability/query.ts b/apps/server/src/operations/availability/query.ts similarity index 59% rename from apps/server/src/services/availability/query.ts rename to apps/server/src/operations/availability/query.ts index 16aa39b..064afd4 100644 --- a/apps/server/src/services/availability/query.ts +++ b/apps/server/src/operations/availability/query.ts @@ -1,32 +1,32 @@ import { sql } from "kysely"; import { db } from "database"; -import { createService } from "lib/service"; -import { availability } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { availabilityInput } from "@floyd-run/schema/inputs"; import type { AvailabilityItem } from "@floyd-run/schema/types"; -import { clampInterval, mergeIntervals, buildTimeline } from "lib/timeline"; +import { clampInterval, mergeIntervals, buildTimeline } from "domain/scheduling/timeline"; interface BlockingAllocation { resourceId: string; - startAt: Date; - endAt: Date; + startTime: Date; + endTime: Date; } -export default createService({ - input: availability.querySchema, +export default createOperation({ + input: availabilityInput.query, execute: async (input): Promise<{ items: AvailabilityItem[] }> => { - const { ledgerId, resourceIds, startAt, endAt } = input; + const { ledgerId, resourceIds, startTime, endTime } = input; // Single query to fetch all blocking allocations const blockingAllocations = await sql` - SELECT resource_id, start_at, end_at + SELECT resource_id, start_time, end_time FROM allocations WHERE ledger_id = ${ledgerId} - AND resource_id = ANY(${sql.raw(`ARRAY[${resourceIds.map((id) => `'${id}'`).join(",")}]::text[]`)}) - AND status IN ('hold', 'confirmed') - AND (status != 'hold' OR expires_at > clock_timestamp()) - AND start_at < ${endAt} - AND end_at > ${startAt} - ORDER BY resource_id, start_at + AND resource_id = ANY(ARRAY[${sql.join(resourceIds.map((id) => sql`${id}`))}]::text[]) + AND active = true + AND (expires_at IS NULL OR expires_at > clock_timestamp()) + AND start_time < ${endTime} + AND end_time > ${startTime} + ORDER BY resource_id, start_time `.execute(db); // Group allocations by resource_id @@ -43,14 +43,14 @@ export default createService({ // Build timeline for each resource const items: AvailabilityItem[] = resourceIds.map((resourceId) => { - const allocations = allocationsByResource.get(resourceId) || []; + const allocations = allocationsByResource.get(resourceId) ?? []; // Clamp allocations to window, merge overlaps, build timeline const clamped = allocations.map((a) => - clampInterval({ start: a.startAt, end: a.endAt }, startAt, endAt), + clampInterval({ start: a.startTime, end: a.endTime }, startTime, endTime), ); const merged = mergeIntervals(clamped); - const timeline = buildTimeline(merged, startAt, endAt); + const timeline = buildTimeline(merged, startTime, endTime); return { resourceId, timeline }; }); diff --git a/apps/server/src/operations/availability/slots.ts b/apps/server/src/operations/availability/slots.ts new file mode 100644 index 0000000..f0a7ab6 --- /dev/null +++ b/apps/server/src/operations/availability/slots.ts @@ -0,0 +1,147 @@ +import { sql } from "kysely"; +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { availabilityInput } from "@floyd-run/schema/inputs"; +import { InputError, NotFoundError } from "lib/errors"; +import { resolveServiceDays, generateSlots } from "domain/scheduling/availability"; +import type { PolicyConfig } from "domain/policy/evaluate"; +import type { BlockingAllocation } from "domain/scheduling/availability"; + +const MAX_SLOTS_RANGE_MS = 7 * 24 * 60 * 60_000; // 7 days + +export default createOperation({ + input: availabilityInput.slots, + execute: async (input) => { + const { ledgerId, serviceId, startTime, endTime, durationMs, includeUnavailable } = input; + + // Validate query window + if (endTime.getTime() - startTime.getTime() > MAX_SLOTS_RANGE_MS) { + throw new InputError([ + { code: "custom", message: "Query range exceeds maximum of 7 days", path: ["endTime"] }, + ]); + } + + if (endTime <= startTime) { + throw new InputError([ + { code: "custom", message: "endTime must be after startTime", path: ["endTime"] }, + ]); + } + + // 1. Load service + const service = await db + .selectFrom("services") + .selectAll() + .where("id", "=", serviceId) + .where("ledgerId", "=", ledgerId) + .executeTakeFirst(); + + if (!service) throw new NotFoundError("Service not found"); + + // 2. Load service's resources + const serviceResourceRows = await db + .selectFrom("serviceResources") + .select("resourceId") + .where("serviceId", "=", serviceId) + .execute(); + const allServiceResourceIds = new Set(serviceResourceRows.map((r) => r.resourceId)); + + let targetResourceIds: string[]; + if (input.resourceIds && input.resourceIds.length > 0) { + // Validate all requested resourceIds belong to the service + for (const rid of input.resourceIds) { + if (!allServiceResourceIds.has(rid)) { + throw new InputError([ + { + code: "custom", + message: `Resource ${rid} does not belong to service ${serviceId}`, + path: ["resourceIds"], + }, + ]); + } + } + targetResourceIds = input.resourceIds; + } else { + targetResourceIds = [...allServiceResourceIds]; + } + + if (targetResourceIds.length === 0) { + const serverTime = await getServerTime(db); + return { data: [], serverTime }; + } + + // 3. Load resources (for timezone) + const resources = await db + .selectFrom("resources") + .select(["id", "timezone"]) + .where("id", "in", targetResourceIds) + .execute(); + + const resourceMap = new Map(resources.map((r) => [r.id, r])); + + // 4. Capture serverTime + const serverTime = await getServerTime(db); + + // 5. Load policy + let policy: PolicyConfig | null = null; + if (service.policyId) { + const policyRow = await db + .selectFrom("policies") + .selectAll() + .where("id", "=", service.policyId) + .executeTakeFirst(); + + if (policyRow) { + policy = policyRow.config as unknown as PolicyConfig; + } + } + + // 6. Fetch allocations + const blockingAllocations = + targetResourceIds.length > 0 + ? await sql` + SELECT resource_id, start_time, end_time + FROM allocations + WHERE ledger_id = ${ledgerId} + AND resource_id = ANY(ARRAY[${sql.join(targetResourceIds.map((id) => sql`${id}`))}]::text[]) + AND active = true + AND (expires_at IS NULL OR expires_at > clock_timestamp()) + AND start_time < ${endTime} + AND end_time > ${startTime} + ORDER BY resource_id, start_time + `.execute(db) + : { rows: [] }; + + // Group allocations by resource + const allocByResource = new Map(); + for (const id of targetResourceIds) { + allocByResource.set(id, []); + } + for (const row of blockingAllocations.rows) { + const list = allocByResource.get(row.resourceId); + if (list) list.push(row); + } + + // 7. Generate slots per resource + const data = targetResourceIds.map((resourceId) => { + const resource = resourceMap.get(resourceId); + const timezone = resource!.timezone; + const allocs = allocByResource.get(resourceId) ?? []; + + const resolvedDays = resolveServiceDays(policy, startTime, endTime, timezone); + const slots = generateSlots( + resolvedDays, + allocs, + durationMs, + serverTime, + timezone, + includeUnavailable, + startTime, + endTime, + ); + + return { resourceId, timezone, slots }; + }); + + return { data, serverTime }; + }, +}); diff --git a/apps/server/src/operations/availability/windows.ts b/apps/server/src/operations/availability/windows.ts new file mode 100644 index 0000000..df084cf --- /dev/null +++ b/apps/server/src/operations/availability/windows.ts @@ -0,0 +1,146 @@ +import { sql } from "kysely"; +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { availabilityInput } from "@floyd-run/schema/inputs"; +import { InputError, NotFoundError } from "lib/errors"; +import { resolveServiceDays, computeWindows } from "domain/scheduling/availability"; +import type { PolicyConfig } from "domain/policy/evaluate"; +import type { BlockingAllocation } from "domain/scheduling/availability"; + +const MAX_WINDOWS_RANGE_MS = 31 * 24 * 60 * 60_000; // 31 days + +export default createOperation({ + input: availabilityInput.windows, + execute: async (input) => { + const { ledgerId, serviceId, startTime, endTime, includeUnavailable } = input; + + // Validate query window + if (endTime.getTime() - startTime.getTime() > MAX_WINDOWS_RANGE_MS) { + throw new InputError([ + { code: "custom", message: "Query range exceeds maximum of 31 days", path: ["endTime"] }, + ]); + } + + if (endTime <= startTime) { + throw new InputError([ + { code: "custom", message: "endTime must be after startTime", path: ["endTime"] }, + ]); + } + + // 1. Load service + const service = await db + .selectFrom("services") + .selectAll() + .where("id", "=", serviceId) + .where("ledgerId", "=", ledgerId) + .executeTakeFirst(); + + if (!service) throw new NotFoundError("Service not found"); + + // 2. Load service's resources + const serviceResourceRows = await db + .selectFrom("serviceResources") + .select("resourceId") + .where("serviceId", "=", serviceId) + .execute(); + + const allServiceResourceIds = new Set(serviceResourceRows.map((r) => r.resourceId)); + + let targetResourceIds: string[]; + if (input.resourceIds && input.resourceIds.length > 0) { + for (const rid of input.resourceIds) { + if (!allServiceResourceIds.has(rid)) { + throw new InputError([ + { + code: "custom", + message: `Resource ${rid} does not belong to service ${serviceId}`, + path: ["resourceIds"], + }, + ]); + } + } + targetResourceIds = input.resourceIds; + } else { + targetResourceIds = [...allServiceResourceIds]; + } + + if (targetResourceIds.length === 0) { + const serverTime = await getServerTime(db); + return { data: [], serverTime }; + } + + // 3. Load resources (for timezone) + const resources = await db + .selectFrom("resources") + .select(["id", "timezone"]) + .where("id", "in", targetResourceIds) + .execute(); + + const resourceMap = new Map(resources.map((r) => [r.id, r])); + + // 4. Capture serverTime + const serverTime = await getServerTime(db); + + // 5. Load policy + let policy: PolicyConfig | null = null; + if (service.policyId) { + const policyRow = await db + .selectFrom("policies") + .selectAll() + .where("id", "=", service.policyId) + .executeTakeFirst(); + + if (policyRow) { + policy = policyRow.config as unknown as PolicyConfig; + } + } + + // 6. Fetch allocations + const blockingAllocations = + targetResourceIds.length > 0 + ? await sql` + SELECT resource_id, start_time, end_time + FROM allocations + WHERE ledger_id = ${ledgerId} + AND resource_id = ANY(ARRAY[${sql.join(targetResourceIds.map((id) => sql`${id}`))}]::text[]) + AND active = true + AND (expires_at IS NULL OR expires_at > clock_timestamp()) + AND start_time < ${endTime} + AND end_time > ${startTime} + ORDER BY resource_id, start_time + `.execute(db) + : { rows: [] }; + + // Group allocations by resource + const allocByResource = new Map(); + for (const id of targetResourceIds) { + allocByResource.set(id, []); + } + for (const row of blockingAllocations.rows) { + const list = allocByResource.get(row.resourceId); + if (list) list.push(row); + } + + // 7. Compute windows per resource + const data = targetResourceIds.map((resourceId) => { + const resource = resourceMap.get(resourceId); + const timezone = resource!.timezone; + const allocs = allocByResource.get(resourceId) ?? []; + + const resolvedDays = resolveServiceDays(policy, startTime, endTime, timezone); + const windows = computeWindows( + resolvedDays, + allocs, + serverTime, + timezone, + includeUnavailable, + startTime, + endTime, + ); + + return { resourceId, timezone, windows }; + }); + + return { data, serverTime }; + }, +}); diff --git a/apps/server/src/operations/booking/cancel.ts b/apps/server/src/operations/booking/cancel.ts new file mode 100644 index 0000000..fa01dd6 --- /dev/null +++ b/apps/server/src/operations/booking/cancel.ts @@ -0,0 +1,79 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { bookingInput } from "@floyd-run/schema/inputs"; +import { ConflictError, NotFoundError } from "lib/errors"; +import { enqueueWebhookEvent } from "infra/webhooks"; +import { serializeBooking } from "routes/v1/serializers"; + +export default createOperation({ + input: bookingInput.cancel, + 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 === "canceled") { + // Idempotent — already canceled + const allocations = await trx + .selectFrom("allocations") + .selectAll() + .where("bookingId", "=", existing.id) + .execute(); + return { booking: existing, allocations, serverTime }; + } + + if (existing.status !== "hold" && existing.status !== "confirmed") { + throw new ConflictError("booking.invalid_transition", { + currentStatus: existing.status, + requestedStatus: "canceled", + }); + } + + // 4. Update booking + const booking = await trx + .updateTable("bookings") + .set({ + status: "canceled", + expiresAt: null, + updatedAt: serverTime, + }) + .where("id", "=", input.id) + .returningAll() + .executeTakeFirstOrThrow(); + + // 5. Deactivate allocations + await trx + .updateTable("allocations") + .set({ active: false, expiresAt: null, updatedAt: serverTime }) + .where("bookingId", "=", input.id) + .execute(); + + const allocations = await trx + .selectFrom("allocations") + .selectAll() + .where("bookingId", "=", input.id) + .execute(); + + // 6. Enqueue webhook + await enqueueWebhookEvent(trx, "booking.canceled", booking.ledgerId, { + booking: serializeBooking(booking, allocations), + }); + + return { booking, allocations, serverTime }; + }); + }, +}); diff --git a/apps/server/src/operations/booking/confirm.ts b/apps/server/src/operations/booking/confirm.ts new file mode 100644 index 0000000..d630b02 --- /dev/null +++ b/apps/server/src/operations/booking/confirm.ts @@ -0,0 +1,87 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { bookingInput } from "@floyd-run/schema/inputs"; +import { ConflictError, NotFoundError } from "lib/errors"; +import { enqueueWebhookEvent } from "infra/webhooks"; +import { serializeBooking } from "routes/v1/serializers"; + +export default createOperation({ + input: bookingInput.confirm, + 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 === "confirmed") { + // Idempotent — already confirmed + const allocations = await trx + .selectFrom("allocations") + .selectAll() + .where("bookingId", "=", existing.id) + .execute(); + return { booking: existing, allocations, serverTime }; + } + + if (existing.status !== "hold") { + throw new ConflictError("booking.invalid_transition", { + currentStatus: existing.status, + requestedStatus: "confirmed", + }); + } + + // 4. Check if hold has expired + if (existing.expiresAt && serverTime >= existing.expiresAt) { + throw new ConflictError("booking.hold_expired", { + expiresAt: existing.expiresAt, + serverTime, + }); + } + + // 5. Update booking + const booking = await trx + .updateTable("bookings") + .set({ + status: "confirmed", + expiresAt: null, + updatedAt: serverTime, + }) + .where("id", "=", input.id) + .returningAll() + .executeTakeFirstOrThrow(); + + // 6. Update allocations + await trx + .updateTable("allocations") + .set({ expiresAt: null, updatedAt: serverTime }) + .where("bookingId", "=", input.id) + .execute(); + + const allocations = await trx + .selectFrom("allocations") + .selectAll() + .where("bookingId", "=", input.id) + .execute(); + + // 7. Enqueue webhook + await enqueueWebhookEvent(trx, "booking.confirmed", booking.ledgerId, { + booking: serializeBooking(booking, allocations), + }); + + return { booking, allocations, serverTime }; + }); + }, +}); diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts new file mode 100644 index 0000000..8b3d2f8 --- /dev/null +++ b/apps/server/src/operations/booking/create.ts @@ -0,0 +1,143 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { generateId } from "@floyd-run/utils"; +import { bookingInput } from "@floyd-run/schema/inputs"; +import { ConflictError, NotFoundError } from "lib/errors"; +import { enqueueWebhookEvent } from "infra/webhooks"; +import { serializeBooking } 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.create, + execute: async (input) => { + return await db.transaction().execute(async (trx) => { + // 1. Lock resource row (serializes concurrent writes) + const resource = await trx + .selectFrom("resources") + .selectAll() + .where("id", "=", input.resourceId) + .where("ledgerId", "=", input.ledgerId) + .forUpdate() + .executeTakeFirst(); + + if (!resource) { + throw new NotFoundError("Resource not found"); + } + + // 2. Capture server time + const serverTime = await getServerTime(trx); + + // 3. Load service + const service = await trx + .selectFrom("services") + .selectAll() + .where("id", "=", input.serviceId) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + if (!service) { + throw new NotFoundError("Service not found"); + } + + // 4. Verify resource belongs to service + const serviceResource = await trx + .selectFrom("serviceResources") + .select("resourceId") + .where("serviceId", "=", input.serviceId) + .where("resourceId", "=", input.resourceId) + .executeTakeFirst(); + + if (!serviceResource) { + throw new ConflictError("service.resource_not_member", { + serviceId: input.serviceId, + resourceId: input.resourceId, + }); + } + + // 5. Policy evaluation (if service has a policy) + let holdDurationMs = DEFAULT_HOLD_DURATION_MS; + let startTime = input.startTime; + let endTime = input.endTime; + let bufferBeforeMs = 0; + let bufferAfterMs = 0; + if (service.policyId) { + const policy = await trx + .selectFrom("policies") + .selectAll() + .where("id", "=", service.policyId) + .executeTakeFirst(); + + if (policy) { + const timezone = resource.timezone; + const result = evaluatePolicy( + policy.config as unknown as PolicyConfig, + { startTime: input.startTime, endTime: input.endTime }, + { decisionTime: serverTime, timezone }, + ); + + if (!result.allowed) { + throw new ConflictError("policy.rejected", { + code: result.code, + message: result.message, + ...("details" in result ? { details: result.details } : {}), + }); + } + + // Use buffer-expanded times as the allocation's blocked window + startTime = result.effectiveStartTime; + endTime = result.effectiveEndTime; + bufferBeforeMs = result.bufferBeforeMs; + bufferAfterMs = result.bufferAfterMs; + + // Use resolved hold_duration (respects per-rule overrides) + if (result.resolvedConfig.hold?.duration_ms !== undefined) { + holdDurationMs = result.resolvedConfig.hold.duration_ms; + } + } + } + + // 6. Compute expiresAt + const isHold = input.status === "hold"; + const expiresAt = isHold ? new Date(serverTime.getTime() + holdDurationMs) : null; + + // 7. Insert booking + const booking = await trx + .insertInto("bookings") + .values({ + id: generateId("bkg"), + ledgerId: input.ledgerId, + serviceId: input.serviceId, + policyId: service.policyId, + status: input.status, + expiresAt, + metadata: input.metadata ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // 8. Conflict check + insert allocation (startTime/endTime = blocked window including buffers) + const allocation = await insertAllocation(trx, { + ledgerId: input.ledgerId, + resourceId: input.resourceId, + bookingId: booking.id, + startTime, + endTime, + bufferBeforeMs, + bufferAfterMs, + expiresAt, + metadata: null, + serverTime, + }); + + // 9. Enqueue webhook + await enqueueWebhookEvent(trx, "booking.created", input.ledgerId, { + booking: serializeBooking(booking, [allocation]), + }); + + return { booking, allocations: [allocation], serverTime }; + }); + }, +}); diff --git a/apps/server/src/operations/booking/get.ts b/apps/server/src/operations/booking/get.ts new file mode 100644 index 0000000..f703c0a --- /dev/null +++ b/apps/server/src/operations/booking/get.ts @@ -0,0 +1,27 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { bookingInput } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: bookingInput.get, + execute: async (input) => { + const booking = await db + .selectFrom("bookings") + .selectAll() + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + if (!booking) { + return { booking: null, allocations: [] }; + } + + const allocations = await db + .selectFrom("allocations") + .selectAll() + .where("bookingId", "=", booking.id) + .execute(); + + return { booking, allocations }; + }, +}); diff --git a/apps/server/src/services/allocation/index.ts b/apps/server/src/operations/booking/index.ts similarity index 87% rename from apps/server/src/services/allocation/index.ts rename to apps/server/src/operations/booking/index.ts index f8a0c4c..9cfd9c5 100644 --- a/apps/server/src/services/allocation/index.ts +++ b/apps/server/src/operations/booking/index.ts @@ -1,13 +1,13 @@ -import cancel from "./cancel"; -import confirm from "./confirm"; import create from "./create"; +import confirm from "./confirm"; +import cancel from "./cancel"; import get from "./get"; import list from "./list"; -export const allocation = { - cancel, - confirm, +export const booking = { create, + confirm, + cancel, get, list, }; diff --git a/apps/server/src/operations/booking/list.ts b/apps/server/src/operations/booking/list.ts new file mode 100644 index 0000000..826f03e --- /dev/null +++ b/apps/server/src/operations/booking/list.ts @@ -0,0 +1,49 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { bookingInput } from "@floyd-run/schema/inputs"; +import type { AllocationRow } from "database/schema"; + +export default createOperation({ + input: bookingInput.list, + execute: async (input) => { + const bookings = await db + .selectFrom("bookings") + .selectAll() + .where("ledgerId", "=", input.ledgerId) + .execute(); + + // Batch load allocations for all bookings + const allocationsByBooking = new Map(); + for (const booking of bookings) { + allocationsByBooking.set(booking.id, []); + } + + if (bookings.length > 0) { + const allocations = await db + .selectFrom("allocations") + .selectAll() + .where( + "bookingId", + "in", + bookings.map((b) => b.id), + ) + .execute(); + + for (const allocation of allocations) { + if (allocation.bookingId) { + const list = allocationsByBooking.get(allocation.bookingId); + if (list) { + list.push(allocation); + } + } + } + } + + return { + bookings: bookings.map((booking) => ({ + booking, + allocations: allocationsByBooking.get(booking.id) ?? [], + })), + }; + }, +}); diff --git a/apps/server/src/services/index.ts b/apps/server/src/operations/index.ts similarity index 61% rename from apps/server/src/services/index.ts rename to apps/server/src/operations/index.ts index 10ba2f1..47f0547 100644 --- a/apps/server/src/services/index.ts +++ b/apps/server/src/operations/index.ts @@ -3,11 +3,17 @@ import { availability } from "./availability"; import { resource } from "./resource"; import { ledger } from "./ledger"; import { webhook } from "./webhook"; +import { policy } from "./policy"; +import { service } from "./service"; +import { booking } from "./booking"; -export const services = { +export const operations = { allocation, availability, resource, ledger, webhook, + policy, + service, + booking, }; diff --git a/apps/server/src/services/ledger/create.ts b/apps/server/src/operations/ledger/create.ts similarity index 78% rename from apps/server/src/services/ledger/create.ts rename to apps/server/src/operations/ledger/create.ts index d4c732b..b48947f 100644 --- a/apps/server/src/services/ledger/create.ts +++ b/apps/server/src/operations/ledger/create.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; -export default createService({ +export default createOperation({ execute: async () => { const ledger = await db .insertInto("ledgers") diff --git a/apps/server/src/services/ledger/get.ts b/apps/server/src/operations/ledger/get.ts similarity index 58% rename from apps/server/src/services/ledger/get.ts rename to apps/server/src/operations/ledger/get.ts index 5efccc1..ce5443c 100644 --- a/apps/server/src/services/ledger/get.ts +++ b/apps/server/src/operations/ledger/get.ts @@ -1,9 +1,9 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { ledger } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { ledgerInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: ledger.getSchema, +export default createOperation({ + input: ledgerInput.get, execute: async (input) => { const ledger = await db .selectFrom("ledgers") diff --git a/apps/server/src/services/ledger/index.ts b/apps/server/src/operations/ledger/index.ts similarity index 100% rename from apps/server/src/services/ledger/index.ts rename to apps/server/src/operations/ledger/index.ts diff --git a/apps/server/src/services/ledger/list.ts b/apps/server/src/operations/ledger/list.ts similarity index 66% rename from apps/server/src/services/ledger/list.ts rename to apps/server/src/operations/ledger/list.ts index c7db28d..25eb3a5 100644 --- a/apps/server/src/services/ledger/list.ts +++ b/apps/server/src/operations/ledger/list.ts @@ -1,7 +1,7 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; -export default createService({ +export default createOperation({ execute: async () => { const ledgers = await db.selectFrom("ledgers").selectAll().execute(); return { ledgers }; diff --git a/apps/server/src/operations/policy/create.ts b/apps/server/src/operations/policy/create.ts new file mode 100644 index 0000000..cc92428 --- /dev/null +++ b/apps/server/src/operations/policy/create.ts @@ -0,0 +1,27 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { generateId } from "@floyd-run/utils"; +import { policyInput } from "@floyd-run/schema/inputs"; +import { preparePolicyConfig } from "domain/policy"; + +export default createOperation({ + input: policyInput.create, + execute: async (input) => { + const { normalized, configHash, warnings } = preparePolicyConfig( + input.config as unknown as Record, + ); + + const row = await db + .insertInto("policies") + .values({ + id: generateId("pol"), + ledgerId: input.ledgerId, + config: normalized, + configHash, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return { policy: row, warnings }; + }, +}); diff --git a/apps/server/src/operations/policy/get.ts b/apps/server/src/operations/policy/get.ts new file mode 100644 index 0000000..7c6563f --- /dev/null +++ b/apps/server/src/operations/policy/get.ts @@ -0,0 +1,17 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { policyInput } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: policyInput.get, + execute: async (input) => { + const row = await db + .selectFrom("policies") + .selectAll() + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + return { policy: row ?? null }; + }, +}); diff --git a/apps/server/src/operations/policy/index.ts b/apps/server/src/operations/policy/index.ts new file mode 100644 index 0000000..8f89ab9 --- /dev/null +++ b/apps/server/src/operations/policy/index.ts @@ -0,0 +1,13 @@ +import create from "./create"; +import get from "./get"; +import list from "./list"; +import update from "./update"; +import remove from "./remove"; + +export const policy = { + create, + get, + list, + update, + remove, +}; diff --git a/apps/server/src/operations/policy/list.ts b/apps/server/src/operations/policy/list.ts new file mode 100644 index 0000000..b2524fb --- /dev/null +++ b/apps/server/src/operations/policy/list.ts @@ -0,0 +1,16 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { policyInput } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: policyInput.list, + execute: async (input) => { + const policies = await db + .selectFrom("policies") + .selectAll() + .where("ledgerId", "=", input.ledgerId) + .execute(); + + return { policies }; + }, +}); diff --git a/apps/server/src/operations/policy/remove.ts b/apps/server/src/operations/policy/remove.ts new file mode 100644 index 0000000..fa9b04e --- /dev/null +++ b/apps/server/src/operations/policy/remove.ts @@ -0,0 +1,26 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { policyInput } from "@floyd-run/schema/inputs"; +import { ConflictError } from "lib/errors"; + +export default createOperation({ + input: policyInput.remove, + execute: async (input) => { + try { + const result = await db + .deleteFrom("policies") + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + return { deleted: result.numDeletedRows > 0n }; + } catch (err: unknown) { + if (err instanceof Error && "code" in err && err.code === "23503") { + throw new ConflictError("policy.in_use", { + message: "Policy is referenced by one or more services", + }); + } + throw err; + } + }, +}); diff --git a/apps/server/src/operations/policy/update.ts b/apps/server/src/operations/policy/update.ts new file mode 100644 index 0000000..321a628 --- /dev/null +++ b/apps/server/src/operations/policy/update.ts @@ -0,0 +1,44 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { policyInput } from "@floyd-run/schema/inputs"; +import { NotFoundError } from "lib/errors"; +import { preparePolicyConfig } from "domain/policy"; + +export default createOperation({ + input: policyInput.update, + execute: async (input) => { + return await db.transaction().execute(async (trx) => { + // 1. Verify policy exists (with row lock) + const existing = await trx + .selectFrom("policies") + .selectAll() + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .forUpdate() + .executeTakeFirst(); + + if (!existing) { + throw new NotFoundError("Policy not found"); + } + + // 2. Normalize, validate, canonicalize, hash + const { normalized, configHash, warnings } = preparePolicyConfig( + input.config as unknown as Record, + ); + + // 3. Update + const row = await trx + .updateTable("policies") + .set({ + config: normalized, + configHash, + }) + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .returningAll() + .executeTakeFirstOrThrow(); + + return { policy: row, warnings }; + }); + }, +}); diff --git a/apps/server/src/services/resource/create.ts b/apps/server/src/operations/resource/create.ts similarity index 63% rename from apps/server/src/services/resource/create.ts rename to apps/server/src/operations/resource/create.ts index 7387b65..60f8c7b 100644 --- a/apps/server/src/services/resource/create.ts +++ b/apps/server/src/operations/resource/create.ts @@ -1,16 +1,17 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; -import { resource } from "@floyd-run/schema/inputs"; +import { resourceInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: resource.createSchema, +export default createOperation({ + input: resourceInput.create, execute: async (input) => { const resource = await db .insertInto("resources") .values({ id: generateId("rsc"), ledgerId: input.ledgerId, + timezone: input.timezone, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/src/services/resource/get.ts b/apps/server/src/operations/resource/get.ts similarity index 52% rename from apps/server/src/services/resource/get.ts rename to apps/server/src/operations/resource/get.ts index 503ac1c..459c359 100644 --- a/apps/server/src/services/resource/get.ts +++ b/apps/server/src/operations/resource/get.ts @@ -1,13 +1,14 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { resource } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { resourceInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: resource.getSchema, +export default createOperation({ + input: resourceInput.get, execute: async (input) => { const resource = await db .selectFrom("resources") .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .selectAll() .executeTakeFirst(); diff --git a/apps/server/src/services/resource/index.ts b/apps/server/src/operations/resource/index.ts similarity index 100% rename from apps/server/src/services/resource/index.ts rename to apps/server/src/operations/resource/index.ts diff --git a/apps/server/src/services/resource/list.ts b/apps/server/src/operations/resource/list.ts similarity index 58% rename from apps/server/src/services/resource/list.ts rename to apps/server/src/operations/resource/list.ts index 3bcaded..2ac1571 100644 --- a/apps/server/src/services/resource/list.ts +++ b/apps/server/src/operations/resource/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { resource } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { resourceInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: resource.listSchema, +export default createOperation({ + input: resourceInput.list, execute: async (input) => { const resources = await db .selectFrom("resources") diff --git a/apps/server/src/operations/resource/remove.ts b/apps/server/src/operations/resource/remove.ts new file mode 100644 index 0000000..a32c52d --- /dev/null +++ b/apps/server/src/operations/resource/remove.ts @@ -0,0 +1,28 @@ +import { resourceInput } from "@floyd-run/schema/inputs"; +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { ConflictError, NotFoundError } from "lib/errors"; + +export default createOperation({ + input: resourceInput.remove, + execute: async (input) => { + try { + const result = await db + .deleteFrom("resources") + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + if (result.numDeletedRows === 0n) { + throw new NotFoundError("Resource not found"); + } + } catch (err: unknown) { + if (err instanceof Error && "code" in err && err.code === "23503") { + throw new ConflictError("resource.in_use", { + message: "Resource has active allocations or service associations", + }); + } + throw err; + } + }, +}); diff --git a/apps/server/src/operations/service/create.ts b/apps/server/src/operations/service/create.ts new file mode 100644 index 0000000..61e53db --- /dev/null +++ b/apps/server/src/operations/service/create.ts @@ -0,0 +1,65 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { generateId } from "@floyd-run/utils"; +import { serviceInput } from "@floyd-run/schema/inputs"; +import { NotFoundError } from "lib/errors"; + +export default createOperation({ + input: serviceInput.create, + execute: async (input) => { + return await db.transaction().execute(async (trx) => { + // 1. Validate policyId exists and belongs to ledger (if provided) + if (input.policyId) { + const policy = await trx + .selectFrom("policies") + .select("id") + .where("id", "=", input.policyId) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + if (!policy) { + throw new NotFoundError("Policy not found"); + } + } + + // 2. Validate all resourceIds exist and belong to same ledger + if (input.resourceIds.length > 0) { + const resources = await trx + .selectFrom("resources") + .select("id") + .where("id", "in", input.resourceIds) + .where("ledgerId", "=", input.ledgerId) + .execute(); + + if (resources.length !== input.resourceIds.length) { + const found = new Set(resources.map((r) => r.id)); + const missing = input.resourceIds.filter((id) => !found.has(id)); + throw new NotFoundError(`Resources not found: ${missing.join(", ")}`); + } + } + + // 3. Insert service + const service = await trx + .insertInto("services") + .values({ + id: generateId("svc"), + ledgerId: input.ledgerId, + policyId: input.policyId ?? null, + name: input.name, + metadata: input.metadata ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // 4. Insert service_resources + if (input.resourceIds.length > 0) { + await trx + .insertInto("serviceResources") + .values(input.resourceIds.map((resourceId) => ({ serviceId: service.id, resourceId }))) + .execute(); + } + + return { service, resourceIds: input.resourceIds }; + }); + }, +}); diff --git a/apps/server/src/operations/service/get.ts b/apps/server/src/operations/service/get.ts new file mode 100644 index 0000000..7e6a7a8 --- /dev/null +++ b/apps/server/src/operations/service/get.ts @@ -0,0 +1,27 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { serviceInput } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: serviceInput.get, + execute: async (input) => { + const service = await db + .selectFrom("services") + .selectAll() + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + if (!service) { + return { service: null, resourceIds: [] }; + } + + const resources = await db + .selectFrom("serviceResources") + .select("resourceId") + .where("serviceId", "=", service.id) + .execute(); + + return { service, resourceIds: resources.map((r) => r.resourceId) }; + }, +}); diff --git a/apps/server/src/operations/service/index.ts b/apps/server/src/operations/service/index.ts new file mode 100644 index 0000000..5d493bd --- /dev/null +++ b/apps/server/src/operations/service/index.ts @@ -0,0 +1,13 @@ +import create from "./create"; +import get from "./get"; +import list from "./list"; +import update from "./update"; +import remove from "./remove"; + +export const service = { + create, + get, + list, + update, + remove, +}; diff --git a/apps/server/src/operations/service/list.ts b/apps/server/src/operations/service/list.ts new file mode 100644 index 0000000..39e74be --- /dev/null +++ b/apps/server/src/operations/service/list.ts @@ -0,0 +1,46 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { serviceInput } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: serviceInput.list, + execute: async (input) => { + const services = await db + .selectFrom("services") + .selectAll() + .where("ledgerId", "=", input.ledgerId) + .execute(); + + // Batch load resourceIds for all services + const resourceIdsByService = new Map(); + for (const s of services) { + resourceIdsByService.set(s.id, []); + } + + if (services.length > 0) { + const serviceResources = await db + .selectFrom("serviceResources") + .selectAll() + .where( + "serviceId", + "in", + services.map((s) => s.id), + ) + .execute(); + + for (const serviceResource of serviceResources) { + const list = resourceIdsByService.get(serviceResource.serviceId); + if (list) { + list.push(serviceResource.resourceId); + } + } + } + + return { + services: services.map((s) => ({ + service: s, + resourceIds: resourceIdsByService.get(s.id) ?? [], + })), + }; + }, +}); diff --git a/apps/server/src/operations/service/remove.ts b/apps/server/src/operations/service/remove.ts new file mode 100644 index 0000000..6468bde --- /dev/null +++ b/apps/server/src/operations/service/remove.ts @@ -0,0 +1,55 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { serviceInput } from "@floyd-run/schema/inputs"; +import { ConflictError, NotFoundError } from "lib/errors"; + +export default createOperation({ + input: serviceInput.remove, + execute: async (input) => { + return await db.transaction().execute(async (trx) => { + // 1. Verify service exists + const existing = await trx + .selectFrom("services") + .select("id") + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .forUpdate() + .executeTakeFirst(); + + if (!existing) { + throw new NotFoundError("Service not found"); + } + + // 2. Check for active bookings + const activeBooking = await trx + .selectFrom("bookings") + .select("id") + .where("serviceId", "=", input.id) + .where("status", "in", ["hold", "confirmed"]) + .limit(1) + .executeTakeFirst(); + + if (activeBooking) { + throw new ConflictError("service.active_bookings"); + } + + // 3. Clean up non-active bookings (canceled/expired) and their allocations + const staleBookings = await trx + .selectFrom("bookings") + .select("id") + .where("serviceId", "=", input.id) + .execute(); + + if (staleBookings.length > 0) { + const bookingIds = staleBookings.map((b) => b.id); + await trx.deleteFrom("allocations").where("bookingId", "in", bookingIds).execute(); + await trx.deleteFrom("bookings").where("id", "in", bookingIds).execute(); + } + + // 4. Delete service (CASCADE handles service_resources) + await trx.deleteFrom("services").where("id", "=", input.id).execute(); + + return { deleted: true }; + }); + }, +}); diff --git a/apps/server/src/operations/service/update.ts b/apps/server/src/operations/service/update.ts new file mode 100644 index 0000000..789fb90 --- /dev/null +++ b/apps/server/src/operations/service/update.ts @@ -0,0 +1,78 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { serviceInput } from "@floyd-run/schema/inputs"; +import { NotFoundError } from "lib/errors"; + +export default createOperation({ + input: serviceInput.update, + execute: async (input) => { + return await db.transaction().execute(async (trx) => { + // 1. Lock service row + const existing = await trx + .selectFrom("services") + .selectAll() + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .forUpdate() + .executeTakeFirst(); + + if (!existing) { + throw new NotFoundError("Service not found"); + } + + // 2. Validate policyId (if provided) + if (input.policyId) { + const policy = await trx + .selectFrom("policies") + .select("id") + .where("id", "=", input.policyId) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); + + if (!policy) { + throw new NotFoundError("Policy not found"); + } + } + + // 3. Validate all resourceIds exist and belong to same ledger + if (input.resourceIds.length > 0) { + const resources = await trx + .selectFrom("resources") + .select("id") + .where("id", "in", input.resourceIds) + .where("ledgerId", "=", input.ledgerId) + .execute(); + + if (resources.length !== input.resourceIds.length) { + const found = new Set(resources.map((r) => r.id)); + const missing = input.resourceIds.filter((id) => !found.has(id)); + throw new NotFoundError(`Resources not found: ${missing.join(", ")}`); + } + } + + // 4. Update service row + const service = await trx + .updateTable("services") + .set({ + name: input.name, + policyId: input.policyId ?? null, + metadata: input.metadata ?? null, + }) + .where("id", "=", input.id) + .returningAll() + .executeTakeFirstOrThrow(); + + // 5. Replace service_resources (delete all, re-insert) + await trx.deleteFrom("serviceResources").where("serviceId", "=", input.id).execute(); + + if (input.resourceIds.length > 0) { + await trx + .insertInto("serviceResources") + .values(input.resourceIds.map((resourceId) => ({ serviceId: input.id, resourceId }))) + .execute(); + } + + return { service, resourceIds: input.resourceIds }; + }); + }, +}); diff --git a/apps/server/src/services/webhook/create.ts b/apps/server/src/operations/webhook/create.ts similarity index 58% rename from apps/server/src/services/webhook/create.ts rename to apps/server/src/operations/webhook/create.ts index 7f1cb0d..8dd9c6b 100644 --- a/apps/server/src/services/webhook/create.ts +++ b/apps/server/src/operations/webhook/create.ts @@ -1,15 +1,11 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; -import { webhook } from "@floyd-run/schema/inputs"; -import { randomBytes } from "crypto"; +import { webhookInput } from "@floyd-run/schema/inputs"; +import { generateSecret } from "./generate-secret"; -function generateSecret(): string { - return `whsec_${randomBytes(24).toString("base64url")}`; -} - -export default createService({ - input: webhook.createSubscriptionSchema, +export default createOperation({ + input: webhookInput.createSubscription, execute: async (input) => { const subscription = await db .insertInto("webhookSubscriptions") diff --git a/apps/server/src/operations/webhook/generate-secret.ts b/apps/server/src/operations/webhook/generate-secret.ts new file mode 100644 index 0000000..fc82957 --- /dev/null +++ b/apps/server/src/operations/webhook/generate-secret.ts @@ -0,0 +1,5 @@ +import { randomBytes } from "crypto"; + +export function generateSecret(): string { + return `whsec_${randomBytes(24).toString("base64url")}`; +} diff --git a/apps/server/src/services/webhook/index.ts b/apps/server/src/operations/webhook/index.ts similarity index 100% rename from apps/server/src/services/webhook/index.ts rename to apps/server/src/operations/webhook/index.ts diff --git a/apps/server/src/services/webhook/list.ts b/apps/server/src/operations/webhook/list.ts similarity index 62% rename from apps/server/src/services/webhook/list.ts rename to apps/server/src/operations/webhook/list.ts index 36a788c..233ed2d 100644 --- a/apps/server/src/services/webhook/list.ts +++ b/apps/server/src/operations/webhook/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { webhook } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { webhookInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: webhook.listSubscriptionsSchema, +export default createOperation({ + input: webhookInput.listSubscriptions, execute: async (input) => { const subscriptions = await db .selectFrom("webhookSubscriptions") diff --git a/apps/server/src/services/webhook/remove.ts b/apps/server/src/operations/webhook/remove.ts similarity index 52% rename from apps/server/src/services/webhook/remove.ts rename to apps/server/src/operations/webhook/remove.ts index dfcc81a..491f054 100644 --- a/apps/server/src/services/webhook/remove.ts +++ b/apps/server/src/operations/webhook/remove.ts @@ -1,13 +1,14 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { webhook } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { webhookInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: webhook.deleteSubscriptionSchema, +export default createOperation({ + input: webhookInput.deleteSubscription, execute: async (input) => { const result = await db .deleteFrom("webhookSubscriptions") .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); return { deleted: result.numDeletedRows > 0n }; diff --git a/apps/server/src/services/webhook/rotate-secret.ts b/apps/server/src/operations/webhook/rotate-secret.ts similarity index 55% rename from apps/server/src/services/webhook/rotate-secret.ts rename to apps/server/src/operations/webhook/rotate-secret.ts index 7f13308..28b2300 100644 --- a/apps/server/src/services/webhook/rotate-secret.ts +++ b/apps/server/src/operations/webhook/rotate-secret.ts @@ -1,14 +1,10 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { webhook } from "@floyd-run/schema/inputs"; -import { randomBytes } from "crypto"; +import { createOperation } from "lib/operation"; +import { webhookInput } from "@floyd-run/schema/inputs"; +import { generateSecret } from "./generate-secret"; -function generateSecret(): string { - return `whsec_${randomBytes(24).toString("base64url")}`; -} - -export default createService({ - input: webhook.rotateSecretSchema, +export default createOperation({ + input: webhookInput.rotateSecret, execute: async (input) => { const newSecret = generateSecret(); @@ -16,6 +12,7 @@ export default createService({ .updateTable("webhookSubscriptions") .set({ secret: newSecret }) .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .returningAll() .executeTakeFirst(); diff --git a/apps/server/src/services/webhook/update.ts b/apps/server/src/operations/webhook/update.ts similarity index 61% rename from apps/server/src/services/webhook/update.ts rename to apps/server/src/operations/webhook/update.ts index 50b5726..932e45b 100644 --- a/apps/server/src/services/webhook/update.ts +++ b/apps/server/src/operations/webhook/update.ts @@ -1,9 +1,9 @@ import { db } from "database"; -import { createService } from "lib/service"; -import { webhook } from "@floyd-run/schema/inputs"; +import { createOperation } from "lib/operation"; +import { webhookInput } from "@floyd-run/schema/inputs"; -export default createService({ - input: webhook.updateSubscriptionSchema, +export default createOperation({ + input: webhookInput.updateSubscription, execute: async (input) => { const subscription = await db .updateTable("webhookSubscriptions") @@ -11,6 +11,7 @@ export default createService({ ...(input.url !== undefined && { url: input.url }), }) .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .returningAll() .executeTakeFirst(); diff --git a/apps/server/src/routes/v1/allocations.ts b/apps/server/src/routes/v1/allocations.ts index c3c842f..4149a37 100644 --- a/apps/server/src/routes/v1/allocations.ts +++ b/apps/server/src/routes/v1/allocations.ts @@ -1,52 +1,45 @@ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; -import { idempotent, storeIdempotencyResponse, IdempotencyVariables } from "infra/idempotency"; +import { idempotent, storeIdempotencyResponse, type IdempotencyVariables } from "infra/idempotency"; import { serializeAllocation } from "./serializers"; // Significant fields for allocation create idempotency hash -const ALLOCATION_SIGNIFICANT_FIELDS = ["resourceId", "startAt", "endAt", "status", "expiresAt"]; +const ALLOCATION_SIGNIFICANT_FIELDS = ["resourceId", "startTime", "endTime", "expiresAt"]; // Nested under /v1/ledgers/:ledgerId/allocations export const allocations = new Hono<{ Variables: IdempotencyVariables }>() .get("/", async (c) => { - const { allocations } = await services.allocation.list({ + const { allocations } = await operations.allocation.list({ ledgerId: c.req.param("ledgerId")!, }); return c.json({ data: allocations.map(serializeAllocation) }); }) .get("/:id", async (c) => { - const { allocation } = await services.allocation.get({ id: c.req.param("id") }); + const { allocation } = await operations.allocation.get({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); if (!allocation) throw new NotFoundError("Allocation not found"); return c.json({ data: serializeAllocation(allocation) }); }) .post("/", idempotent({ significantFields: ALLOCATION_SIGNIFICANT_FIELDS }), async (c) => { - const body = c.get("parsedBody") || (await c.req.json()); - const { allocation, serverTime } = await services.allocation.create({ + const body = c.get("parsedBody") ?? (await c.req.json()); + const { allocation, serverTime } = await operations.allocation.create({ ...(body as object), - ledgerId: c.req.param("ledgerId")!, - } as Parameters[0]); + ledgerId: c.req.param("ledgerId"), + } as Parameters[0]); const responseBody = { data: serializeAllocation(allocation), meta: { serverTime } }; await storeIdempotencyResponse(c, responseBody, 201); return c.json(responseBody, 201); }) - .post("/:id/confirm", idempotent(), async (c) => { - const { allocation, serverTime } = await services.allocation.confirm({ - id: c.req.param("id"), - }); - const responseBody = { data: serializeAllocation(allocation), meta: { serverTime } }; - await storeIdempotencyResponse(c, responseBody, 200); - return c.json(responseBody); - }) - - .post("/:id/cancel", idempotent(), async (c) => { - const { allocation, serverTime } = await services.allocation.cancel({ + .delete("/:id", async (c) => { + await operations.allocation.remove({ id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, }); - const responseBody = { data: serializeAllocation(allocation), meta: { serverTime } }; - await storeIdempotencyResponse(c, responseBody, 200); - return c.json(responseBody); + return c.body(null, 204); }); diff --git a/apps/server/src/routes/v1/availability.ts b/apps/server/src/routes/v1/availability.ts index 8d92bb4..941b32d 100644 --- a/apps/server/src/routes/v1/availability.ts +++ b/apps/server/src/routes/v1/availability.ts @@ -1,16 +1,19 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "operations"; // Nested under /v1/ledgers/:ledgerId/availability export const availability = new Hono().post("/", async (c) => { const ledgerId = c.req.param("ledgerId")!; const body = await c.req.json(); - const result = await services.availability.query({ + const result = await operations.availability.query({ ledgerId, resourceIds: body.resourceIds, - startAt: body.startAt, - endAt: body.endAt, + startTime: body.startTime, + endTime: body.endTime, }); return c.json({ data: result.items }); diff --git a/apps/server/src/routes/v1/bookings.ts b/apps/server/src/routes/v1/bookings.ts new file mode 100644 index 0000000..edcdb90 --- /dev/null +++ b/apps/server/src/routes/v1/bookings.ts @@ -0,0 +1,59 @@ +import { Hono } from "hono"; +import { operations } from "operations"; +import { NotFoundError } from "lib/errors"; +import { idempotent, storeIdempotencyResponse, type IdempotencyVariables } from "infra/idempotency"; +import { serializeBooking } from "./serializers"; + +// Significant fields for booking create idempotency hash +const BOOKING_SIGNIFICANT_FIELDS = ["serviceId", "resourceId", "startTime", "endTime", "status"]; + +// Nested under /v1/ledgers/:ledgerId/bookings +export const bookings = new Hono<{ Variables: IdempotencyVariables }>() + .get("/", async (c) => { + const { bookings } = await operations.booking.list({ + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ + data: bookings.map(({ booking, allocations }) => serializeBooking(booking, allocations)), + }); + }) + + .get("/:id", async (c) => { + const { booking, allocations } = await operations.booking.get({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); + if (!booking) throw new NotFoundError("Booking not found"); + return c.json({ data: serializeBooking(booking, allocations) }); + }) + + .post("/", idempotent({ significantFields: BOOKING_SIGNIFICANT_FIELDS }), async (c) => { + const body = c.get("parsedBody") ?? (await c.req.json()); + const { booking, allocations, serverTime } = await operations.booking.create({ + ...(body as object), + ledgerId: c.req.param("ledgerId"), + } as Parameters[0]); + const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } }; + await storeIdempotencyResponse(c, responseBody, 201); + return c.json(responseBody, 201); + }) + + .post("/:id/confirm", idempotent(), async (c) => { + const { booking, allocations, serverTime } = await operations.booking.confirm({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId"), + }); + const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } }; + await storeIdempotencyResponse(c, responseBody, 200); + return c.json(responseBody); + }) + + .post("/:id/cancel", idempotent(), async (c) => { + const { booking, allocations, serverTime } = await operations.booking.cancel({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId"), + }); + const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } }; + await storeIdempotencyResponse(c, responseBody, 200); + return c.json(responseBody); + }); diff --git a/apps/server/src/routes/v1/index.ts b/apps/server/src/routes/v1/index.ts index 9643d92..d122765 100644 --- a/apps/server/src/routes/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -4,10 +4,16 @@ import { availability } from "./availability"; import { resources } from "./resources"; import { ledgers } from "./ledgers"; import { webhooks } from "./webhooks"; +import { policies } from "./policies"; +import { services } from "./services"; +import { bookings } from "./bookings"; export const v1 = new Hono() .route("/ledgers", ledgers) .route("/ledgers/:ledgerId/resources", resources) .route("/ledgers/:ledgerId/allocations", allocations) .route("/ledgers/:ledgerId/availability", availability) - .route("/ledgers/:ledgerId/webhooks", webhooks); + .route("/ledgers/:ledgerId/webhooks", webhooks) + .route("/ledgers/:ledgerId/policies", policies) + .route("/ledgers/:ledgerId/services", services) + .route("/ledgers/:ledgerId/bookings", bookings); diff --git a/apps/server/src/routes/v1/ledgers.ts b/apps/server/src/routes/v1/ledgers.ts index 0fc0d02..a3d011b 100644 --- a/apps/server/src/routes/v1/ledgers.ts +++ b/apps/server/src/routes/v1/ledgers.ts @@ -1,21 +1,21 @@ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { serializeLedger } from "./serializers"; export const ledgers = new Hono() .get("/", async (c) => { - const { ledgers } = await services.ledger.list(); + const { ledgers } = await operations.ledger.list(); return c.json({ data: ledgers.map(serializeLedger) }); }) .get("/:id", async (c) => { - const { ledger } = await services.ledger.get({ id: c.req.param("id") }); + const { ledger } = await operations.ledger.get({ id: c.req.param("id") }); if (!ledger) throw new NotFoundError("Ledger not found"); return c.json({ data: serializeLedger(ledger) }); }) .post("/", async (c) => { - const { ledger } = await services.ledger.create(); + const { ledger } = await operations.ledger.create(); return c.json({ data: serializeLedger(ledger) }, 201); }); diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts new file mode 100644 index 0000000..6d7523c --- /dev/null +++ b/apps/server/src/routes/v1/policies.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Hono } from "hono"; +import { operations } from "operations"; +import { NotFoundError } from "lib/errors"; +import { serializePolicy } from "./serializers"; + +// Nested under /v1/ledgers/:ledgerId/policies +export const policies = new Hono() + .get("/", async (c) => { + const { policies } = await operations.policy.list({ + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ data: policies.map(serializePolicy) }); + }) + + .get("/:id", async (c) => { + const { policy } = await operations.policy.get({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); + if (!policy) throw new NotFoundError("Policy not found"); + return c.json({ data: serializePolicy(policy) }); + }) + + .post("/", async (c) => { + const body = await c.req.json(); + const { policy, warnings } = await operations.policy.create({ + ...(body as object), + ledgerId: c.req.param("ledgerId")!, + } as Parameters[0]); + + const responseBody: Record = { data: serializePolicy(policy) }; + if (warnings.length > 0) { + responseBody["meta"] = { warnings }; + } + return c.json(responseBody, 201); + }) + + .put("/:id", async (c) => { + const body = await c.req.json(); + const { policy, warnings } = await operations.policy.update({ + ...(body as object), + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + } as Parameters[0]); + + const responseBody: Record = { data: serializePolicy(policy) }; + if (warnings.length > 0) { + responseBody["meta"] = { warnings }; + } + return c.json(responseBody); + }) + + .delete("/:id", async (c) => { + const { deleted } = await operations.policy.remove({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); + if (!deleted) throw new NotFoundError("Policy not found"); + return c.body(null, 204); + }); diff --git a/apps/server/src/routes/v1/resources.ts b/apps/server/src/routes/v1/resources.ts index 7130525..0b97fbf 100644 --- a/apps/server/src/routes/v1/resources.ts +++ b/apps/server/src/routes/v1/resources.ts @@ -1,33 +1,38 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { serializeResource } from "./serializers"; // Nested under /v1/ledgers/:ledgerId/resources export const resources = new Hono() .get("/", async (c) => { - const { resources } = await services.resource.list({ + const { resources } = await operations.resource.list({ ledgerId: c.req.param("ledgerId")!, }); return c.json({ data: resources.map(serializeResource) }); }) .get("/:id", async (c) => { - const { resource } = await services.resource.get({ id: c.req.param("id") }); + const { resource } = await operations.resource.get({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); if (!resource) throw new NotFoundError("Resource not found"); return c.json({ data: serializeResource(resource) }); }) .post("/", async (c) => { const body = await c.req.json(); - const { resource } = await services.resource.create({ + const { resource } = await operations.resource.create({ ...body, - ledgerId: c.req.param("ledgerId"), + ledgerId: c.req.param("ledgerId")!, }); return c.json({ data: serializeResource(resource) }, 201); }) .delete("/:id", async (c) => { - await services.resource.remove({ id: c.req.param("id") }); + await operations.resource.remove({ id: c.req.param("id"), ledgerId: c.req.param("ledgerId")! }); return c.body(null, 204); }); diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index 98d0746..caadfdc 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -1,10 +1,26 @@ -import { AllocationRow, ResourceRow, LedgerRow, WebhookSubscriptionRow } from "database/schema"; -import { Allocation, Resource, Ledger } from "@floyd-run/schema/types"; +import type { + AllocationRow, + ResourceRow, + LedgerRow, + WebhookSubscriptionRow, + PolicyRow, + ServiceRow, + BookingRow, +} from "database/schema"; +import type { + Allocation, + Resource, + Ledger, + Policy, + Service, + Booking, +} from "@floyd-run/schema/types"; export function serializeResource(resource: ResourceRow): Resource { return { id: resource.id, ledgerId: resource.ledgerId, + timezone: resource.timezone, createdAt: resource.createdAt.toISOString(), updatedAt: resource.updatedAt.toISOString(), }; @@ -23,9 +39,14 @@ export function serializeAllocation(allocation: AllocationRow): Allocation { id: allocation.id, ledgerId: allocation.ledgerId, resourceId: allocation.resourceId, - status: allocation.status, - startAt: allocation.startAt.toISOString(), - endAt: allocation.endAt.toISOString(), + bookingId: allocation.bookingId, + active: allocation.active, + startTime: allocation.startTime.toISOString(), + endTime: allocation.endTime.toISOString(), + buffer: { + beforeMs: allocation.bufferBeforeMs, + afterMs: allocation.bufferAfterMs, + }, expiresAt: allocation.expiresAt?.toISOString() ?? null, metadata: allocation.metadata, createdAt: allocation.createdAt.toISOString(), @@ -50,3 +71,52 @@ export function serializeWebhookSubscription(sub: WebhookSubscriptionRow): Webho updatedAt: sub.updatedAt.toISOString(), }; } + +export function serializePolicy(policy: PolicyRow): Policy { + return { + id: policy.id, + ledgerId: policy.ledgerId, + config: policy.config, + configHash: policy.configHash, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + }; +} + +export function serializeService(service: ServiceRow, resourceIds: string[]): Service { + return { + id: service.id, + ledgerId: service.ledgerId, + name: service.name, + policyId: service.policyId, + resourceIds, + metadata: service.metadata, + createdAt: service.createdAt.toISOString(), + updatedAt: service.updatedAt.toISOString(), + }; +} + +export function serializeBooking(booking: BookingRow, allocations: AllocationRow[]): Booking { + return { + id: booking.id, + ledgerId: booking.ledgerId, + serviceId: booking.serviceId, + policyId: booking.policyId, + status: booking.status, + expiresAt: booking.expiresAt?.toISOString() ?? null, + allocations: allocations.map((a) => ({ + id: a.id, + resourceId: a.resourceId, + startTime: a.startTime.toISOString(), + endTime: a.endTime.toISOString(), + buffer: { + beforeMs: a.bufferBeforeMs, + afterMs: a.bufferAfterMs, + }, + active: a.active, + })), + metadata: booking.metadata, + createdAt: booking.createdAt.toISOString(), + updatedAt: booking.updatedAt.toISOString(), + }; +} diff --git a/apps/server/src/routes/v1/services.ts b/apps/server/src/routes/v1/services.ts new file mode 100644 index 0000000..8774b04 --- /dev/null +++ b/apps/server/src/routes/v1/services.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { Hono } from "hono"; +import { operations } from "operations"; +import { NotFoundError } from "lib/errors"; +import { serializeService } from "./serializers"; + +// Nested under /v1/ledgers/:ledgerId/services +export const services = new Hono() + .get("/", async (c) => { + const { services } = await operations.service.list({ + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ + data: services.map(({ service, resourceIds }) => serializeService(service, resourceIds)), + }); + }) + + .get("/:id", async (c) => { + const { service, resourceIds } = await operations.service.get({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); + if (!service) throw new NotFoundError("Service not found"); + return c.json({ data: serializeService(service, resourceIds) }); + }) + + .post("/", async (c) => { + const body = await c.req.json(); + const { service, resourceIds } = await operations.service.create({ + ...body, + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ data: serializeService(service, resourceIds) }, 201); + }) + + .put("/:id", async (c) => { + const body = await c.req.json(); + const { service, resourceIds } = await operations.service.update({ + ...body, + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ data: serializeService(service, resourceIds) }); + }) + + .delete("/:id", async (c) => { + await operations.service.remove({ id: c.req.param("id"), ledgerId: c.req.param("ledgerId")! }); + return c.body(null, 204); + }) + + .post("/:id/availability/slots", async (c) => { + const body = await c.req.json(); + const result = await operations.availability.slots({ + ...body, + serviceId: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ + data: result.data, + meta: { serverTime: result.serverTime.toISOString() }, + }); + }) + + .post("/:id/availability/windows", async (c) => { + const body = await c.req.json(); + const result = await operations.availability.windows({ + ...body, + serviceId: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ + data: result.data, + meta: { serverTime: result.serverTime.toISOString() }, + }); + }); diff --git a/apps/server/src/routes/v1/webhooks.ts b/apps/server/src/routes/v1/webhooks.ts index 859bcd8..b9c9121 100644 --- a/apps/server/src/routes/v1/webhooks.ts +++ b/apps/server/src/routes/v1/webhooks.ts @@ -1,5 +1,7 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { serializeWebhookSubscription } from "./serializers"; @@ -7,7 +9,7 @@ import { serializeWebhookSubscription } from "./serializers"; export const webhooks = new Hono() // List subscriptions .get("/", async (c) => { - const { subscriptions } = await services.webhook.list({ + const { subscriptions } = await operations.webhook.list({ ledgerId: c.req.param("ledgerId")!, }); return c.json({ data: subscriptions.map(serializeWebhookSubscription) }); @@ -16,7 +18,7 @@ export const webhooks = new Hono() // Create subscription .post("/", async (c) => { const body = await c.req.json(); - const { subscription } = await services.webhook.create({ + const { subscription } = await operations.webhook.create({ ...body, ledgerId: c.req.param("ledgerId")!, }); @@ -34,11 +36,12 @@ export const webhooks = new Hono() }) // Update subscription - .patch("/:subscriptionId", async (c) => { + .patch("/:id", async (c) => { const body = await c.req.json(); - const { subscription } = await services.webhook.update({ + const { subscription } = await operations.webhook.update({ ...body, - id: c.req.param("subscriptionId")!, + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, }); if (!subscription) { @@ -49,9 +52,10 @@ export const webhooks = new Hono() }) // Delete subscription - .delete("/:subscriptionId", async (c) => { - const { deleted } = await services.webhook.remove({ - id: c.req.param("subscriptionId")!, + .delete("/:id", async (c) => { + const { deleted } = await operations.webhook.remove({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, }); if (!deleted) { @@ -62,9 +66,10 @@ export const webhooks = new Hono() }) // Rotate secret - .post("/:subscriptionId/rotate-secret", async (c) => { - const { subscription, secret } = await services.webhook.rotateSecret({ - id: c.req.param("subscriptionId")!, + .post("/:id/rotate-secret", async (c) => { + const { subscription, secret } = await operations.webhook.rotateSecret({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, }); if (!subscription) { diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 3874da3..d45ebea 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -7,19 +7,29 @@ import { resource, ledger, webhook, + policy, error, availability, + service, + booking, } from "@floyd-run/schema/outputs"; const registry = new OpenAPIRegistry(); // Register schemas -registry.register("Ledger", ledger.schema); -registry.register("Resource", resource.schema); -registry.register("Allocation", allocation.schema); -registry.register("WebhookSubscription", webhook.subscriptionSchema); -registry.register("AvailabilityItem", availability.itemSchema); -registry.register("TimelineBlock", availability.timelineBlockSchema); +registry.register("Ledger", ledger.base); +registry.register("Resource", resource.base); +registry.register("Allocation", allocation.base); +registry.register("WebhookSubscription", webhook.subscription); +registry.register("AvailabilityItem", availability.item); +registry.register("TimelineBlock", availability.timelineBlock); +registry.register("Slot", availability.slot); +registry.register("ResourceSlots", availability.resourceSlots); +registry.register("Window", availability.window); +registry.register("ResourceWindows", availability.resourceWindows); +registry.register("Policy", policy.base); +registry.register("Service", service.base); +registry.register("Booking", booking.base); registry.register("Error", error.schema); // Ledger routes @@ -31,7 +41,7 @@ registry.registerPath({ responses: { 200: { description: "List of ledgers", - content: { "application/json": { schema: ledger.listSchema } }, + content: { "application/json": { schema: ledger.list } }, }, }, }); @@ -49,7 +59,7 @@ registry.registerPath({ responses: { 200: { description: "Ledger details", - content: { "application/json": { schema: ledger.getSchema } }, + content: { "application/json": { schema: ledger.get } }, }, 404: { description: "Ledger not found", @@ -71,7 +81,7 @@ registry.registerPath({ responses: { 201: { description: "Ledger created", - content: { "application/json": { schema: ledger.getSchema } }, + content: { "application/json": { schema: ledger.get } }, }, }, }); @@ -86,7 +96,7 @@ registry.registerPath({ responses: { 200: { description: "List of resources", - content: { "application/json": { schema: resource.listSchema } }, + content: { "application/json": { schema: resource.list } }, }, }, }); @@ -102,7 +112,7 @@ registry.registerPath({ responses: { 200: { description: "Resource details", - content: { "application/json": { schema: resource.getSchema } }, + content: { "application/json": { schema: resource.get } }, }, 404: { description: "Resource not found", @@ -121,7 +131,12 @@ registry.registerPath({ body: { content: { "application/json": { - schema: z.object({}), + schema: z.object({ + timezone: z.string().openapi({ + description: "IANA timezone for the resource (e.g. America/New_York)", + example: "America/New_York", + }), + }), }, }, }, @@ -129,7 +144,7 @@ registry.registerPath({ responses: { 201: { description: "Resource created", - content: { "application/json": { schema: resource.getSchema } }, + content: { "application/json": { schema: resource.get } }, }, }, }); @@ -170,11 +185,11 @@ registry.registerPath({ description: "Resource IDs to query", example: ["rsc_01abc123def456ghi789jkl012"], }), - startAt: z.string().datetime().openapi({ + startTime: z.iso.datetime().openapi({ description: "Start of the time window (ISO 8601)", example: "2026-01-04T10:00:00Z", }), - endAt: z.string().datetime().openapi({ + endTime: z.iso.datetime().openapi({ description: "End of the time window (ISO 8601)", example: "2026-01-04T18:00:00Z", }), @@ -186,7 +201,7 @@ registry.registerPath({ responses: { 200: { description: "Availability timeline for each resource", - content: { "application/json": { schema: availability.querySchema } }, + content: { "application/json": { schema: availability.query } }, }, }, }); @@ -201,7 +216,7 @@ registry.registerPath({ responses: { 200: { description: "List of allocations", - content: { "application/json": { schema: allocation.listSchema } }, + content: { "application/json": { schema: allocation.list } }, }, }, }); @@ -217,7 +232,7 @@ registry.registerPath({ responses: { 200: { description: "Allocation details", - content: { "application/json": { schema: allocation.getSchema } }, + content: { "application/json": { schema: allocation.get } }, }, 404: { description: "Allocation not found", @@ -232,18 +247,19 @@ registry.registerPath({ tags: ["Allocations"], summary: "Create a new allocation", description: - "Creates a new allocation for a resource. Supports idempotency via the Idempotency-Key header.", + "Creates a raw allocation for a resource. Use bookings for policy-evaluated reservations with lifecycle management. Supports idempotency via the Idempotency-Key header.", request: { params: z.object({ ledgerId: z.string() }), body: { content: { "application/json": { schema: z.object({ - resourceId: z.string().openapi({ example: "res_01abc123def456ghi789jkl012" }), - status: z.enum(["hold", "confirmed"]).default("hold"), - startAt: z.string().datetime(), - endAt: z.string().datetime(), - expiresAt: z.string().datetime().nullable().optional(), + resourceId: z.string().openapi({ example: "rsc_01abc123def456ghi789jkl012" }), + startTime: z.iso.datetime(), + endTime: z.iso.datetime(), + expiresAt: z.iso.datetime().nullable().optional().openapi({ + description: "If set, the allocation auto-expires after this time", + }), metadata: z.record(z.string(), z.unknown()).nullable().optional(), }), }, @@ -253,7 +269,7 @@ registry.registerPath({ responses: { 201: { description: "Allocation created", - content: { "application/json": { schema: allocation.getSchema } }, + content: { "application/json": { schema: allocation.get } }, }, 409: { description: "Allocation conflicts with existing allocation", @@ -263,50 +279,408 @@ registry.registerPath({ }); registry.registerPath({ - method: "post", - path: "/v1/ledgers/{ledgerId}/allocations/{id}/confirm", + method: "delete", + path: "/v1/ledgers/{ledgerId}/allocations/{id}", tags: ["Allocations"], - summary: "Confirm a held allocation", - description: "Confirms an allocation that is currently in HOLD status.", + summary: "Delete an allocation", + description: + "Deletes a raw allocation. Allocations that belong to a booking cannot be deleted directly — cancel the booking instead.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 204: { description: "Allocation deleted" }, + 404: { + description: "Allocation not found", + content: { "application/json": { schema: error.schema } }, + }, + 409: { + description: "Allocation belongs to a booking", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +// Service routes +registry.registerPath({ + method: "get", + path: "/v1/ledgers/{ledgerId}/services", + tags: ["Services"], + summary: "List all services in a ledger", + request: { params: z.object({ ledgerId: z.string() }) }, + responses: { + 200: { + description: "List of services", + content: { "application/json": { schema: service.list } }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/v1/ledgers/{ledgerId}/services/{id}", + tags: ["Services"], + summary: "Get a service by ID", request: { params: z.object({ ledgerId: z.string(), id: z.string() }), }, responses: { 200: { - description: "Allocation confirmed", - content: { "application/json": { schema: allocation.getSchema } }, + description: "Service details", + content: { "application/json": { schema: service.get } }, }, 404: { - description: "Allocation not found", + description: "Service not found", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/v1/ledgers/{ledgerId}/services", + tags: ["Services"], + summary: "Create a new service", + description: "Creates a service that groups resources with an optional scheduling policy.", + request: { + params: z.object({ ledgerId: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + name: z.string().openapi({ description: "Service name", example: "Haircut" }), + policyId: z + .string() + .nullable() + .optional() + .openapi({ description: "Policy to enforce on bookings" }), + resourceIds: z + .array(z.string()) + .optional() + .openapi({ + description: "Resources that belong to this service", + example: ["rsc_01abc123def456ghi789jkl012"], + }), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Service created", + content: { "application/json": { schema: service.get } }, + }, + 404: { + description: "Policy or resource not found", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "put", + path: "/v1/ledgers/{ledgerId}/services/{id}", + tags: ["Services"], + summary: "Update a service", + description: + "Replaces the full service definition including name, policy, and resource assignments.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + name: z.string().openapi({ description: "Service name", example: "Haircut" }), + policyId: z + .string() + .nullable() + .optional() + .openapi({ description: "Policy to enforce on bookings" }), + resourceIds: z + .array(z.string()) + .optional() + .openapi({ + description: "Resources that belong to this service", + example: ["rsc_01abc123def456ghi789jkl012"], + }), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Service updated", + content: { "application/json": { schema: service.get } }, + }, + 404: { + description: "Service, policy, or resource not found", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "delete", + path: "/v1/ledgers/{ledgerId}/services/{id}", + tags: ["Services"], + summary: "Delete a service", + description: "Deletes a service. Fails if the service has bookings in hold or confirmed status.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 204: { description: "Service deleted" }, + 404: { + description: "Service not found", content: { "application/json": { schema: error.schema } }, }, 409: { - description: "Allocation cannot be confirmed", + description: "Service has active bookings", content: { "application/json": { schema: error.schema } }, }, }, }); +// Service Availability routes registry.registerPath({ method: "post", - path: "/v1/ledgers/{ledgerId}/allocations/{id}/cancel", - tags: ["Allocations"], - summary: "Cancel an allocation", - description: "Cancels an allocation that is in HOLD or CONFIRMED status.", + path: "/v1/ledgers/{ledgerId}/services/{id}/availability/slots", + tags: ["Service Availability"], + summary: "Query available booking slots", + description: + "Returns discrete grid-aligned time slots for appointment-style booking. " + + "Applies the service's policy (schedule, duration, grid, buffers, lead time) and filters by existing allocations. " + + "Pass includeUnavailable: true to get the full grid with available/unavailable status.", request: { params: z.object({ ledgerId: z.string(), id: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + startTime: z.iso.datetime().openapi({ + description: "Start of the query window (ISO 8601)", + example: "2026-03-02T00:00:00Z", + }), + endTime: z.iso.datetime().openapi({ + description: "End of the query window (ISO 8601). Max 7 days from startTime.", + example: "2026-03-07T00:00:00Z", + }), + durationMs: z.number().int().positive().openapi({ + description: "Desired booking duration in milliseconds", + example: 3600000, + }), + resourceIds: z.array(z.string()).optional().openapi({ + description: + "Filter to specific resources. Defaults to all resources in the service.", + }), + includeUnavailable: z.boolean().optional().openapi({ + description: "Return all grid positions with status. Defaults to false.", + }), + }), + }, + }, + }, }, responses: { 200: { - description: "Allocation cancelled", - content: { "application/json": { schema: allocation.getSchema } }, + description: "Available slots per resource", + content: { "application/json": { schema: availability.slotsResponse } }, }, 404: { - description: "Allocation not found", + description: "Service not found", + content: { "application/json": { schema: error.schema } }, + }, + 422: { + description: "Invalid input (range too large, invalid resourceIds, etc.)", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/v1/ledgers/{ledgerId}/services/{id}/availability/windows", + tags: ["Service Availability"], + summary: "Query available time windows", + description: + "Returns continuous available time ranges for rental-style booking. " + + "Applies the service's policy and subtracts existing allocations (with buffer-aware gap shrinkage). " + + "Pass includeUnavailable: true to get the full schedule with available/unavailable status.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + startTime: z.iso.datetime().openapi({ + description: "Start of the query window (ISO 8601)", + example: "2026-03-02T00:00:00Z", + }), + endTime: z.iso.datetime().openapi({ + description: "End of the query window (ISO 8601). Max 31 days from startTime.", + example: "2026-03-07T00:00:00Z", + }), + resourceIds: z.array(z.string()).optional().openapi({ + description: + "Filter to specific resources. Defaults to all resources in the service.", + }), + includeUnavailable: z.boolean().optional().openapi({ + description: "Return full schedule with status. Defaults to false.", + }), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Available windows per resource", + content: { "application/json": { schema: availability.windowsResponse } }, + }, + 404: { + description: "Service not found", + content: { "application/json": { schema: error.schema } }, + }, + 422: { + description: "Invalid input (range too large, invalid resourceIds, etc.)", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +// Booking routes +registry.registerPath({ + method: "get", + path: "/v1/ledgers/{ledgerId}/bookings", + tags: ["Bookings"], + summary: "List all bookings in a ledger", + request: { params: z.object({ ledgerId: z.string() }) }, + responses: { + 200: { + description: "List of bookings", + content: { "application/json": { schema: booking.list } }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/v1/ledgers/{ledgerId}/bookings/{id}", + tags: ["Bookings"], + summary: "Get a booking by ID", + description: "Returns the booking with its nested allocations.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 200: { + description: "Booking details with allocations", + content: { "application/json": { schema: booking.get } }, + }, + 404: { + description: "Booking not found", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/v1/ledgers/{ledgerId}/bookings", + tags: ["Bookings"], + summary: "Create a new booking", + description: + "Creates a booking for a service. Evaluates the service's policy, checks for conflicts, and creates the underlying allocation. " + + "When the policy defines buffers, the allocation's startTime/endTime represent the buffer-expanded blocked window. " + + "The original customer time can be derived using buffer.beforeMs and buffer.afterMs on the allocation. " + + "Supports idempotency via the Idempotency-Key header.", + request: { + params: z.object({ ledgerId: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + serviceId: z.string().openapi({ example: "svc_01abc123def456ghi789jkl012" }), + resourceId: z.string().openapi({ example: "rsc_01abc123def456ghi789jkl012" }), + startTime: z.iso.datetime().openapi({ example: "2026-01-15T10:00:00Z" }), + endTime: z.iso.datetime().openapi({ example: "2026-01-15T11:00:00Z" }), + status: z + .enum(["hold", "confirmed"]) + .default("hold") + .openapi({ description: "Initial status. Hold creates a temporary reservation." }), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Booking created", + content: { "application/json": { schema: booking.get } }, + }, + 404: { + description: "Service or resource not found", + content: { "application/json": { schema: error.schema } }, + }, + 409: { + description: "Conflict (overlap, policy rejected, or resource not in service)", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/v1/ledgers/{ledgerId}/bookings/{id}/confirm", + tags: ["Bookings"], + summary: "Confirm a held booking", + description: + "Confirms a booking that is in hold status. Idempotent — confirming an already confirmed booking returns success. Supports idempotency via the Idempotency-Key header.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 200: { + description: "Booking confirmed", + content: { "application/json": { schema: booking.get } }, + }, + 404: { + description: "Booking not found", + content: { "application/json": { schema: error.schema } }, + }, + 409: { + description: "Booking cannot be confirmed (expired or invalid state)", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/v1/ledgers/{ledgerId}/bookings/{id}/cancel", + tags: ["Bookings"], + summary: "Cancel a booking", + description: + "Cancels a booking in hold or confirmed status. Idempotent — canceling an already canceled booking returns success. Supports idempotency via the Idempotency-Key header.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 200: { + description: "Booking canceled", + content: { "application/json": { schema: booking.get } }, + }, + 404: { + description: "Booking not found", content: { "application/json": { schema: error.schema } }, }, 409: { - description: "Allocation cannot be cancelled", + description: "Booking cannot be canceled (invalid state)", content: { "application/json": { schema: error.schema } }, }, }, @@ -322,7 +696,7 @@ registry.registerPath({ responses: { 200: { description: "List of webhook subscriptions", - content: { "application/json": { schema: webhook.listSubscriptionsSchema } }, + content: { "application/json": { schema: webhook.listSubscriptions } }, }, }, }); @@ -340,7 +714,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - url: z.string().url().openapi({ example: "https://example.com/webhook" }), + url: z.url().openapi({ example: "https://example.com/webhook" }), }), }, }, @@ -349,23 +723,23 @@ registry.registerPath({ responses: { 201: { description: "Webhook subscription created (includes secret)", - content: { "application/json": { schema: webhook.createSubscriptionSchema } }, + content: { "application/json": { schema: webhook.createSubscription } }, }, }, }); registry.registerPath({ method: "patch", - path: "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}", + path: "/v1/ledgers/{ledgerId}/webhooks/{id}", tags: ["Webhooks"], summary: "Update a webhook subscription", request: { - params: z.object({ ledgerId: z.string(), subscriptionId: z.string() }), + params: z.object({ ledgerId: z.string(), id: z.string() }), body: { content: { "application/json": { schema: z.object({ - url: z.string().url().optional(), + url: z.url().optional(), }), }, }, @@ -374,7 +748,7 @@ registry.registerPath({ responses: { 200: { description: "Webhook subscription updated", - content: { "application/json": { schema: webhook.updateSubscriptionSchema } }, + content: { "application/json": { schema: webhook.updateSubscription } }, }, 404: { description: "Webhook subscription not found", @@ -385,11 +759,11 @@ registry.registerPath({ registry.registerPath({ method: "delete", - path: "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}", + path: "/v1/ledgers/{ledgerId}/webhooks/{id}", tags: ["Webhooks"], summary: "Delete a webhook subscription", request: { - params: z.object({ ledgerId: z.string(), subscriptionId: z.string() }), + params: z.object({ ledgerId: z.string(), id: z.string() }), }, responses: { 204: { description: "Webhook subscription deleted" }, @@ -402,18 +776,18 @@ registry.registerPath({ registry.registerPath({ method: "post", - path: "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}/rotate-secret", + path: "/v1/ledgers/{ledgerId}/webhooks/{id}/rotate-secret", tags: ["Webhooks"], summary: "Rotate webhook secret", description: "Generates a new secret for the webhook subscription. The old secret is invalidated immediately.", request: { - params: z.object({ ledgerId: z.string(), subscriptionId: z.string() }), + params: z.object({ ledgerId: z.string(), id: z.string() }), }, responses: { 200: { description: "New secret generated (includes secret)", - content: { "application/json": { schema: webhook.rotateSecretSchema } }, + content: { "application/json": { schema: webhook.rotateSecret } }, }, 404: { description: "Webhook subscription not found", @@ -422,6 +796,132 @@ registry.registerPath({ }, }); +// Policy routes +registry.registerPath({ + method: "get", + path: "/v1/ledgers/{ledgerId}/policies", + tags: ["Policies"], + summary: "List policies for a ledger", + request: { params: z.object({ ledgerId: z.string() }) }, + responses: { + 200: { + description: "List of policies", + content: { "application/json": { schema: policy.list } }, + }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/v1/ledgers/{ledgerId}/policies/{id}", + tags: ["Policies"], + summary: "Get a policy by ID", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 200: { + description: "Policy details", + content: { "application/json": { schema: policy.get } }, + }, + 404: { + description: "Policy not found", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/v1/ledgers/{ledgerId}/policies", + tags: ["Policies"], + summary: "Create a policy", + description: + "Creates a new scheduling policy for the ledger. The config is provided in authoring format (friendly units like minutes/hours) and stored in canonical format (milliseconds).", + request: { + params: z.object({ ledgerId: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + config: z + .object({ + schema_version: z.literal(1), + default: z.enum(["open", "closed"]), + config: z.object({}).loose(), + rules: z.array(z.object({}).loose()).optional(), + }) + .loose() + .openapi({ description: "Policy configuration in authoring format" }), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Policy created (may include warnings)", + content: { "application/json": { schema: policy.get } }, + }, + }, +}); + +registry.registerPath({ + method: "put", + path: "/v1/ledgers/{ledgerId}/policies/{id}", + tags: ["Policies"], + summary: "Update a policy", + description: + "Replaces the full policy configuration. The config is re-normalized, re-validated, and re-hashed.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + config: z + .object({ + schema_version: z.literal(1), + default: z.enum(["open", "closed"]), + config: z.object({}).loose(), + rules: z.array(z.object({}).loose()).optional(), + }) + .loose() + .openapi({ description: "Policy configuration in authoring format" }), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Policy updated (may include warnings)", + content: { "application/json": { schema: policy.get } }, + }, + 404: { + description: "Policy not found", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + +registry.registerPath({ + method: "delete", + path: "/v1/ledgers/{ledgerId}/policies/{id}", + tags: ["Policies"], + summary: "Delete a policy", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 204: { description: "Policy deleted" }, + 404: { + description: "Policy not found", + content: { "application/json": { schema: error.schema } }, + }, + }, +}); + // Generate OpenAPI document const generator = new OpenApiGeneratorV31(registry.definitions); const doc = generator.generateDocument({ @@ -429,7 +929,7 @@ const doc = generator.generateDocument({ info: { title: "Floyd Engine API", version: "1.0.0", - description: "Resource scheduling and allocation engine", + description: "Booking engine for AI agents", }, servers: [ { diff --git a/apps/server/src/services/allocation/cancel.ts b/apps/server/src/services/allocation/cancel.ts deleted file mode 100644 index 42d6a9b..0000000 --- a/apps/server/src/services/allocation/cancel.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { sql } from "kysely"; -import { db } from "database"; -import { createService } from "lib/service"; -import { allocation } from "@floyd-run/schema/inputs"; -import { ConflictError, NotFoundError } from "lib/errors"; -import { enqueueWebhookEvent } from "infra/webhooks"; - -export default createService({ - input: allocation.cancelSchema, - execute: async (input) => { - return await db.transaction().execute(async (trx) => { - // 1. Get the allocation with lock - const existing = await trx - .selectFrom("allocations") - .selectAll() - .where("id", "=", input.id) - .forUpdate() - .executeTakeFirst(); - - if (!existing) { - throw new NotFoundError("Allocation not found"); - } - - // 2. Capture server time after acquiring lock - const result = await sql<{ - serverTime: Date; - }>`SELECT clock_timestamp() AS server_time`.execute(trx); - const serverTime = result.rows[0]!.serverTime; - - // 3. Validate state transition - if (existing.status === "cancelled") { - // Already cancelled - idempotent success - return { allocation: existing, serverTime }; - } - - if (existing.status === "expired") { - throw new ConflictError("invalid_state_transition", { - currentStatus: existing.status, - requestedStatus: "cancelled", - message: "Cannot cancel an expired allocation", - }); - } - - // 4. Update to cancelled (valid from hold or confirmed) - // Clear expires_at per database constraint - const allocation = await trx - .updateTable("allocations") - .set({ - status: "cancelled", - expiresAt: null, - updatedAt: serverTime, - }) - .where("id", "=", input.id) - .returningAll() - .executeTakeFirstOrThrow(); - - // 5. Enqueue webhook event (in same transaction) - await enqueueWebhookEvent(trx, "allocation.cancelled", allocation.ledgerId, allocation); - - return { allocation, serverTime }; - }); - }, -}); diff --git a/apps/server/src/services/allocation/confirm.ts b/apps/server/src/services/allocation/confirm.ts deleted file mode 100644 index ca41d32..0000000 --- a/apps/server/src/services/allocation/confirm.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { db } from "database"; -import { sql } from "kysely"; -import { createService } from "lib/service"; -import { allocation } from "@floyd-run/schema/inputs"; -import { ConflictError, NotFoundError } from "lib/errors"; -import { enqueueWebhookEvent } from "infra/webhooks"; - -export default createService({ - input: allocation.confirmSchema, - execute: async (input) => { - return await db.transaction().execute(async (trx) => { - // 1. Get the allocation with lock - const existing = await trx - .selectFrom("allocations") - .selectAll() - .where("id", "=", input.id) - .forUpdate() - .executeTakeFirst(); - - if (!existing) { - throw new NotFoundError("Allocation not found"); - } - - // 2. Capture server time after acquiring lock - const result = await sql<{ - serverTime: Date; - }>`SELECT clock_timestamp() AS server_time`.execute(trx); - const serverTime = result.rows[0]!.serverTime; - - // 3. Validate state transition - if (existing.status === "confirmed") { - // Already confirmed - idempotent success - return { allocation: existing, serverTime }; - } - - if (existing.status === "cancelled") { - throw new ConflictError("invalid_state_transition", { - currentStatus: existing.status, - requestedStatus: "confirmed", - message: "Cannot confirm a cancelled allocation", - }); - } - - if (existing.status === "expired") { - throw new ConflictError("invalid_state_transition", { - currentStatus: existing.status, - requestedStatus: "confirmed", - message: "Cannot confirm an expired allocation", - }); - } - - // 4. For holds, check if expired based on server time - if (existing.status === "hold" && existing.expiresAt) { - if (serverTime >= existing.expiresAt) { - throw new ConflictError("hold_expired", { - expiresAt: existing.expiresAt, - serverTime, - }); - } - } - - // 5. Update to confirmed (clear expires_at per database constraint) - const allocation = await trx - .updateTable("allocations") - .set({ - status: "confirmed", - expiresAt: null, - updatedAt: serverTime, - }) - .where("id", "=", input.id) - .returningAll() - .executeTakeFirstOrThrow(); - - // 6. Enqueue webhook event (in same transaction) - await enqueueWebhookEvent(trx, "allocation.confirmed", allocation.ledgerId, allocation); - - return { allocation, serverTime }; - }); - }, -}); diff --git a/apps/server/src/services/allocation/create.ts b/apps/server/src/services/allocation/create.ts deleted file mode 100644 index 0095eb8..0000000 --- a/apps/server/src/services/allocation/create.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { sql } from "kysely"; -import { db } from "database"; -import { createService } from "lib/service"; -import { generateId } from "@floyd-run/utils"; -import { allocation } from "@floyd-run/schema/inputs"; -import { ConflictError, NotFoundError } from "lib/errors"; -import { enqueueWebhookEvent } from "infra/webhooks"; - -export default createService({ - input: allocation.createSchema, - execute: async (input) => { - return await db.transaction().execute(async (trx) => { - // 1. Lock the resource row (FOR UPDATE) - serializes concurrent writes - const resource = await trx - .selectFrom("resources") - .selectAll() - .where("id", "=", input.resourceId) - .forUpdate() - .executeTakeFirst(); - - if (!resource) { - throw new NotFoundError("Resource not found"); - } - - // 2. Capture server time immediately after acquiring lock - const result = await sql<{ - serverTime: Date; - }>`SELECT clock_timestamp() AS server_time`.execute(trx); - const serverTime = result.rows[0]!.serverTime; - - // 3. Check for overlapping allocations that would block this request - // Blocking allocations are: confirmed OR (hold AND not expired) - const conflicting = await trx - .selectFrom("allocations") - .select(["id", "status", "startAt", "endAt"]) - .where("resourceId", "=", input.resourceId) - .where((eb) => - eb.or([ - eb("status", "=", "confirmed"), - eb.and([eb("status", "=", "hold"), eb("expiresAt", ">", serverTime)]), - ]), - ) - // Overlap condition: existing.start < new.end AND existing.end > new.start - .where("startAt", "<", input.endAt) - .where("endAt", ">", input.startAt) - .execute(); - - if (conflicting.length > 0) { - throw new ConflictError("overlap_conflict", { - conflictingAllocationIds: conflicting.map((a) => a.id), - }); - } - - // 4. Compute expiresAt for holds - // Holds require expiresAt; default to 15 minutes from serverTime if not provided - // Confirmed allocations must have null expiresAt - let expiresAt: Date | null = null; - if (input.status === "hold") { - if (input.expiresAt) { - expiresAt = input.expiresAt; - } else { - expiresAt = new Date(serverTime.getTime() + 15 * 60 * 1000); // 15 minutes - } - } - - // 5. Insert the allocation - const alloc = await trx - .insertInto("allocations") - .values({ - id: generateId("alc"), - ledgerId: input.ledgerId, - resourceId: input.resourceId, - status: input.status, - startAt: input.startAt, - endAt: input.endAt, - expiresAt, - metadata: input.metadata ?? null, - }) - .returningAll() - .executeTakeFirstOrThrow(); - - // 6. Enqueue webhook event (in same transaction) - await enqueueWebhookEvent(trx, "allocation.created", input.ledgerId, alloc); - - return { allocation: alloc, serverTime }; - }); - }, -}); diff --git a/apps/server/src/services/availability/index.ts b/apps/server/src/services/availability/index.ts deleted file mode 100644 index 51a6c20..0000000 --- a/apps/server/src/services/availability/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import query from "./query"; - -export const availability = { - query, -}; diff --git a/apps/server/src/services/resource/remove.ts b/apps/server/src/services/resource/remove.ts deleted file mode 100644 index bc09b88..0000000 --- a/apps/server/src/services/resource/remove.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { resource } from "@floyd-run/schema/inputs"; -import { db } from "database"; -import { createService } from "lib/service"; - -export default createService({ - input: resource.removeSchema, - execute: async (input) => { - await db.deleteFrom("resources").where("id", "=", input.id).executeTakeFirstOrThrow(); - }, -}); diff --git a/apps/server/src/workers/expiration-worker.ts b/apps/server/src/workers/expiration-worker.ts index 63ef2c8..43205e0 100644 --- a/apps/server/src/workers/expiration-worker.ts +++ b/apps/server/src/workers/expiration-worker.ts @@ -1,24 +1,20 @@ -import { db } from "database"; -import { sql } from "kysely"; +import { db, getServerTime } from "database"; import { logger } from "infra/logger"; import { enqueueWebhookEvent } from "infra/webhooks"; +import { serializeBooking } from "routes/v1/serializers"; const POLL_INTERVAL_MS = 5000; // 5 seconds const BATCH_SIZE = 100; let isRunning = false; -async function processExpiredHolds(): Promise { +async function processExpiredBookings(): Promise { return await db.transaction().execute(async (trx) => { - // Get server time - const result = await sql<{ - serverTime: Date; - }>`SELECT clock_timestamp() AS server_time`.execute(trx); - const serverTime = result.rows[0]!.serverTime; - - // Find expired holds and lock them - const expiredHolds = await trx - .selectFrom("allocations") + const serverTime = await getServerTime(trx); + + // Find expired booking holds and lock them + const expiredBookings = await trx + .selectFrom("bookings") .selectAll() .where("status", "=", "hold") .where("expiresAt", "<=", serverTime) @@ -27,34 +23,86 @@ async function processExpiredHolds(): Promise { .skipLocked() .execute(); - if (expiredHolds.length === 0) { + if (expiredBookings.length === 0) { return 0; } - const ids = expiredHolds.map((h) => h.id); + const bookingIds = expiredBookings.map((booking) => booking.id); - // Update to expired (clear expiresAt per database constraint) + // Update bookings to expired await trx - .updateTable("allocations") + .updateTable("bookings") .set({ status: "expired", expiresAt: null, updatedAt: serverTime, }) - .where("id", "in", ids) + .where("id", "in", bookingIds) .execute(); - // Enqueue webhook events for each expired allocation - for (const hold of expiredHolds) { - await enqueueWebhookEvent(trx, "allocation.expired", hold.ledgerId, { - ...hold, - status: "expired", + // Deactivate associated allocations + await trx + .updateTable("allocations") + .set({ + active: false, expiresAt: null, updatedAt: serverTime, + }) + .where("bookingId", "in", bookingIds) + .execute(); + + // Fetch all allocations for expired bookings in one query + const allocations = await trx + .selectFrom("allocations") + .selectAll() + .where("bookingId", "in", bookingIds) + .execute(); + + const allocationsByBookingId = new Map(); + for (const allocation of allocations) { + const group = allocationsByBookingId.get(allocation.bookingId!) ?? []; + group.push(allocation); + allocationsByBookingId.set(allocation.bookingId!, group); + } + + // Enqueue webhook events + for (const booking of expiredBookings) { + await enqueueWebhookEvent(trx, "booking.expired", booking.ledgerId, { + booking: serializeBooking( + { ...booking, status: "expired" as const, expiresAt: null, updatedAt: serverTime }, + allocationsByBookingId.get(booking.id) ?? [], + ), }); } - return expiredHolds.length; + return expiredBookings.length; + }); +} + +async function cleanupExpiredRawAllocations(): Promise { + return await db.transaction().execute(async (trx) => { + const serverTime = await getServerTime(trx); + + // Find expired raw allocations (no booking) and hard delete + const expiredAllocations = await trx + .selectFrom("allocations") + .select("id") + .where("bookingId", "is", null) + .where("expiresAt", "is not", null) + .where("expiresAt", "<=", serverTime) + .limit(BATCH_SIZE) + .forUpdate() + .skipLocked() + .execute(); + + if (expiredAllocations.length === 0) { + return 0; + } + + const allocationIds = expiredAllocations.map((allocation) => allocation.id); + await trx.deleteFrom("allocations").where("id", "in", allocationIds).execute(); + + return expiredAllocations.length; }); } @@ -62,13 +110,19 @@ async function runWorker(): Promise { if (isRunning) return; isRunning = true; - logger.info("[expiration-worker] Starting hold expiration worker..."); + logger.info("[expiration-worker] Starting expiration worker..."); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (isRunning) { try { - const processed = await processExpiredHolds(); - if (processed > 0) { - logger.info(`[expiration-worker] Expired ${processed} holds`); + const expiredCount = await processExpiredBookings(); + if (expiredCount > 0) { + logger.info(`[expiration-worker] Expired ${expiredCount} booking holds`); + } + + const cleanedCount = await cleanupExpiredRawAllocations(); + if (cleanedCount > 0) { + logger.info(`[expiration-worker] Cleaned up ${cleanedCount} expired raw allocations`); } } catch (error) { logger.error(error, "[expiration-worker] Error processing expirations"); @@ -79,12 +133,12 @@ async function runWorker(): Promise { } export function stopExpirationWorker(): void { - logger.info("[expiration-worker] Stopping hold expiration worker..."); + logger.info("[expiration-worker] Stopping expiration worker..."); isRunning = false; } export function startExpirationWorker(): void { - runWorker().catch((error) => { + runWorker().catch((error: unknown) => { logger.error(error, "[expiration-worker] Fatal error"); }); } diff --git a/apps/server/src/workers/webhook-worker.ts b/apps/server/src/workers/webhook-worker.ts index d908d3d..932a235 100644 --- a/apps/server/src/workers/webhook-worker.ts +++ b/apps/server/src/workers/webhook-worker.ts @@ -12,6 +12,7 @@ async function runWorker(): Promise { logger.info("[webhook-worker] Starting webhook delivery worker..."); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (isRunning) { try { const processed = await processPendingDeliveries(BATCH_SIZE); @@ -32,7 +33,7 @@ export function stopWebhookWorker(): void { } export function startWebhookWorker(): void { - runWorker().catch((error) => { + runWorker().catch((error: unknown) => { logger.error(error, "[webhook-worker] Fatal error"); }); } diff --git a/apps/server/test/setup/client.ts b/apps/server/test/integration/setup/client.ts similarity index 81% rename from apps/server/test/setup/client.ts rename to apps/server/test/integration/setup/client.ts index 328ec6a..0ef16a7 100644 --- a/apps/server/test/setup/client.ts +++ b/apps/server/test/integration/setup/client.ts @@ -1,6 +1,4 @@ -import type { Hono } from "hono"; - -type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE"; +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RequestOptions { headers?: Record; @@ -9,12 +7,13 @@ interface RequestOptions { export interface Client { get: (path: string, options?: RequestOptions) => Promise; post: (path: string, body?: unknown, options?: RequestOptions) => Promise; + put: (path: string, body?: unknown, options?: RequestOptions) => Promise; patch: (path: string, body?: unknown, options?: RequestOptions) => Promise; delete: (path: string, body?: unknown, options?: RequestOptions) => Promise; } export const createClient = async (): Promise => { - const { default: app } = await import("../../src/app"); + const { default: app } = await import("../../../src/app"); const makeRequest = async ( method: HttpMethod, @@ -34,7 +33,7 @@ export const createClient = async (): Promise => { body !== undefined ? JSON.stringify(body) : (undefined as unknown as RequestInit["body"]), } as RequestInit); - return (app as Hono).request(request as Request); + return app.request(request as Request); }; return { @@ -42,6 +41,8 @@ export const createClient = async (): Promise => { makeRequest("GET", path, options?.headers ?? {}), post: (path: string, body?: unknown, options?: RequestOptions) => makeRequest("POST", path, options?.headers ?? {}, body), + put: (path: string, body?: unknown, options?: RequestOptions) => + makeRequest("PUT", path, options?.headers ?? {}, body), patch: (path: string, body?: unknown, options?: RequestOptions) => makeRequest("PATCH", path, options?.headers ?? {}, body), delete: (path: string, body?: unknown, options?: RequestOptions) => diff --git a/apps/server/test/setup/factories/allocation.factory.ts b/apps/server/test/integration/setup/factories/allocation.factory.ts similarity index 74% rename from apps/server/test/setup/factories/allocation.factory.ts rename to apps/server/test/integration/setup/factories/allocation.factory.ts index b3429dd..bd8bea7 100644 --- a/apps/server/test/setup/factories/allocation.factory.ts +++ b/apps/server/test/integration/setup/factories/allocation.factory.ts @@ -1,15 +1,15 @@ import { faker } from "@faker-js/faker"; import { db } from "database"; -import { AllocationStatus } from "@floyd-run/schema/types"; import { generateId } from "@floyd-run/utils"; import { createResource } from "./resource.factory"; export async function createAllocation(overrides?: { ledgerId?: string; resourceId?: string; - status?: AllocationStatus; - startAt?: Date; - endAt?: Date; + active?: boolean; + bookingId?: string | null; + startTime?: Date; + endTime?: Date; expiresAt?: Date | null; metadata?: Record | null; }) { @@ -32,8 +32,8 @@ export async function createAllocation(overrides?: { ledgerId = resource.ledgerId; } - const startAt = overrides?.startAt ?? faker.date.future(); - const endAt = overrides?.endAt ?? new Date(startAt.getTime() + 60 * 60 * 1000); // 1 hour later + const startTime = overrides?.startTime ?? faker.date.future(); + const endTime = overrides?.endTime ?? new Date(startTime.getTime() + 60 * 60 * 1000); // 1 hour later const allocation = await db .insertInto("allocations") @@ -41,9 +41,12 @@ export async function createAllocation(overrides?: { id: generateId("alc"), ledgerId, resourceId, - status: overrides?.status ?? "confirmed", - startAt, - endAt, + bookingId: overrides?.bookingId ?? null, + active: overrides?.active ?? true, + startTime, + endTime, + bufferBeforeMs: 0, + bufferAfterMs: 0, expiresAt: overrides?.expiresAt ?? null, metadata: overrides?.metadata ?? null, }) diff --git a/apps/server/test/integration/setup/factories/booking.factory.ts b/apps/server/test/integration/setup/factories/booking.factory.ts new file mode 100644 index 0000000..fc4ded7 --- /dev/null +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -0,0 +1,93 @@ +import { db } from "database"; +import { generateId } from "@floyd-run/utils"; +import { faker } from "@faker-js/faker"; +import { createService } from "./service.factory"; +import { createResource } from "./resource.factory"; + +export async function createBooking(overrides?: { + ledgerId?: string; + serviceId?: string; + resourceId?: string; + status?: "hold" | "confirmed" | "canceled" | "expired"; + expiresAt?: Date | null; + metadata?: Record | null; + startTime?: Date; + endTime?: Date; +}) { + let ledgerId = overrides?.ledgerId; + let serviceId = overrides?.serviceId; + let resourceId = overrides?.resourceId; + + // If no service or resource provided, create the full chain + if (!serviceId || !resourceId) { + if (!ledgerId) { + const { createLedger } = await import("./ledger.factory"); + const { ledger } = await createLedger(); + ledgerId = ledger.id; + } + + if (!resourceId) { + const { resource } = await createResource({ ledgerId }); + resourceId = resource.id; + } + + if (!serviceId) { + const { service } = await createService({ ledgerId, resourceIds: [resourceId] }); + serviceId = service.id; + } + } + + if (!ledgerId) { + const service = await db + .selectFrom("services") + .where("id", "=", serviceId) + .selectAll() + .executeTakeFirstOrThrow(); + ledgerId = service.ledgerId; + } + + const startTime = overrides?.startTime ?? faker.date.future(); + const endTime = overrides?.endTime ?? new Date(startTime.getTime() + 60 * 60 * 1000); + const status = overrides?.status ?? "hold"; + + // Respect DB constraint: hold requires expiresAt, others require null + const expiresAt = + overrides?.expiresAt !== undefined + ? overrides.expiresAt + : status === "hold" + ? new Date(Date.now() + 15 * 60 * 1000) + : null; + + const booking = await db + .insertInto("bookings") + .values({ + id: generateId("bkg"), + ledgerId, + serviceId, + status, + expiresAt, + metadata: overrides?.metadata ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + const allocation = await db + .insertInto("allocations") + .values({ + id: generateId("alc"), + ledgerId, + resourceId, + bookingId: booking.id, + active: status === "hold" || status === "confirmed", + startTime, + endTime, + bufferBeforeMs: 0, + bufferAfterMs: 0, + expiresAt, + metadata: null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return { booking, allocation, ledgerId, serviceId, resourceId }; +} diff --git a/apps/server/test/setup/factories/index.ts b/apps/server/test/integration/setup/factories/index.ts similarity index 57% rename from apps/server/test/setup/factories/index.ts rename to apps/server/test/integration/setup/factories/index.ts index 8f49713..73d9628 100644 --- a/apps/server/test/setup/factories/index.ts +++ b/apps/server/test/integration/setup/factories/index.ts @@ -2,3 +2,6 @@ export * from "./allocation.factory"; export * from "./resource.factory"; export * from "./ledger.factory"; export * from "./webhook.factory"; +export * from "./policy.factory"; +export * from "./service.factory"; +export * from "./booking.factory"; diff --git a/apps/server/test/setup/factories/ledger.factory.ts b/apps/server/test/integration/setup/factories/ledger.factory.ts similarity index 100% rename from apps/server/test/setup/factories/ledger.factory.ts rename to apps/server/test/integration/setup/factories/ledger.factory.ts diff --git a/apps/server/test/integration/setup/factories/policy.factory.ts b/apps/server/test/integration/setup/factories/policy.factory.ts new file mode 100644 index 0000000..8fdb2d9 --- /dev/null +++ b/apps/server/test/integration/setup/factories/policy.factory.ts @@ -0,0 +1,46 @@ +import { db } from "database"; +import { generateId } from "@floyd-run/utils"; +import { createLedger } from "./ledger.factory"; +import { preparePolicyConfig } from "domain/policy"; + +const DEFAULT_CONFIG = { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_ms: [1800000, 3600000] }, + grid: { interval_ms: 1800000 }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday", "tuesday", "wednesday", "thursday", "friday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], +}; + +export async function createPolicy(overrides?: { + ledgerId?: string; + config?: Record; +}) { + let ledgerId = overrides?.ledgerId; + if (!ledgerId) { + const { ledger } = await createLedger(); + ledgerId = ledger.id; + } + + const rawConfig = overrides?.config ?? DEFAULT_CONFIG; + const { normalized, configHash } = preparePolicyConfig(rawConfig); + + const policy = await db + .insertInto("policies") + .values({ + id: generateId("pol"), + ledgerId, + config: normalized, + configHash, + }) + .returningAll() + .executeTakeFirst(); + + return { policy: policy!, ledgerId }; +} diff --git a/apps/server/test/setup/factories/resource.factory.ts b/apps/server/test/integration/setup/factories/resource.factory.ts similarity index 87% rename from apps/server/test/setup/factories/resource.factory.ts rename to apps/server/test/integration/setup/factories/resource.factory.ts index 38bbd69..893bb1f 100644 --- a/apps/server/test/setup/factories/resource.factory.ts +++ b/apps/server/test/integration/setup/factories/resource.factory.ts @@ -2,7 +2,7 @@ import { db } from "database"; import { generateId } from "@floyd-run/utils"; import { createLedger } from "./ledger.factory"; -export async function createResource(overrides?: { ledgerId?: string }) { +export async function createResource(overrides?: { ledgerId?: string; timezone?: string }) { let ledgerId = overrides?.ledgerId; if (!ledgerId) { const { ledger } = await createLedger(); @@ -14,6 +14,7 @@ export async function createResource(overrides?: { ledgerId?: string }) { .values({ id: generateId("rsc"), ledgerId, + timezone: overrides?.timezone ?? "UTC", }) .returningAll() .executeTakeFirst(); diff --git a/apps/server/test/integration/setup/factories/service.factory.ts b/apps/server/test/integration/setup/factories/service.factory.ts new file mode 100644 index 0000000..f031e7d --- /dev/null +++ b/apps/server/test/integration/setup/factories/service.factory.ts @@ -0,0 +1,39 @@ +import { db } from "database"; +import { generateId } from "@floyd-run/utils"; +import { createLedger } from "./ledger.factory"; + +export async function createService(overrides?: { + ledgerId?: string; + name?: string; + policyId?: string | null; + resourceIds?: string[]; + metadata?: Record | null; +}) { + let ledgerId = overrides?.ledgerId; + if (!ledgerId) { + const { ledger } = await createLedger(); + ledgerId = ledger.id; + } + + const service = await db + .insertInto("services") + .values({ + id: generateId("svc"), + ledgerId, + name: overrides?.name ?? "Test Service", + policyId: overrides?.policyId ?? null, + metadata: overrides?.metadata ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + const resourceIds = overrides?.resourceIds ?? []; + if (resourceIds.length > 0) { + await db + .insertInto("serviceResources") + .values(resourceIds.map((resourceId) => ({ serviceId: service.id, resourceId }))) + .execute(); + } + + return { service, ledgerId, resourceIds }; +} diff --git a/apps/server/test/setup/factories/webhook.factory.ts b/apps/server/test/integration/setup/factories/webhook.factory.ts similarity index 100% rename from apps/server/test/setup/factories/webhook.factory.ts rename to apps/server/test/integration/setup/factories/webhook.factory.ts diff --git a/apps/server/test/setup/global.ts b/apps/server/test/integration/setup/global.ts similarity index 87% rename from apps/server/test/setup/global.ts rename to apps/server/test/integration/setup/global.ts index ca2d90d..e05bc48 100644 --- a/apps/server/test/setup/global.ts +++ b/apps/server/test/integration/setup/global.ts @@ -4,7 +4,7 @@ dotenv.config({ path: ".env.test" }); import { execSync } from "child_process"; -export default async function setup() { +export default function setup() { console.log("Running migrations..."); execSync("npx tsx src/scripts/migrate.ts", { env: process.env, diff --git a/apps/server/test/setup/types.ts b/apps/server/test/integration/setup/types.ts similarity index 71% rename from apps/server/test/setup/types.ts rename to apps/server/test/integration/setup/types.ts index d30a1ca..02edb13 100644 --- a/apps/server/test/setup/types.ts +++ b/apps/server/test/integration/setup/types.ts @@ -1,4 +1,11 @@ -import type { Allocation, Resource, Ledger, AvailabilityItem } from "@floyd-run/schema/types"; +import type { + Allocation, + Resource, + Ledger, + AvailabilityItem, + Service, + Booking, +} from "@floyd-run/schema/types"; // Generic API response type that can represent both success and error responses export interface ApiResponse { @@ -12,4 +19,6 @@ export type AllocationResponse = ApiResponse; export type ResourceResponse = ApiResponse; export type LedgerResponse = ApiResponse; export type AvailabilityResponse = ApiResponse; +export type ServiceResponse = ApiResponse; +export type BookingResponse = ApiResponse; export type ListResponse = ApiResponse; diff --git a/apps/server/test/v1/allocations/create.spec.ts b/apps/server/test/integration/v1/allocations/create.spec.ts similarity index 55% rename from apps/server/test/v1/allocations/create.spec.ts rename to apps/server/test/integration/v1/allocations/create.spec.ts index e08c11e..088f2c7 100644 --- a/apps/server/test/v1/allocations/create.spec.ts +++ b/apps/server/test/integration/v1/allocations/create.spec.ts @@ -1,19 +1,18 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; import { createResource } from "../../setup/factories"; -import { Allocation } from "@floyd-run/schema/types"; +import type { Allocation } from "@floyd-run/schema/types"; describe("POST /v1/ledgers/:ledgerId/allocations", () => { - it("returns 201 for valid confirmed allocation", async () => { + it("returns 201 for valid allocation", async () => { const { resource, ledgerId } = await createResource(); - const startAt = new Date("2026-02-01T10:00:00Z"); - const endAt = new Date("2026-02-01T11:00:00Z"); + const startTime = new Date("2026-02-01T10:00:00Z"); + const endTime = new Date("2026-02-01T11:00:00Z"); const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), }); expect(response.status).toBe(201); @@ -21,30 +20,29 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { expect(data.id).toMatch(/^alc_/); expect(data.ledgerId).toBe(ledgerId); expect(data.resourceId).toBe(resource.id); - expect(data.status).toBe("confirmed"); - expect(data.startAt).toBe(startAt.toISOString()); - expect(data.endAt).toBe(endAt.toISOString()); + expect(data.active).toBe(true); + expect(data.bookingId).toBeNull(); + expect(data.startTime).toBe(startTime.toISOString()); + expect(data.endTime).toBe(endTime.toISOString()); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); - it("returns 201 for hold allocation with expiry", async () => { + it("returns 201 with expiresAt for temporary block", async () => { const { resource, ledgerId } = await createResource(); - const startAt = new Date("2026-02-01T10:00:00Z"); - const endAt = new Date("2026-02-01T11:00:00Z"); + const startTime = new Date("2026-02-01T10:00:00Z"); + const endTime = new Date("2026-02-01T11:00:00Z"); const expiresAt = new Date("2026-01-25T10:00:00Z"); const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "hold", - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), expiresAt: expiresAt.toISOString(), }); expect(response.status).toBe(201); const { data } = (await response.json()) as { data: Allocation }; - expect(data.status).toBe("hold"); expect(data.expiresAt).toBe(expiresAt.toISOString()); }); @@ -54,9 +52,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), metadata, }); @@ -65,64 +62,34 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { expect(data.metadata).toEqual(metadata); }); - it("returns 422 for invalid status", async () => { - const { resource, ledgerId } = await createResource(); - - const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "invalid", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), - }); - - expect(response.status).toBe(422); - }); - - it("returns 422 for terminal status (cancelled)", async () => { - const { resource, ledgerId } = await createResource(); + it("returns 422 for missing required fields", async () => { + const { ledgerId } = await createResource(); - const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "cancelled", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), - }); + const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, {}); expect(response.status).toBe(422); }); - it("returns 422 for terminal status (expired)", async () => { + it("returns 422 when endTime equals startTime", async () => { const { resource, ledgerId } = await createResource(); + const time = new Date("2026-02-01T10:00:00Z").toISOString(); const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "expired", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: time, + endTime: time, }); expect(response.status).toBe(422); }); - it("defaults to hold status when not specified", async () => { + it("returns 422 when endTime is before startTime", async () => { const { resource, ledgerId } = await createResource(); const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), - }); - - expect(response.status).toBe(201); - const { data } = (await response.json()) as { data: Allocation }; - expect(data.status).toBe("hold"); - }); - - it("returns 422 for missing required fields", async () => { - const { ledgerId } = await createResource(); - - const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - status: "confirmed", + startTime: new Date("2026-02-01T11:00:00Z").toISOString(), + endTime: new Date("2026-02-01T10:00:00Z").toISOString(), }); expect(response.status).toBe(422); @@ -133,50 +100,46 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: "rsc_00000000000000000000000000", - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }); expect(response.status).toBe(404); }); describe("conflict detection", () => { - it("returns 409 when overlapping with confirmed allocation", async () => { + it("returns 409 when overlapping with active allocation", async () => { const { resource, ledgerId } = await createResource(); - // Create first confirmed allocation: 10:00-11:00 + // Create first allocation: 10:00-11:00 const first = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }); expect(first.status).toBe(201); // Try to create overlapping allocation: 10:30-11:30 const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:30:00Z").toISOString(), - endAt: new Date("2026-02-01T11:30:00Z").toISOString(), + startTime: new Date("2026-02-01T10:30:00Z").toISOString(), + endTime: new Date("2026-02-01T11:30:00Z").toISOString(), }); expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("overlap_conflict"); + expect(body.error.code).toBe("allocation.overlap"); }); - it("returns 409 when overlapping with active hold", async () => { + it("returns 409 when overlapping with non-expired temporary allocation", async () => { const { resource, ledgerId } = await createResource(); - // Create hold that expires in the future + // Create allocation with future expiry const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes from now const first = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "hold", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), expiresAt: expiresAt.toISOString(), }); expect(first.status).toBe(201); @@ -184,34 +147,31 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // Try to create overlapping allocation const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:30:00Z").toISOString(), - endAt: new Date("2026-02-01T11:30:00Z").toISOString(), + startTime: new Date("2026-02-01T10:30:00Z").toISOString(), + endTime: new Date("2026-02-01T11:30:00Z").toISOString(), }); expect(response.status).toBe(409); }); - it("allows allocation when hold has expired", async () => { + it("allows allocation when temporary allocation has expired", async () => { const { resource, ledgerId } = await createResource(); - // Create hold that already expired + // Create allocation that already expired const expiresAt = new Date(Date.now() - 1000); // 1 second ago const first = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "hold", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), expiresAt: expiresAt.toISOString(), }); expect(first.status).toBe(201); - // Should succeed because hold expired + // Should succeed because the previous allocation expired const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:30:00Z").toISOString(), - endAt: new Date("2026-02-01T11:30:00Z").toISOString(), + startTime: new Date("2026-02-01T10:30:00Z").toISOString(), + endTime: new Date("2026-02-01T11:30:00Z").toISOString(), }); expect(response.status).toBe(201); @@ -223,18 +183,16 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // Create first allocation: 10:00-11:00 const first = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }); expect(first.status).toBe(201); // Create adjacent allocation: 11:00-12:00 (starts exactly when first ends) const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T11:00:00Z").toISOString(), - endAt: new Date("2026-02-01T12:00:00Z").toISOString(), + startTime: new Date("2026-02-01T11:00:00Z").toISOString(), + endTime: new Date("2026-02-01T12:00:00Z").toISOString(), }); expect(response.status).toBe(201); @@ -247,43 +205,39 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // Create allocation on resource1 const first = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource1.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }); expect(first.status).toBe(201); // Same time slot on resource2 should succeed const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource2.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }); expect(response.status).toBe(201); }); - it("ignores cancelled allocations for conflict detection", async () => { + it("ignores inactive allocations for conflict detection", async () => { const { resource, ledgerId } = await createResource(); - // Create and then we'll pretend it's cancelled via factory - // For this test, we use the factory to insert a cancelled allocation directly + // Insert an inactive allocation directly via factory const { createAllocation } = await import("../../setup/factories"); await createAllocation({ resourceId: resource.id, ledgerId, - status: "cancelled", - startAt: new Date("2026-02-01T10:00:00Z"), - endAt: new Date("2026-02-01T11:00:00Z"), + active: false, + startTime: new Date("2026-02-01T10:00:00Z"), + endTime: new Date("2026-02-01T11:00:00Z"), }); - // Should succeed because cancelled allocations don't block + // Should succeed because inactive allocations don't block const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:30:00Z").toISOString(), - endAt: new Date("2026-02-01T11:30:00Z").toISOString(), + startTime: new Date("2026-02-01T10:30:00Z").toISOString(), + endTime: new Date("2026-02-01T11:30:00Z").toISOString(), }); expect(response.status).toBe(201); @@ -294,9 +248,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }); expect(response.status).toBe(201); diff --git a/apps/server/test/integration/v1/allocations/delete.spec.ts b/apps/server/test/integration/v1/allocations/delete.spec.ts new file mode 100644 index 0000000..67912eb --- /dev/null +++ b/apps/server/test/integration/v1/allocations/delete.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createAllocation, createBooking, createLedger } from "../../setup/factories"; + +describe("DELETE /v1/ledgers/:ledgerId/allocations/:id", () => { + it("returns 204 for successful deletion of raw allocation", async () => { + const { allocation, ledgerId } = await createAllocation(); + + const response = await client.delete(`/v1/ledgers/${ledgerId}/allocations/${allocation.id}`); + + expect(response.status).toBe(204); + }); + + it("allocation is gone after deletion", async () => { + const { allocation, ledgerId } = await createAllocation(); + + await client.delete(`/v1/ledgers/${ledgerId}/allocations/${allocation.id}`); + const getResp = await client.get(`/v1/ledgers/${ledgerId}/allocations/${allocation.id}`); + + expect(getResp.status).toBe(404); + }); + + it("returns 404 for non-existent allocation", async () => { + const { ledger } = await createLedger(); + + const response = await client.delete( + `/v1/ledgers/${ledger.id}/allocations/alc_00000000000000000000000000`, + ); + + expect(response.status).toBe(404); + }); + + it("returns 422 for invalid allocation id", async () => { + const { ledger } = await createLedger(); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/allocations/invalid-id`); + + expect(response.status).toBe(422); + }); + + it("returns 409 for booking-owned allocation", async () => { + const { allocation, ledgerId } = await createBooking(); + + const response = await client.delete(`/v1/ledgers/${ledgerId}/allocations/${allocation.id}`); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("allocation.managed_by_booking"); + }); +}); diff --git a/apps/server/test/v1/allocations/get.spec.ts b/apps/server/test/integration/v1/allocations/get.spec.ts similarity index 88% rename from apps/server/test/v1/allocations/get.spec.ts rename to apps/server/test/integration/v1/allocations/get.spec.ts index e98a399..b076224 100644 --- a/apps/server/test/v1/allocations/get.spec.ts +++ b/apps/server/test/integration/v1/allocations/get.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; import { createAllocation, createLedger } from "../../setup/factories"; -import { Allocation } from "@floyd-run/schema/types"; +import type { Allocation } from "@floyd-run/schema/types"; describe("GET /v1/ledgers/:ledgerId/allocations/:id", () => { it("returns 422 for invalid allocation id", async () => { @@ -29,7 +29,8 @@ describe("GET /v1/ledgers/:ledgerId/allocations/:id", () => { expect(data.id).toBe(allocation.id); expect(data.ledgerId).toBe(ledgerId); expect(data.resourceId).toBe(resourceId); - expect(data.status).toBe(allocation.status); + expect(data.active).toBe(allocation.active); + expect(data.bookingId).toBe(allocation.bookingId); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); diff --git a/apps/server/test/v1/allocations/idempotency.spec.ts b/apps/server/test/integration/v1/allocations/idempotency.spec.ts similarity index 52% rename from apps/server/test/v1/allocations/idempotency.spec.ts rename to apps/server/test/integration/v1/allocations/idempotency.spec.ts index f2670b1..f43907f 100644 --- a/apps/server/test/v1/allocations/idempotency.spec.ts +++ b/apps/server/test/integration/v1/allocations/idempotency.spec.ts @@ -11,9 +11,8 @@ describe("Idempotency", () => { const body = { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }; // First request @@ -43,9 +42,8 @@ describe("Idempotency", () => { `/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }, { headers: { "Idempotency-Key": idempotencyKey } }, ); @@ -56,9 +54,8 @@ describe("Idempotency", () => { `/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T11:00:00Z").toISOString(), // Different time - endAt: new Date("2026-02-01T12:00:00Z").toISOString(), + startTime: new Date("2026-02-01T11:00:00Z").toISOString(), // Different time + endTime: new Date("2026-02-01T12:00:00Z").toISOString(), }, { headers: { "Idempotency-Key": idempotencyKey } }, ); @@ -76,9 +73,8 @@ describe("Idempotency", () => { `/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }, { headers: { "Idempotency-Key": `key1-${Date.now()}` } }, ); @@ -90,9 +86,8 @@ describe("Idempotency", () => { `/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T12:00:00Z").toISOString(), - endAt: new Date("2026-02-01T13:00:00Z").toISOString(), + startTime: new Date("2026-02-01T12:00:00Z").toISOString(), + endTime: new Date("2026-02-01T13:00:00Z").toISOString(), }, { headers: { "Idempotency-Key": `key2-${Date.now()}` } }, ); @@ -109,9 +104,8 @@ describe("Idempotency", () => { const basePayload = { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }; // First request without metadata @@ -139,89 +133,11 @@ describe("Idempotency", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }); expect(response.status).toBe(201); }); }); - - describe("POST /v1/ledgers/:ledgerId/allocations/:id/confirm", () => { - it("returns same response for duplicate confirm with same idempotency key", async () => { - const { resource, ledgerId } = await createResource(); - const idempotencyKey = `confirm-key-${Date.now()}`; - - // Create a hold - const createResponse = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "hold", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - }); - const { data: hold } = (await createResponse.json()) as AllocationResponse; - - // First confirm - const response1 = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${hold.id}/confirm`, - {}, - { headers: { "Idempotency-Key": idempotencyKey } }, - ); - expect(response1.status).toBe(200); - const result1 = (await response1.json()) as AllocationResponse; - - // Second confirm with same key - const response2 = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${hold.id}/confirm`, - {}, - { headers: { "Idempotency-Key": idempotencyKey } }, - ); - expect(response2.status).toBe(200); - const result2 = (await response2.json()) as AllocationResponse; - - // Should return same response (cached) - expect(result2.data.id).toBe(result1.data.id); - expect(result2.data.status).toBe(result1.data.status); - }); - }); - - describe("POST /v1/ledgers/:ledgerId/allocations/:id/cancel", () => { - it("returns same response for duplicate cancel with same idempotency key", async () => { - const { resource, ledgerId } = await createResource(); - const idempotencyKey = `cancel-key-${Date.now()}`; - - // Create a confirmed allocation - const createResponse = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), - }); - const { data: allocation } = (await createResponse.json()) as AllocationResponse; - - // First cancel - const response1 = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/cancel`, - {}, - { headers: { "Idempotency-Key": idempotencyKey } }, - ); - expect(response1.status).toBe(200); - const result1 = (await response1.json()) as AllocationResponse; - - // Second cancel with same key - const response2 = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/cancel`, - {}, - { headers: { "Idempotency-Key": idempotencyKey } }, - ); - expect(response2.status).toBe(200); - const result2 = (await response2.json()) as AllocationResponse; - - // Should return same response (cached) - expect(result2.data.id).toBe(result1.data.id); - expect(result2.data.status).toBe(result1.data.status); - }); - }); }); diff --git a/apps/server/test/v1/allocations/list.spec.ts b/apps/server/test/integration/v1/allocations/list.spec.ts similarity index 96% rename from apps/server/test/v1/allocations/list.spec.ts rename to apps/server/test/integration/v1/allocations/list.spec.ts index 49cd5a0..03bfda8 100644 --- a/apps/server/test/v1/allocations/list.spec.ts +++ b/apps/server/test/integration/v1/allocations/list.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; import { createAllocation, createLedger } from "../../setup/factories"; -import { Allocation } from "@floyd-run/schema/types"; +import type { Allocation } from "@floyd-run/schema/types"; describe("GET /v1/ledgers/:ledgerId/allocations", () => { it("returns 200 with empty array when no allocations", async () => { diff --git a/apps/server/test/v1/auth.spec.ts b/apps/server/test/integration/v1/auth.spec.ts similarity index 100% rename from apps/server/test/v1/auth.spec.ts rename to apps/server/test/integration/v1/auth.spec.ts diff --git a/apps/server/test/v1/availability/query.spec.ts b/apps/server/test/integration/v1/availability/query.spec.ts similarity index 60% rename from apps/server/test/v1/availability/query.spec.ts rename to apps/server/test/integration/v1/availability/query.spec.ts index 89d727b..2372c33 100644 --- a/apps/server/test/v1/availability/query.spec.ts +++ b/apps/server/test/integration/v1/availability/query.spec.ts @@ -8,13 +8,13 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); @@ -22,10 +22,10 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { expect(data).toHaveLength(1); expect(data[0]!.resourceId).toBe(resource.id); - expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + expect(data[0]!.timeline).toEqual([{ startTime, endTime, status: "free" }]); }); - it("returns busy block for confirmed allocation", async () => { + it("returns busy block for active allocation", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); @@ -35,31 +35,43 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", - startAt: allocStart, - endAt: allocEnd, + active: true, + startTime: allocStart, + endTime: allocEnd, }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); const { data } = (await response.json()) as AvailabilityResponse; expect(data[0]!.timeline).toEqual([ - { startAt: "2026-01-01T10:00:00.000Z", endAt: "2026-01-01T10:30:00.000Z", status: "free" }, - { startAt: "2026-01-01T10:30:00.000Z", endAt: "2026-01-01T11:00:00.000Z", status: "busy" }, - { startAt: "2026-01-01T11:00:00.000Z", endAt: "2026-01-01T12:00:00.000Z", status: "free" }, + { + startTime: "2026-01-01T10:00:00.000Z", + endTime: "2026-01-01T10:30:00.000Z", + status: "free", + }, + { + startTime: "2026-01-01T10:30:00.000Z", + endTime: "2026-01-01T11:00:00.000Z", + status: "busy", + }, + { + startTime: "2026-01-01T11:00:00.000Z", + endTime: "2026-01-01T12:00:00.000Z", + status: "free", + }, ]); }); - it("returns busy block for unexpired hold", async () => { + it("returns busy block for unexpired temporary allocation", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); @@ -70,19 +82,19 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "hold", - startAt: allocStart, - endAt: allocEnd, + active: true, + startTime: allocStart, + endTime: allocEnd, expiresAt, }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); @@ -92,7 +104,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { expect(data[0]!.timeline[1]!.status).toBe("busy"); }); - it("ignores expired holds", async () => { + it("ignores expired temporary allocations", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); @@ -103,53 +115,53 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "hold", - startAt: allocStart, - endAt: allocEnd, + active: true, + startTime: allocStart, + endTime: allocEnd, expiresAt, }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); const { data } = (await response.json()) as AvailabilityResponse; - // Expired hold should not block - entire window is free - expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + // Expired allocation should not block - entire window is free + expect(data[0]!.timeline).toEqual([{ startTime, endTime, status: "free" }]); }); - it("ignores cancelled allocations", async () => { + it("ignores inactive allocations", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "cancelled", - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + active: false, + startTime: new Date("2026-01-01T10:30:00.000Z"), + endTime: new Date("2026-01-01T11:00:00.000Z"), }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); const { data } = (await response.json()) as AvailabilityResponse; - expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + expect(data[0]!.timeline).toEqual([{ startTime, endTime, status: "free" }]); }); it("merges overlapping allocations", async () => { @@ -160,25 +172,25 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + active: true, + startTime: new Date("2026-01-01T10:30:00.000Z"), + endTime: new Date("2026-01-01T11:00:00.000Z"), }); await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-01-01T10:45:00.000Z"), - endAt: new Date("2026-01-01T11:15:00.000Z"), + active: true, + startTime: new Date("2026-01-01T10:45:00.000Z"), + endTime: new Date("2026-01-01T11:15:00.000Z"), }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); @@ -186,9 +198,21 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { // Should be merged into single busy block 10:30-11:15 expect(data[0]!.timeline).toEqual([ - { startAt: "2026-01-01T10:00:00.000Z", endAt: "2026-01-01T10:30:00.000Z", status: "free" }, - { startAt: "2026-01-01T10:30:00.000Z", endAt: "2026-01-01T11:15:00.000Z", status: "busy" }, - { startAt: "2026-01-01T11:15:00.000Z", endAt: "2026-01-01T12:00:00.000Z", status: "free" }, + { + startTime: "2026-01-01T10:00:00.000Z", + endTime: "2026-01-01T10:30:00.000Z", + status: "free", + }, + { + startTime: "2026-01-01T10:30:00.000Z", + endTime: "2026-01-01T11:15:00.000Z", + status: "busy", + }, + { + startTime: "2026-01-01T11:15:00.000Z", + endTime: "2026-01-01T12:00:00.000Z", + status: "free", + }, ]); }); @@ -200,25 +224,25 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + active: true, + startTime: new Date("2026-01-01T10:30:00.000Z"), + endTime: new Date("2026-01-01T11:00:00.000Z"), }); await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-01-01T11:00:00.000Z"), - endAt: new Date("2026-01-01T11:30:00.000Z"), + active: true, + startTime: new Date("2026-01-01T11:00:00.000Z"), + endTime: new Date("2026-01-01T11:30:00.000Z"), }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); @@ -226,9 +250,21 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { // Should be merged into single busy block 10:30-11:30 expect(data[0]!.timeline).toEqual([ - { startAt: "2026-01-01T10:00:00.000Z", endAt: "2026-01-01T10:30:00.000Z", status: "free" }, - { startAt: "2026-01-01T10:30:00.000Z", endAt: "2026-01-01T11:30:00.000Z", status: "busy" }, - { startAt: "2026-01-01T11:30:00.000Z", endAt: "2026-01-01T12:00:00.000Z", status: "free" }, + { + startTime: "2026-01-01T10:00:00.000Z", + endTime: "2026-01-01T10:30:00.000Z", + status: "free", + }, + { + startTime: "2026-01-01T10:30:00.000Z", + endTime: "2026-01-01T11:30:00.000Z", + status: "busy", + }, + { + startTime: "2026-01-01T11:30:00.000Z", + endTime: "2026-01-01T12:00:00.000Z", + status: "free", + }, ]); }); @@ -240,25 +276,25 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-01-01T09:00:00.000Z"), - endAt: new Date("2026-01-01T13:00:00.000Z"), + active: true, + startTime: new Date("2026-01-01T09:00:00.000Z"), + endTime: new Date("2026-01-01T13:00:00.000Z"), }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); const { data } = (await response.json()) as AvailabilityResponse; // Should be clamped to query window - expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "busy" }]); + expect(data[0]!.timeline).toEqual([{ startTime, endTime, status: "busy" }]); }); it("handles multiple resources", async () => { @@ -270,18 +306,18 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource1.id, - status: "confirmed", - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + active: true, + startTime: new Date("2026-01-01T10:30:00.000Z"), + endTime: new Date("2026-01-01T11:00:00.000Z"), }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource1.id, resource2.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); @@ -295,7 +331,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { expect(item1.timeline).toHaveLength(3); expect(item1.timeline[1]!.status).toBe("busy"); - expect(item2.timeline).toEqual([{ startAt, endAt, status: "free" }]); + expect(item2.timeline).toEqual([{ startTime, endTime, status: "free" }]); }); it("excludes allocations outside query window", async () => { @@ -306,23 +342,23 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", - startAt: new Date("2026-01-01T08:00:00.000Z"), - endAt: new Date("2026-01-01T09:00:00.000Z"), + active: true, + startTime: new Date("2026-01-01T08:00:00.000Z"), + endTime: new Date("2026-01-01T09:00:00.000Z"), }); - const startAt = "2026-01-01T10:00:00.000Z"; - const endAt = "2026-01-01T12:00:00.000Z"; + const startTime = "2026-01-01T10:00:00.000Z"; + const endTime = "2026-01-01T12:00:00.000Z"; const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, { resourceIds: [resource.id], - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(200); const { data } = (await response.json()) as AvailabilityResponse; - expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + expect(data[0]!.timeline).toEqual([{ startTime, endTime, status: "free" }]); }); }); diff --git a/apps/server/test/integration/v1/availability/slots.spec.ts b/apps/server/test/integration/v1/availability/slots.spec.ts new file mode 100644 index 0000000..e3143fc --- /dev/null +++ b/apps/server/test/integration/v1/availability/slots.spec.ts @@ -0,0 +1,514 @@ +import { describe, expect, it } from "vitest"; +import { generateId } from "@floyd-run/utils"; +import { client } from "../../setup/client"; +import { + createLedger, + createResource, + createPolicy, + createService, + createAllocation, +} from "../../setup/factories"; + +interface SlotsResponse { + data: { + resourceId: string; + timezone: string; + slots: { startTime: string; endTime: string; status?: "available" | "unavailable" }[]; + }[]; + meta: { serverTime: string }; +} + +const SALON_POLICY = { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_minutes: [30, 60, 90] }, + grid: { interval_minutes: 30 }, + buffers: { after_minutes: 10 }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday", "tuesday", "wednesday", "thursday", "friday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], +}; + +// Helper: create a standard test setup +async function setupSalon(overrides?: { policyConfig?: Record }) { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: overrides?.policyConfig ?? SALON_POLICY, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + return { ledger, resource, policy: policy, service }; +} + +describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { + it("returns slots for a service with policy", async () => { + const { ledger, resource, service } = await setupSalon(); + + // Monday 2026-03-02 + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 3600000, // 60 min + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + + expect(body.data).toHaveLength(1); + expect(body.data[0]!.resourceId).toBe(resource.id); + expect(body.data[0]!.timezone).toBe("UTC"); + + // 60min slots with 30min grid on 09:00-17:00 window + // Slots at 09:00, 09:30, 10:00, ..., 16:00 (last where start + 60min <= 17:00) + const slots = body.data[0]!.slots; + expect(slots.length).toBe(15); // 09:00 through 16:00 at 30min intervals + expect(slots[0]!.startTime).toBe("2026-03-02T09:00:00.000Z"); + expect(slots[0]!.endTime).toBe("2026-03-02T10:00:00.000Z"); + expect(slots[slots.length - 1]!.startTime).toBe("2026-03-02T16:00:00.000Z"); + expect(slots[slots.length - 1]!.endTime).toBe("2026-03-02T17:00:00.000Z"); + + // No status field when includeUnavailable is false + expect(slots[0]).not.toHaveProperty("status"); + }); + + it("grid alignment: overlapping slots with grid < duration", async () => { + const { ledger, service } = await setupSalon(); + + // 90min slots with 30min grid → slots overlap + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 5400000, // 90 min + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + const slots = body.data[0]!.slots; + + // First slot 09:00-10:30, second 09:30-11:00 — they overlap + expect(slots[0]!.startTime).toBe("2026-03-02T09:00:00.000Z"); + expect(slots[0]!.endTime).toBe("2026-03-02T10:30:00.000Z"); + expect(slots[1]!.startTime).toBe("2026-03-02T09:30:00.000Z"); + expect(slots[1]!.endTime).toBe("2026-03-02T11:00:00.000Z"); + + // Last slot at 15:30 (15:30 + 90min = 17:00) + expect(slots[slots.length - 1]!.startTime).toBe("2026-03-02T15:30:00.000Z"); + }); + + it("no grid: step defaults to durationMs, non-overlapping", async () => { + const { ledger, resource } = await createLedger().then(async ({ ledger }) => { + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + return { ledger, resource }; + }); + + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default: "closed", + config: { + duration: { min_minutes: 60, max_minutes: 120 }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "13:00" }], + }, + ], + }, + }); + + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", // Monday + endTime: "2026-03-02T23:59:59Z", + durationMs: 3600000, // 60 min + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + const slots = body.data[0]!.slots; + + // No grid → step = durationMs = 60min. Slots at 09:00, 10:00, 11:00, 12:00 + expect(slots).toHaveLength(4); + expect(slots[0]!.startTime).toBe("2026-03-02T09:00:00.000Z"); + expect(slots[1]!.startTime).toBe("2026-03-02T10:00:00.000Z"); + expect(slots[2]!.startTime).toBe("2026-03-02T11:00:00.000Z"); + expect(slots[3]!.startTime).toBe("2026-03-02T12:00:00.000Z"); + }); + + it("skips days where duration is invalid", async () => { + const { ledger, resource } = await createLedger().then(async ({ ledger }) => { + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + return { ledger, resource }; + }); + + // Saturday has restricted durations + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_minutes: [30, 60, 90] }, + grid: { interval_minutes: 30 }, + }, + rules: [ + { + match: { type: "weekly", days: ["weekdays"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + { + match: { type: "weekly", days: ["saturday"] }, + windows: [{ start: "10:00", end: "14:00" }], + config: { + duration: { allowed_minutes: [30, 60] }, + }, + }, + ], + }, + }); + + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + // Query Fri 2026-03-06 to Sat 2026-03-07 with 90min duration + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-06T00:00:00Z", + endTime: "2026-03-08T00:00:00Z", + durationMs: 5400000, // 90 min + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + const slots = body.data[0]!.slots; + + // Friday should have slots, Saturday should have none (90min not in [30, 60]) + const fridaySlots = slots.filter((s) => s.startTime.startsWith("2026-03-06")); + const saturdaySlots = slots.filter((s) => s.startTime.startsWith("2026-03-07")); + + expect(fridaySlots.length).toBeGreaterThan(0); + expect(saturdaySlots).toHaveLength(0); + }); + + it("filters slots that conflict with allocations", async () => { + const { ledger, resource, service } = await setupSalon(); + + // Create allocation from 10:00-11:00 (with 10min after-buffer → effective 10:00-11:10) + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T10:00:00Z"), + endTime: new Date("2026-03-02T11:10:00Z"), // buffer-expanded + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, // 30 min + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + const slots = body.data[0]!.slots; + + // Slots whose buffer-expanded times overlap [10:00, 11:10) should be excluded + // After-buffer is 10min from policy. Before-buffer is 0. + // So slot at 10:00 → effective [10:00, 10:40) overlaps [10:00, 11:10) → excluded + // Slot at 10:30 → effective [10:30, 11:10) overlaps → excluded + // Slot at 11:00 → effective [11:00, 11:40) overlaps (start 11:00 < 11:10) → excluded + // Slot at 11:30 → effective [11:30, 12:10) does NOT overlap → included + const slotStarts = slots.map((s) => s.startTime); + expect(slotStarts).not.toContain("2026-03-02T10:00:00.000Z"); + expect(slotStarts).not.toContain("2026-03-02T10:30:00.000Z"); + expect(slotStarts).not.toContain("2026-03-02T11:00:00.000Z"); + expect(slotStarts).toContain("2026-03-02T11:30:00.000Z"); + expect(slotStarts).toContain("2026-03-02T09:00:00.000Z"); + }); + + it("includeUnavailable: returns full grid with status", async () => { + const { ledger, resource, service } = await setupSalon(); + + // Create allocation conflicting with some slots + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T10:00:00Z"), + endTime: new Date("2026-03-02T11:10:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, + includeUnavailable: true, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + const slots = body.data[0]!.slots; + + // All slots should have status + for (const slot of slots) { + expect(slot.status).toMatch(/^(available|unavailable)$/); + } + + // Conflicting slots should be unavailable + const slot1000 = slots.find((s) => s.startTime === "2026-03-02T10:00:00.000Z"); + expect(slot1000).toBeDefined(); + expect(slot1000!.status).toBe("unavailable"); + + // Non-conflicting should be available + const slot0900 = slots.find((s) => s.startTime === "2026-03-02T09:00:00.000Z"); + expect(slot0900).toBeDefined(); + expect(slot0900!.status).toBe("available"); + }); + + it("multiple resources: independent slots per resource", async () => { + const { ledger } = await createLedger(); + const { resource: r1 } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + const { resource: r2 } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + + const { policy } = await createPolicy({ ledgerId: ledger.id, config: SALON_POLICY }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [r1.id, r2.id], + }); + + // Allocation only on r1 + await createAllocation({ + ledgerId: ledger.id, + resourceId: r1.id, + active: true, + startTime: new Date("2026-03-02T10:00:00Z"), + endTime: new Date("2026-03-02T11:10:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + + expect(body.data).toHaveLength(2); + const r1Data = body.data.find((d) => d.resourceId === r1.id)!; + const r2Data = body.data.find((d) => d.resourceId === r2.id)!; + + // r2 should have more slots (no conflicts) + expect(r2Data.slots.length).toBeGreaterThan(r1Data.slots.length); + }); + + it("resourceIds filter: only returns slots for specified resources", async () => { + const { ledger } = await createLedger(); + const { resource: r1 } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + const { resource: r2 } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + + const { policy } = await createPolicy({ ledgerId: ledger.id, config: SALON_POLICY }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [r1.id, r2.id], + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, + resourceIds: [r1.id], + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + + expect(body.data).toHaveLength(1); + expect(body.data[0]!.resourceId).toBe(r1.id); + }); + + it("rejects resourceIds not in service", async () => { + const { ledger, service } = await setupSalon(); + const { resource: outsideResource } = await createResource({ ledgerId: ledger.id }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, + resourceIds: [outsideResource.id], + }, + ); + + expect(response.status).toBe(422); + }); + + it("no policy: all times open, grid defaults to durationMs", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: null, + resourceIds: [resource.id], + }); + + // Query a 2-hour window + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T10:00:00Z", + endTime: "2026-03-02T12:00:00Z", + durationMs: 1800000, // 30 min + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + const slots = body.data[0]!.slots; + + // No grid → step = 30min. Window 10:00-12:00 → 4 slots + expect(slots).toHaveLength(4); + expect(slots[0]!.startTime).toBe("2026-03-02T10:00:00.000Z"); + expect(slots[3]!.startTime).toBe("2026-03-02T11:30:00.000Z"); + }); + + it("rejects query range > 7 days", async () => { + const { ledger, service } = await setupSalon(); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-10T00:00:00Z", // 8 days + durationMs: 1800000, + }, + ); + + expect(response.status).toBe(422); + }); + + it("returns meta.serverTime", async () => { + const { ledger, service } = await setupSalon(); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + expect(body.meta.serverTime).toBeDefined(); + expect(new Date(body.meta.serverTime).getTime()).not.toBeNaN(); + }); + + it("returns per-resource timezone", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ + ledgerId: ledger.id, + timezone: "America/New_York", + }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: SALON_POLICY }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + expect(body.data[0]!.timezone).toBe("America/New_York"); + }); + + it("closed day (Sunday) produces no slots", async () => { + const { ledger, service } = await setupSalon(); + + // Sunday 2026-03-01 + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startTime: "2026-03-01T00:00:00Z", + endTime: "2026-03-01T23:59:59Z", + durationMs: 1800000, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as SlotsResponse; + expect(body.data[0]!.slots).toHaveLength(0); + }); + + it("service not found returns 404", async () => { + const { ledger } = await createLedger(); + const fakeServiceId = generateId("svc"); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${fakeServiceId}/availability/slots`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + durationMs: 1800000, + }, + ); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/server/test/integration/v1/availability/windows.spec.ts b/apps/server/test/integration/v1/availability/windows.spec.ts new file mode 100644 index 0000000..50dd5b1 --- /dev/null +++ b/apps/server/test/integration/v1/availability/windows.spec.ts @@ -0,0 +1,447 @@ +import { describe, expect, it } from "vitest"; +import { generateId } from "@floyd-run/utils"; +import { client } from "../../setup/client"; +import { + createLedger, + createResource, + createPolicy, + createService, + createAllocation, +} from "../../setup/factories"; + +interface WindowsResponse { + data: { + resourceId: string; + timezone: string; + windows: { startTime: string; endTime: string; status?: "available" | "unavailable" }[]; + }[]; + meta: { serverTime: string }; +} + +const KAYAK_POLICY = { + schema_version: 1, + default: "closed", + config: { + duration: { min_hours: 2, max_hours: 8 }, + buffers: { after_minutes: 30 }, + }, + rules: [ + { + match: { type: "weekly", days: ["everyday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], +}; + +async function setupKayak(overrides?: { policyConfig?: Record }) { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: overrides?.policyConfig ?? KAYAK_POLICY, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + return { ledger, resource, policy: policy, service }; +} + +describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { + it("returns available windows for a service with policy", async () => { + const { ledger, resource, service } = await setupKayak(); + + // Monday 2026-03-02 + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + + expect(body.data).toHaveLength(1); + expect(body.data[0]!.resourceId).toBe(resource.id); + expect(body.data[0]!.timezone).toBe("UTC"); + + // Full day open 09:00-17:00, no allocations + const windows = body.data[0]!.windows; + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-02T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-02T17:00:00.000Z"); + expect(windows[0]).not.toHaveProperty("status"); + }); + + it("allocation subtraction: existing booking carves out time", async () => { + const { ledger, resource, service } = await setupKayak(); + + // Booking 10:00-13:00 with 30min after-buffer → effective 10:00-13:30 + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T10:00:00Z"), + endTime: new Date("2026-03-02T13:30:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + const windows = body.data[0]!.windows; + + // Schedule 09:00-17:00, allocation effective [10:00, 13:30) + // Gap before: 09:00-10:00, no allocation before → no after_ms shrinkage needed + // But new booking's after_ms=30min shrinks the end: 10:00 - 30min = 09:30 + // Wait — gap end is adjacent to allocation → shrink by after_ms(30min): 10:00 - 30min = 09:30 + // Gap start at schedule boundary → no shrinkage: 09:00 + // Available: 09:00 - 09:30 (1 hour gap shrunk to 30min) → too short (min 2h) → DISCARDED + // Gap after: 13:30-17:00, gap start adjacent to allocation → shrink by before_ms(0): 13:30 + // Gap end at schedule boundary → no shrinkage: 17:00 + // Available: 13:30-17:00 (3.5 hours) → valid (>= 2h min) + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-02T13:30:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-02T17:00:00.000Z"); + }); + + it("asymmetric buffer shrinkage at allocation boundaries", async () => { + const { ledger, resource } = await createLedger().then(async ({ ledger }) => { + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + return { ledger, resource }; + }); + + // Policy with before_ms=15min, after_ms=10min + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default: "closed", + config: { + buffers: { before_minutes: 15, after_minutes: 10 }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }, + }); + + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + // Allocation effective [09:45, 11:10) + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T09:45:00Z"), + endTime: new Date("2026-03-02T11:10:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + const windows = body.data[0]!.windows; + + // Gap before allocation: 09:00 - 09:45 + // Start at schedule boundary → no shrinkage: 09:00 + // End adjacent to allocation → shrink by after_ms(10min): 09:45 - 10min = 09:35 + // Available: [09:00, 09:35) + // Gap after allocation: 11:10 - 17:00 + // Start adjacent to allocation → shrink by before_ms(15min): 11:10 + 15min = 11:25 + // End at schedule boundary → no shrinkage: 17:00 + // Available: [11:25, 17:00) + expect(windows).toHaveLength(2); + expect(windows[0]!.startTime).toBe("2026-03-02T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-02T09:35:00.000Z"); + expect(windows[1]!.startTime).toBe("2026-03-02T11:25:00.000Z"); + expect(windows[1]!.endTime).toBe("2026-03-02T17:00:00.000Z"); + }); + + it("sub-minimum filtering: gaps shorter than min_ms are discarded", async () => { + const { ledger, resource, service } = await setupKayak(); + + // Two allocations close together → small gap between them + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T09:00:00Z"), + endTime: new Date("2026-03-02T12:00:00Z"), + }); + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T13:00:00Z"), + endTime: new Date("2026-03-02T17:00:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + const windows = body.data[0]!.windows; + + // Gap between allocations: 12:00-13:00 = 1 hour, but min_hours is 2 → discarded + expect(windows).toHaveLength(0); + }); + + it("contiguous merging: windows across day boundary merge", async () => { + const { ledger, resource } = await createLedger().then(async ({ ledger }) => { + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + return { ledger, resource }; + }); + + // 24/7 open policy + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default: "open", + config: {}, + rules: [], + }, + }); + + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-04T00:00:00Z", // 2 days + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + const windows = body.data[0]!.windows; + + // Should be one contiguous 48-hour window + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-02T00:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-04T00:00:00.000Z"); + }); + + it("includeUnavailable: returns allocation times within schedule as unavailable", async () => { + const { ledger, resource, service } = await setupKayak(); + + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T10:00:00Z"), + endTime: new Date("2026-03-02T13:30:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + includeUnavailable: true, + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + const windows = body.data[0]!.windows; + + // All windows should have status + for (const w of windows) { + expect(w.status).toMatch(/^(available|unavailable)$/); + } + + // Should see unavailable window for the allocation within schedule hours + const unavailable = windows.filter((w) => w.status === "unavailable"); + expect(unavailable.length).toBeGreaterThan(0); + expect(unavailable[0]!.startTime).toBe("2026-03-02T10:00:00.000Z"); + expect(unavailable[0]!.endTime).toBe("2026-03-02T13:30:00.000Z"); + }); + + it("multiple resources: independent windows per resource", async () => { + const { ledger } = await createLedger(); + const { resource: r1 } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + const { resource: r2 } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + + const { policy } = await createPolicy({ ledgerId: ledger.id, config: KAYAK_POLICY }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [r1.id, r2.id], + }); + + // Allocation only on r1 + await createAllocation({ + ledgerId: ledger.id, + resourceId: r1.id, + active: true, + startTime: new Date("2026-03-02T10:00:00Z"), + endTime: new Date("2026-03-02T15:00:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + + const r1Data = body.data.find((d) => d.resourceId === r1.id)!; + const r2Data = body.data.find((d) => d.resourceId === r2.id)!; + + // r2 should have full 09:00-17:00 window + expect(r2Data.windows).toHaveLength(1); + expect(r2Data.windows[0]!.startTime).toBe("2026-03-02T09:00:00.000Z"); + expect(r2Data.windows[0]!.endTime).toBe("2026-03-02T17:00:00.000Z"); + + // r1 should have restricted windows (allocation carved out) + expect(r1Data.windows.length).toBeLessThan(r2Data.windows.length + 1); + }); + + it("no policy: 24h open, only allocations subtract", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id, timezone: "UTC" }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: null, + resourceIds: [resource.id], + }); + + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-03-02T10:00:00Z"), + endTime: new Date("2026-03-02T12:00:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T08:00:00Z", + endTime: "2026-03-02T16:00:00Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + const windows = body.data[0]!.windows; + + // Two windows: 08:00-10:00 and 12:00-16:00 + expect(windows).toHaveLength(2); + expect(windows[0]!.startTime).toBe("2026-03-02T08:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-02T10:00:00.000Z"); + expect(windows[1]!.startTime).toBe("2026-03-02T12:00:00.000Z"); + expect(windows[1]!.endTime).toBe("2026-03-02T16:00:00.000Z"); + }); + + it("rejects query range > 31 days", async () => { + const { ledger, service } = await setupKayak(); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-01T00:00:00Z", + endTime: "2026-04-02T00:00:00Z", // 32 days + }, + ); + + expect(response.status).toBe(422); + }); + + it("returns meta.serverTime", async () => { + const { ledger, service } = await setupKayak(); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + expect(body.meta.serverTime).toBeDefined(); + expect(new Date(body.meta.serverTime).getTime()).not.toBeNaN(); + }); + + it("returns per-resource timezone", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ + ledgerId: ledger.id, + timezone: "Europe/London", + }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: KAYAK_POLICY }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as WindowsResponse; + expect(body.data[0]!.timezone).toBe("Europe/London"); + }); + + it("service not found returns 404", async () => { + const { ledger } = await createLedger(); + const fakeServiceId = generateId("svc"); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${fakeServiceId}/availability/windows`, + { + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/server/test/integration/v1/bookings/cancel.spec.ts b/apps/server/test/integration/v1/bookings/cancel.spec.ts new file mode 100644 index 0000000..21f0b1a --- /dev/null +++ b/apps/server/test/integration/v1/bookings/cancel.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createResource, createService } from "../../setup/factories"; +import type { Booking } from "@floyd-run/schema/types"; + +describe("POST /v1/ledgers/:ledgerId/bookings/:id/cancel", () => { + async function createHoldBooking(ledgerId: string) { + const { resource } = await createResource({ ledgerId }); + const { service } = await createService({ ledgerId, 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 data; + } + + it("returns 200 when canceling a hold booking", async () => { + const { ledger } = await createLedger(); + const holdBooking = await createHoldBooking(ledger.id); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`, + ); + + 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("canceled"); + expect(data.expiresAt).toBeNull(); + expect(data.allocations).toHaveLength(1); + expect(data.allocations[0]!.active).toBe(false); + expect(meta.serverTime).toBeDefined(); + }); + + it("returns 200 when canceling a confirmed booking", async () => { + const { ledger } = await createLedger(); + const 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); + + // Now cancel + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.status).toBe("canceled"); + expect(data.allocations[0]!.active).toBe(false); + }); + + it("is idempotent — canceling an already canceled booking returns same data", async () => { + const { ledger } = await createLedger(); + const holdBooking = await createHoldBooking(ledger.id); + + // Cancel first time + const resp1 = await client.post(`/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`); + expect(resp1.status).toBe(200); + + // Cancel second time — idempotent + const resp2 = await client.post(`/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`); + expect(resp2.status).toBe(200); + const { data } = (await resp2.json()) as { data: Booking }; + expect(data.id).toBe(holdBooking.id); + expect(data.status).toBe("canceled"); + }); + + it("handles Idempotency-Key header with empty body", async () => { + const { ledger } = await createLedger(); + const holdBooking = await createHoldBooking(ledger.id); + + // Cancel with idempotency key but no body + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`, + undefined, + { + headers: { + "Idempotency-Key": "test-cancel-empty-body-456", + }, + }, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.status).toBe("canceled"); + + // Second request with same key returns cached response + const response2 = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`, + undefined, + { + headers: { + "Idempotency-Key": "test-cancel-empty-body-456", + }, + }, + ); + + expect(response2.status).toBe(200); + }); + + it("returns 404 for non-existent booking", async () => { + const { ledger } = await createLedger(); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/bkg_00000000000000000000000000/cancel`, + ); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/server/test/integration/v1/bookings/confirm.spec.ts b/apps/server/test/integration/v1/bookings/confirm.spec.ts new file mode 100644 index 0000000..e5fdedb --- /dev/null +++ b/apps/server/test/integration/v1/bookings/confirm.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createResource, createService } from "../../setup/factories"; +import type { Booking } from "@floyd-run/schema/types"; + +describe("POST /v1/ledgers/:ledgerId/bookings/:id/confirm", () => { + async function createHoldBooking(ledgerId: string) { + const { resource } = await createResource({ ledgerId }); + const { service } = await createService({ ledgerId, 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 data; + } + + it("returns 200 when confirming a hold booking", async () => { + const { ledger } = await createLedger(); + const holdBooking = await createHoldBooking(ledger.id); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/confirm`, + ); + + 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("confirmed"); + expect(data.expiresAt).toBeNull(); + expect(data.allocations).toHaveLength(1); + expect(meta.serverTime).toBeDefined(); + }); + + it("is idempotent — confirming an already confirmed booking returns same data", async () => { + const { ledger } = await createLedger(); + const holdBooking = await createHoldBooking(ledger.id); + + // Confirm first time + const resp1 = await client.post(`/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/confirm`); + expect(resp1.status).toBe(200); + + // Confirm second time — idempotent + const resp2 = await client.post(`/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/confirm`); + expect(resp2.status).toBe(200); + const { data } = (await resp2.json()) as { data: Booking }; + expect(data.id).toBe(holdBooking.id); + expect(data.status).toBe("confirmed"); + }); + + it("handles Idempotency-Key header with empty body", async () => { + const { ledger } = await createLedger(); + const holdBooking = await createHoldBooking(ledger.id); + + // Confirm with idempotency key but no body + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/confirm`, + undefined, + { + headers: { + "Idempotency-Key": "test-confirm-empty-body-123", + }, + }, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.status).toBe("confirmed"); + + // Second request with same key returns cached response + const response2 = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/confirm`, + undefined, + { + headers: { + "Idempotency-Key": "test-confirm-empty-body-123", + }, + }, + ); + + expect(response2.status).toBe(200); + }); + + it("returns 404 for non-existent booking", async () => { + const { ledger } = await createLedger(); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/bkg_00000000000000000000000000/confirm`, + ); + + expect(response.status).toBe(404); + }); + + it("returns 409 when confirming a canceled booking", async () => { + const { ledger } = await createLedger(); + const holdBooking = await createHoldBooking(ledger.id); + + // Cancel first + const cancelResp = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/cancel`, + ); + expect(cancelResp.status).toBe(200); + + // Try to confirm + const response = await client.post( + `/v1/ledgers/${ledger.id}/bookings/${holdBooking.id}/confirm`, + ); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("booking.invalid_transition"); + }); +}); diff --git a/apps/server/test/integration/v1/bookings/create.spec.ts b/apps/server/test/integration/v1/bookings/create.spec.ts new file mode 100644 index 0000000..9130fc0 --- /dev/null +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -0,0 +1,466 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { + createLedger, + createResource, + createService, + createAllocation, + createPolicy, +} from "../../setup/factories"; +import type { Booking } from "@floyd-run/schema/types"; + +describe("POST /v1/ledgers/:ledgerId/bookings", () => { + it("returns 201 for valid hold booking", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + const startTime = "2026-06-01T10:00:00.000Z"; + const endTime = "2026-06-01T11:00:00.000Z"; + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime, + endTime, + }); + + expect(response.status).toBe(201); + const { data, meta } = (await response.json()) as { + data: Booking; + meta: { serverTime: string }; + }; + expect(data.id).toMatch(/^bkg_/); + expect(data.ledgerId).toBe(ledger.id); + expect(data.serviceId).toBe(service.id); + expect(data.status).toBe("hold"); + expect(data.expiresAt).toBeDefined(); + expect(data.allocations).toHaveLength(1); + expect(data.allocations[0]!.resourceId).toBe(resource.id); + expect(data.allocations[0]!.startTime).toBe(startTime); + expect(data.allocations[0]!.endTime).toBe(endTime); + expect(data.allocations[0]!.active).toBe(true); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); + expect(meta.serverTime).toBeDefined(); + }); + + it("returns 201 for confirmed booking", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + const response = 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(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + expect(data.status).toBe("confirmed"); + expect(data.expiresAt).toBeNull(); + }); + + it("returns 201 with metadata", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + const metadata = { customerName: "Alice", notes: "Window seat" }; + + const response = 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", + metadata, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + expect(data.metadata).toEqual(metadata); + }); + + it("returns 422 for missing required fields", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, {}); + + expect(response.status).toBe(422); + }); + + it("returns 422 when endTime equals startTime", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + const time = "2026-06-01T10:00:00.000Z"; + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: time, + endTime: time, + }); + + expect(response.status).toBe(422); + }); + + it("returns 422 when endTime is before startTime", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T11:00:00.000Z", + endTime: "2026-06-01T10:00:00.000Z", + }); + + expect(response.status).toBe(422); + }); + + it("returns 404 for non-existent service", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: "svc_00000000000000000000000000", + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + }); + + expect(response.status).toBe(404); + }); + + it("returns 404 for non-existent resource", async () => { + const { ledger } = await createLedger(); + const { service } = await createService({ ledgerId: ledger.id }); + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: "rsc_00000000000000000000000000", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + }); + + expect(response.status).toBe(404); + }); + + it("returns 409 when resource does not belong to service", async () => { + const { ledger } = await createLedger(); + const { resource: r1 } = await createResource({ ledgerId: ledger.id }); + const { resource: r2 } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [r1.id], // only r1 is in the service + }); + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: r2.id, // r2 is NOT in the service + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + }); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("service.resource_not_member"); + }); + + describe("conflict detection", () => { + it("returns 409 when overlapping with existing active allocation", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + // Create a raw allocation blocking 10:00-11:00 + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startTime: new Date("2026-06-01T10:00:00.000Z"), + endTime: new Date("2026-06-01T11:00:00.000Z"), + }); + + // Try to book overlapping time + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:30:00.000Z", + endTime: "2026-06-01T11:30:00.000Z", + }); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("allocation.overlap"); + }); + + it("returns 409 when overlapping with existing booking", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + // Create first booking + const first = 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(first.status).toBe(201); + + // Try to create overlapping booking + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:30:00.000Z", + endTime: "2026-06-01T11:30:00.000Z", + }); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("allocation.overlap"); + }); + + it("allows adjacent bookings (no overlap)", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + const first = 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(first.status).toBe(201); + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T11:00:00.000Z", + endTime: "2026-06-01T12:00:00.000Z", + status: "confirmed", + }); + + expect(response.status).toBe(201); + }); + + it("ignores inactive allocations for conflict detection", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + // Create an inactive allocation + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: false, + startTime: new Date("2026-06-01T10:00:00.000Z"), + endTime: new Date("2026-06-01T11:00:00.000Z"), + }); + + // Should succeed because inactive allocation doesn't block + const response = 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", + }); + + expect(response.status).toBe(201); + }); + }); + + describe("buffers", () => { + it("stores buffer-expanded times as allocation startTime/endTime", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default: "open", + config: { + buffers: { before_ms: 900_000, after_ms: 600_000 }, // 15min before, 10min after + }, + }, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + const response = 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(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + const allocation = data.allocations[0]!; + + // Allocation startTime/endTime = buffer-expanded blocked window + expect(allocation.startTime).toBe("2026-06-01T09:45:00.000Z"); // 10:00 - 15min + expect(allocation.endTime).toBe("2026-06-01T11:10:00.000Z"); // 11:00 + 10min + + // Buffer amounts stored for deriving original customer time + expect(allocation.buffer.beforeMs).toBe(900_000); + expect(allocation.buffer.afterMs).toBe(600_000); + }); + + it("stores zero buffers when no policy", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + const startTime = "2026-06-01T10:00:00.000Z"; + const endTime = "2026-06-01T11:00:00.000Z"; + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime, + endTime, + status: "confirmed", + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + const allocation = data.allocations[0]!; + + // Without policy, allocation times = input times (no buffers) + expect(allocation.startTime).toBe(startTime); + expect(allocation.endTime).toBe(endTime); + expect(allocation.buffer.beforeMs).toBe(0); + expect(allocation.buffer.afterMs).toBe(0); + }); + + it("detects conflicts across buffer windows", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default: "open", + config: { + buffers: { before_ms: 0, after_ms: 1_800_000 }, // 30min after-buffer + }, + }, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + // First booking: customer 10:00-11:00, allocation blocks until 11:30 + const first = 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(first.status).toBe(201); + + // Second booking: customer 11:00-12:00, allocation starts at 11:00 + // Conflicts because first allocation blocks until 11:30 + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T11:00:00.000Z", + endTime: "2026-06-01T12:00:00.000Z", + status: "confirmed", + }); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("allocation.overlap"); + }); + + it("allows bookings outside the buffer window", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ + ledgerId: ledger.id, + config: { + schema_version: 1, + default: "open", + config: { + buffers: { before_ms: 900_000, after_ms: 900_000 }, // 15min each side + }, + }, + }); + const { service } = await createService({ + ledgerId: ledger.id, + policyId: policy.id, + resourceIds: [resource.id], + }); + + // First booking: customer 10:00-11:00, allocation 09:45-11:15 + const first = 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(first.status).toBe(201); + + // Second booking: customer 11:30-12:30, allocation 11:15-12:45 + // Adjacent at 11:15 — no overlap + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T11:30:00.000Z", + endTime: "2026-06-01T12:30:00.000Z", + status: "confirmed", + }); + + expect(response.status).toBe(201); + }); + }); +}); diff --git a/apps/server/test/integration/v1/bookings/get.spec.ts b/apps/server/test/integration/v1/bookings/get.spec.ts new file mode 100644 index 0000000..1f688ce --- /dev/null +++ b/apps/server/test/integration/v1/bookings/get.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createBooking } from "../../setup/factories"; +import type { Booking } from "@floyd-run/schema/types"; + +describe("GET /v1/ledgers/:ledgerId/bookings/:id", () => { + it("returns 422 for invalid booking id", async () => { + const { ledger } = await createLedger(); + const response = await client.get(`/v1/ledgers/${ledger.id}/bookings/invalid-id`); + + expect(response.status).toBe(422); + }); + + it("returns 404 for non-existent booking", async () => { + const { ledger } = await createLedger(); + const response = await client.get( + `/v1/ledgers/${ledger.id}/bookings/bkg_00000000000000000000000000`, + ); + + expect(response.status).toBe(404); + }); + + it("returns 200 with booking data and nested allocations", async () => { + const { booking, ledgerId, serviceId, resourceId } = await createBooking(); + + const response = await client.get(`/v1/ledgers/${ledgerId}/bookings/${booking.id}`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking }; + expect(data.id).toBe(booking.id); + expect(data.ledgerId).toBe(ledgerId); + expect(data.serviceId).toBe(serviceId); + expect(data.status).toBe("hold"); + expect(data.allocations).toHaveLength(1); + expect(data.allocations[0]!.resourceId).toBe(resourceId); + expect(data.allocations[0]!.active).toBe(true); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); + }); +}); diff --git a/apps/server/test/integration/v1/bookings/list.spec.ts b/apps/server/test/integration/v1/bookings/list.spec.ts new file mode 100644 index 0000000..e39773a --- /dev/null +++ b/apps/server/test/integration/v1/bookings/list.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createBooking } from "../../setup/factories"; +import type { Booking } from "@floyd-run/schema/types"; + +describe("GET /v1/ledgers/:ledgerId/bookings", () => { + it("returns 200 with empty array when no bookings", async () => { + const { ledger } = await createLedger(); + const response = await client.get(`/v1/ledgers/${ledger.id}/bookings`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking[] }; + expect(data).toEqual([]); + }); + + it("returns bookings for the ledger", async () => { + const { ledger } = await createLedger(); + await createBooking({ ledgerId: ledger.id }); + await createBooking({ ledgerId: ledger.id }); + + const response = await client.get(`/v1/ledgers/${ledger.id}/bookings`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking[] }; + expect(data).toHaveLength(2); + expect(data[0]!.ledgerId).toBe(ledger.id); + expect(data[1]!.ledgerId).toBe(ledger.id); + // Each booking should have allocations + expect(data[0]!.allocations).toHaveLength(1); + expect(data[1]!.allocations).toHaveLength(1); + }); + + it("does not return bookings from other ledgers", async () => { + const { ledger: ledger1 } = await createLedger(); + const { ledger: ledger2 } = await createLedger(); + + await createBooking({ ledgerId: ledger1.id }); + await createBooking({ ledgerId: ledger2.id }); + + const response = await client.get(`/v1/ledgers/${ledger1.id}/bookings`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Booking[] }; + expect(data).toHaveLength(1); + expect(data[0]!.ledgerId).toBe(ledger1.id); + }); +}); diff --git a/apps/server/test/v1/ledgers/create.spec.ts b/apps/server/test/integration/v1/ledgers/create.spec.ts similarity index 100% rename from apps/server/test/v1/ledgers/create.spec.ts rename to apps/server/test/integration/v1/ledgers/create.spec.ts diff --git a/apps/server/test/v1/ledgers/get.spec.ts b/apps/server/test/integration/v1/ledgers/get.spec.ts similarity index 100% rename from apps/server/test/v1/ledgers/get.spec.ts rename to apps/server/test/integration/v1/ledgers/get.spec.ts diff --git a/apps/server/test/v1/ledgers/list.spec.ts b/apps/server/test/integration/v1/ledgers/list.spec.ts similarity index 83% rename from apps/server/test/v1/ledgers/list.spec.ts rename to apps/server/test/integration/v1/ledgers/list.spec.ts index afa1bb0..f54910f 100644 --- a/apps/server/test/v1/ledgers/list.spec.ts +++ b/apps/server/test/integration/v1/ledgers/list.spec.ts @@ -14,8 +14,8 @@ describe("GET /v1/ledgers", () => { }); it("returns 200 with ledgers list", async () => { - const { ledger: ws1 } = await createLedger(); - const { ledger: ws2 } = await createLedger(); + const { ledger: ledger1 } = await createLedger(); + const { ledger: ledger2 } = await createLedger(); const response = await client.get("/v1/ledgers"); expect(response.status).toBe(200); @@ -25,7 +25,7 @@ describe("GET /v1/ledgers", () => { expect(body.data.length).toBeGreaterThanOrEqual(2); const ids = body.data.map((w) => w.id); - expect(ids).toContain(ws1.id); - expect(ids).toContain(ws2.id); + expect(ids).toContain(ledger1.id); + expect(ids).toContain(ledger2.id); }); }); diff --git a/apps/server/test/integration/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts new file mode 100644 index 0000000..e0e42e8 --- /dev/null +++ b/apps/server/test/integration/v1/policies/create.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger } from "../../setup/factories"; +import type { Policy } from "@floyd-run/schema/types"; + +const validAuthoringConfig = { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_minutes: [30, 60] }, + grid: { interval_minutes: 15 }, + }, + rules: [ + { + match: { type: "weekly", days: ["weekdays"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], +}; + +describe("POST /v1/ledgers/:ledgerId/policies", () => { + it("returns 201 for valid policy with authoring format", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: validAuthoringConfig, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Policy }; + expect(data.id).toMatch(/^pol_/); + expect(data.ledgerId).toBe(ledger.id); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); + }); + + it("normalizes config from authoring format to canonical format", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: validAuthoringConfig, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Policy }; + const config = data.config; + const duration = config["config"] as Record>; + + // allowed_minutes: [30, 60] -> allowed_ms: [1800000, 3600000] + expect(duration["duration"]!["allowed_ms"]).toEqual([1800000, 3600000]); + expect(duration["duration"]!["allowed_minutes"]).toBeUndefined(); + + // interval_minutes: 15 -> interval_ms: 900000 + expect(duration["grid"]!["interval_ms"]).toBe(900000); + expect(duration["grid"]!["interval_minutes"]).toBeUndefined(); + + // weekdays -> expanded day names + const rules = config["rules"] as { + match: { days: string[] }; + }[]; + expect(rules[0]!.match.days).toEqual(["monday", "tuesday", "wednesday", "thursday", "friday"]); + }); + + it("returns configHash", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: validAuthoringConfig, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Policy }; + expect(data.configHash).toBeDefined(); + expect(typeof data.configHash).toBe("string"); + expect(data.configHash.length).toBeGreaterThan(0); + }); + + it("returns 422 for invalid schema_version", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: { + ...validAuthoringConfig, + schema_version: 2, + }, + }); + + expect(response.status).toBe(422); + }); + + it("returns 422 for missing required fields", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: { + schema_version: 1, + // missing default and config + }, + }); + + expect(response.status).toBe(422); + }); + + it("returns 422 for invalid time window where end is before start", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_minutes: [30] }, + grid: { interval_minutes: 15 }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "17:00", end: "09:00" }], + }, + ], + }, + }); + + expect(response.status).toBe(422); + }); + + it("returns 422 for closed rule with windows", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_minutes: [30] }, + grid: { interval_minutes: 15 }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + closed: true, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }, + }); + + expect(response.status).toBe(422); + }); + + it("returns warnings for unreachable rules with duplicate days", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + config: { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_minutes: [30] }, + grid: { interval_minutes: 15 }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday", "tuesday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + { + match: { type: "weekly", days: ["monday", "wednesday"] }, + windows: [{ start: "10:00", end: "16:00" }], + }, + ], + }, + }); + + expect(response.status).toBe(201); + const body = (await response.json()) as { + data: Policy; + meta: { warnings: { code: string; message: string }[] }; + }; + expect(body.meta.warnings).toBeDefined(); + expect(body.meta.warnings.length).toBeGreaterThan(0); + expect(body.meta.warnings[0]!.code).toBe("unreachable_weekly_rule"); + }); +}); diff --git a/apps/server/test/integration/v1/policies/delete.spec.ts b/apps/server/test/integration/v1/policies/delete.spec.ts new file mode 100644 index 0000000..ea4d5ea --- /dev/null +++ b/apps/server/test/integration/v1/policies/delete.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createPolicy } from "../../setup/factories"; +import type { Policy } from "@floyd-run/schema/types"; + +describe("DELETE /v1/ledgers/:ledgerId/policies/:id", () => { + it("returns 204 for successful delete", async () => { + const { ledger } = await createLedger(); + const { policy } = await createPolicy({ ledgerId: ledger.id }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/policies/${policy.id}`); + + expect(response.status).toBe(204); + + // Verify it's deleted by listing + const listResponse = await client.get(`/v1/ledgers/${ledger.id}/policies`); + const { data } = (await listResponse.json()) as { data: Policy[] }; + expect(data.find((p) => p.id === policy.id)).toBeUndefined(); + }); + + it("returns 404 for non-existent policy", async () => { + const { ledger } = await createLedger(); + + const response = await client.delete( + `/v1/ledgers/${ledger.id}/policies/pol_00000000000000000000000000`, + ); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/server/test/integration/v1/policies/get.spec.ts b/apps/server/test/integration/v1/policies/get.spec.ts new file mode 100644 index 0000000..58a08b9 --- /dev/null +++ b/apps/server/test/integration/v1/policies/get.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createPolicy } from "../../setup/factories"; +import type { Policy } from "@floyd-run/schema/types"; + +describe("GET /v1/ledgers/:ledgerId/policies/:id", () => { + it("returns 200 for existing policy", async () => { + const { policy, ledgerId } = await createPolicy(); + + const response = await client.get(`/v1/ledgers/${ledgerId}/policies/${policy.id}`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy }; + expect(data.id).toBe(policy.id); + expect(data.ledgerId).toBe(ledgerId); + expect(data.config).toBeDefined(); + expect(data.configHash).toBeDefined(); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); + }); + + it("returns 404 for non-existent policy", async () => { + const { ledger } = await createLedger(); + + const response = await client.get( + `/v1/ledgers/${ledger.id}/policies/pol_00000000000000000000000000`, + ); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/server/test/integration/v1/policies/list.spec.ts b/apps/server/test/integration/v1/policies/list.spec.ts new file mode 100644 index 0000000..b0bfee5 --- /dev/null +++ b/apps/server/test/integration/v1/policies/list.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createPolicy } from "../../setup/factories"; +import type { Policy } from "@floyd-run/schema/types"; + +describe("GET /v1/ledgers/:ledgerId/policies", () => { + it("returns empty array when no policies exist", async () => { + const { ledger } = await createLedger(); + + const response = await client.get(`/v1/ledgers/${ledger.id}/policies`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy[] }; + expect(data).toEqual([]); + }); + + it("returns all policies for a ledger", async () => { + const { ledger } = await createLedger(); + await createPolicy({ ledgerId: ledger.id }); + await createPolicy({ ledgerId: ledger.id }); + + const response = await client.get(`/v1/ledgers/${ledger.id}/policies`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy[] }; + expect(data).toHaveLength(2); + }); +}); diff --git a/apps/server/test/integration/v1/policies/update.spec.ts b/apps/server/test/integration/v1/policies/update.spec.ts new file mode 100644 index 0000000..07e66a0 --- /dev/null +++ b/apps/server/test/integration/v1/policies/update.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createPolicy } from "../../setup/factories"; +import type { Policy } from "@floyd-run/schema/types"; + +const validConfig = { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_minutes: [30, 60] }, + grid: { interval_minutes: 15 }, + }, + rules: [ + { + match: { type: "weekly", days: ["weekdays"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], +}; + +const updatedConfig = { + schema_version: 1, + default: "open", + config: { + duration: { allowed_minutes: [45, 90] }, + grid: { interval_minutes: 30 }, + }, + rules: [ + { + match: { type: "weekly", days: ["weekends"] }, + windows: [{ start: "10:00", end: "16:00" }], + }, + ], +}; + +describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { + it("returns 200 for valid update", async () => { + const { policy, ledgerId } = await createPolicy(); + + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + config: updatedConfig, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy }; + expect(data.id).toBe(policy.id); + expect(data.ledgerId).toBe(ledgerId); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); + }); + + it("updates config and recalculates hash", async () => { + const { policy, ledgerId } = await createPolicy(); + const originalHash = policy.configHash; + + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + config: updatedConfig, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy }; + + // Config should reflect the updated values (normalized) + const config = data.config; + expect(config["default"] as string).toBe("open"); + + // Hash should be recalculated and differ from original + expect(data.configHash).toBeDefined(); + expect(data.configHash).not.toBe(originalHash); + }); + + it("returns 404 for non-existent policy", async () => { + const { ledger } = await createLedger(); + + const response = await client.put( + `/v1/ledgers/${ledger.id}/policies/pol_00000000000000000000000000`, + { config: validConfig }, + ); + + expect(response.status).toBe(404); + }); + + it("returns 422 for invalid config", async () => { + const { policy, ledgerId } = await createPolicy(); + + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + config: { + schema_version: 2, + default: "closed", + config: {}, + }, + }); + + expect(response.status).toBe(422); + }); +}); diff --git a/apps/server/test/v1/resources/create.spec.ts b/apps/server/test/integration/v1/resources/create.spec.ts similarity index 50% rename from apps/server/test/v1/resources/create.spec.ts rename to apps/server/test/integration/v1/resources/create.spec.ts index 00f1091..66c2012 100644 --- a/apps/server/test/v1/resources/create.spec.ts +++ b/apps/server/test/integration/v1/resources/create.spec.ts @@ -6,7 +6,9 @@ import type { ResourceResponse } from "../../setup/types"; describe("POST /v1/ledgers/:ledgerId/resources", () => { it("returns 201 with created resource", async () => { const { ledger } = await createLedger(); - const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, {}); + const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + timezone: "UTC", + }); expect(response.status).toBe(201); const { data } = (await response.json()) as ResourceResponse; @@ -15,4 +17,24 @@ describe("POST /v1/ledgers/:ledgerId/resources", () => { expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); + + it("returns 201 with valid IANA timezone", async () => { + const { ledger } = await createLedger(); + const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + timezone: "America/New_York", + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as ResourceResponse; + expect(data.timezone).toBe("America/New_York"); + }); + + it("returns 422 for invalid timezone", async () => { + const { ledger } = await createLedger(); + const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + timezone: "Not/A_Timezone", + }); + + expect(response.status).toBe(422); + }); }); diff --git a/apps/server/test/integration/v1/resources/delete.spec.ts b/apps/server/test/integration/v1/resources/delete.spec.ts new file mode 100644 index 0000000..d68b41d --- /dev/null +++ b/apps/server/test/integration/v1/resources/delete.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { + createLedger, + createResource, + createAllocation, + createService, +} from "../../setup/factories"; + +describe("DELETE /v1/ledgers/:ledgerId/resources/:id", () => { + it("returns 204 for successful deletion", async () => { + const { resource, ledgerId } = await createResource(); + + const response = await client.delete(`/v1/ledgers/${ledgerId}/resources/${resource.id}`); + + expect(response.status).toBe(204); + }); + + it("returns 404 for non-existent resource", async () => { + const { ledger } = await createLedger(); + + const response = await client.delete( + `/v1/ledgers/${ledger.id}/resources/rsc_00000000000000000000000000`, + ); + + expect(response.status).toBe(404); + }); + + it("returns 409 when resource has active allocations", async () => { + const { resource, ledgerId } = await createResource(); + await createAllocation({ + ledgerId, + resourceId: resource.id, + active: true, + startTime: new Date("2026-06-01T10:00:00Z"), + endTime: new Date("2026-06-01T11:00:00Z"), + }); + + const response = await client.delete(`/v1/ledgers/${ledgerId}/resources/${resource.id}`); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("resource.in_use"); + }); + + it("returns 409 when resource belongs to a service", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + await createService({ + ledgerId: ledger.id, + resourceIds: [resource.id], + }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/resources/${resource.id}`); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("resource.in_use"); + }); +}); diff --git a/apps/server/test/v1/resources/get.spec.ts b/apps/server/test/integration/v1/resources/get.spec.ts similarity index 100% rename from apps/server/test/v1/resources/get.spec.ts rename to apps/server/test/integration/v1/resources/get.spec.ts diff --git a/apps/server/test/v1/resources/list.spec.ts b/apps/server/test/integration/v1/resources/list.spec.ts similarity index 83% rename from apps/server/test/v1/resources/list.spec.ts rename to apps/server/test/integration/v1/resources/list.spec.ts index a2c9f64..f3354a8 100644 --- a/apps/server/test/v1/resources/list.spec.ts +++ b/apps/server/test/integration/v1/resources/list.spec.ts @@ -31,12 +31,12 @@ describe("GET /v1/ledgers/:ledgerId/resources", () => { }); it("does not return resources from other ledgers", async () => { - const { ledger: ws1 } = await createLedger(); - const { ledger: ws2 } = await createLedger(); - const { resource } = await createResource({ ledgerId: ws1.id }); - await createResource({ ledgerId: ws2.id }); + const { ledger: ledger1 } = await createLedger(); + const { ledger: ledger2 } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger1.id }); + await createResource({ ledgerId: ledger2.id }); - const response = await client.get(`/v1/ledgers/${ws1.id}/resources`); + const response = await client.get(`/v1/ledgers/${ledger1.id}/resources`); expect(response.status).toBe(200); const { data } = (await response.json()) as ListResponse; diff --git a/apps/server/test/integration/v1/services/create.spec.ts b/apps/server/test/integration/v1/services/create.spec.ts new file mode 100644 index 0000000..b5a774e --- /dev/null +++ b/apps/server/test/integration/v1/services/create.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createResource, createPolicy } from "../../setup/factories"; +import type { Service } from "@floyd-run/schema/types"; + +describe("POST /v1/ledgers/:ledgerId/services", () => { + it("returns 201 for valid service", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, { + name: "Haircut", + resourceIds: [resource.id], + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Service }; + expect(data.id).toMatch(/^svc_/); + expect(data.ledgerId).toBe(ledger.id); + expect(data.name).toBe("Haircut"); + expect(data.policyId).toBeNull(); + expect(data.resourceIds).toEqual([resource.id]); + expect(data.metadata).toBeNull(); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); + }); + + it("returns 201 with no resources", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, { + name: "Empty Service", + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Service }; + expect(data.resourceIds).toEqual([]); + }); + + it("returns 201 with policyId", async () => { + const { ledger } = await createLedger(); + const { policy } = await createPolicy({ ledgerId: ledger.id }); + const { resource } = await createResource({ ledgerId: ledger.id }); + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, { + name: "Guided Tour", + policyId: policy.id, + resourceIds: [resource.id], + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Service }; + expect(data.policyId).toBe(policy.id); + }); + + it("returns 201 with metadata", async () => { + const { ledger } = await createLedger(); + const metadata = { duration: 60, category: "wellness" }; + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, { + name: "Massage", + metadata, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Service }; + expect(data.metadata).toEqual(metadata); + }); + + it("returns 201 with multiple resources", async () => { + const { ledger } = await createLedger(); + const { resource: r1 } = await createResource({ ledgerId: ledger.id }); + const { resource: r2 } = await createResource({ ledgerId: ledger.id }); + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, { + name: "Multi-Resource Service", + resourceIds: [r1.id, r2.id], + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Service }; + expect(data.resourceIds).toHaveLength(2); + expect(data.resourceIds).toContain(r1.id); + expect(data.resourceIds).toContain(r2.id); + }); + + it("returns 422 for missing name", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, {}); + + expect(response.status).toBe(422); + }); + + it("returns 404 for non-existent policyId", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, { + name: "Test", + policyId: "pol_00000000000000000000000000", + }); + + expect(response.status).toBe(404); + }); + + it("returns 404 for non-existent resourceId", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/services`, { + name: "Test", + resourceIds: ["rsc_00000000000000000000000000"], + }); + + expect(response.status).toBe(404); + }); + + it("returns 404 for resource from different ledger", async () => { + const { ledger: ledger1 } = await createLedger(); + const { ledger: ledger2 } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger2.id }); + + const response = await client.post(`/v1/ledgers/${ledger1.id}/services`, { + name: "Test", + resourceIds: [resource.id], + }); + + expect(response.status).toBe(404); + }); + + it("returns 404 for policy from different ledger", async () => { + const { ledger: ledger1 } = await createLedger(); + const { policy } = await createPolicy(); // creates its own ledger + + const response = await client.post(`/v1/ledgers/${ledger1.id}/services`, { + name: "Test", + policyId: policy.id, + }); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/server/test/integration/v1/services/delete.spec.ts b/apps/server/test/integration/v1/services/delete.spec.ts new file mode 100644 index 0000000..8228838 --- /dev/null +++ b/apps/server/test/integration/v1/services/delete.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createService, createBooking } from "../../setup/factories"; + +describe("DELETE /v1/ledgers/:ledgerId/services/:id", () => { + it("returns 204 for successful deletion", async () => { + const { ledger } = await createLedger(); + const { service } = await createService({ ledgerId: ledger.id }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/services/${service.id}`); + + expect(response.status).toBe(204); + }); + + it("service is gone after deletion", async () => { + const { ledger } = await createLedger(); + const { service } = await createService({ ledgerId: ledger.id }); + + await client.delete(`/v1/ledgers/${ledger.id}/services/${service.id}`); + const getResp = await client.get(`/v1/ledgers/${ledger.id}/services/${service.id}`); + + expect(getResp.status).toBe(404); + }); + + it("returns 404 for non-existent service", async () => { + const { ledger } = await createLedger(); + + const response = await client.delete( + `/v1/ledgers/${ledger.id}/services/svc_00000000000000000000000000`, + ); + + expect(response.status).toBe(404); + }); + + it("returns 409 when service has active hold bookings", async () => { + const { ledger } = await createLedger(); + const { serviceId } = await createBooking({ ledgerId: ledger.id, status: "hold" }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/services/${serviceId}`); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("service.active_bookings"); + }); + + it("returns 409 when service has confirmed bookings", async () => { + const { ledger } = await createLedger(); + const { serviceId } = await createBooking({ ledgerId: ledger.id, status: "confirmed" }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/services/${serviceId}`); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("service.active_bookings"); + }); + + it("allows deletion when all bookings are canceled", async () => { + const { ledger } = await createLedger(); + const { serviceId } = await createBooking({ ledgerId: ledger.id, status: "canceled" }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/services/${serviceId}`); + + expect(response.status).toBe(204); + }); + + it("allows deletion when all bookings are expired", async () => { + const { ledger } = await createLedger(); + const { serviceId } = await createBooking({ ledgerId: ledger.id, status: "expired" }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/services/${serviceId}`); + + expect(response.status).toBe(204); + }); +}); diff --git a/apps/server/test/integration/v1/services/get.spec.ts b/apps/server/test/integration/v1/services/get.spec.ts new file mode 100644 index 0000000..cef6499 --- /dev/null +++ b/apps/server/test/integration/v1/services/get.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createResource, createService } from "../../setup/factories"; +import type { Service } from "@floyd-run/schema/types"; + +describe("GET /v1/ledgers/:ledgerId/services/:id", () => { + it("returns 422 for invalid service id", async () => { + const { ledger } = await createLedger(); + const response = await client.get(`/v1/ledgers/${ledger.id}/services/invalid-id`); + + expect(response.status).toBe(422); + }); + + it("returns 404 for non-existent service", async () => { + const { ledger } = await createLedger(); + const response = await client.get( + `/v1/ledgers/${ledger.id}/services/svc_00000000000000000000000000`, + ); + + expect(response.status).toBe(404); + }); + + it("returns 200 with service data", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + name: "Test Service", + resourceIds: [resource.id], + }); + + const response = await client.get(`/v1/ledgers/${ledger.id}/services/${service.id}`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service }; + expect(data.id).toBe(service.id); + expect(data.ledgerId).toBe(ledger.id); + expect(data.name).toBe("Test Service"); + expect(data.resourceIds).toEqual([resource.id]); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); + }); + + it("returns service with empty resourceIds", async () => { + const { ledger } = await createLedger(); + const { service } = await createService({ ledgerId: ledger.id }); + + const response = await client.get(`/v1/ledgers/${ledger.id}/services/${service.id}`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service }; + expect(data.resourceIds).toEqual([]); + }); +}); diff --git a/apps/server/test/integration/v1/services/list.spec.ts b/apps/server/test/integration/v1/services/list.spec.ts new file mode 100644 index 0000000..c342a62 --- /dev/null +++ b/apps/server/test/integration/v1/services/list.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createService } from "../../setup/factories"; +import type { Service } from "@floyd-run/schema/types"; + +describe("GET /v1/ledgers/:ledgerId/services", () => { + it("returns 200 with empty array when no services", async () => { + const { ledger } = await createLedger(); + const response = await client.get(`/v1/ledgers/${ledger.id}/services`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service[] }; + expect(data).toEqual([]); + }); + + it("returns services for the ledger", async () => { + const { ledger } = await createLedger(); + await createService({ ledgerId: ledger.id, name: "Service A" }); + await createService({ ledgerId: ledger.id, name: "Service B" }); + + const response = await client.get(`/v1/ledgers/${ledger.id}/services`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service[] }; + expect(data).toHaveLength(2); + expect(data[0]!.ledgerId).toBe(ledger.id); + expect(data[1]!.ledgerId).toBe(ledger.id); + }); + + it("does not return services from other ledgers", async () => { + const { ledger: ledger1 } = await createLedger(); + const { ledger: ledger2 } = await createLedger(); + + await createService({ ledgerId: ledger1.id, name: "Ledger 1 Service" }); + await createService({ ledgerId: ledger2.id, name: "Ledger 2 Service" }); + + const response = await client.get(`/v1/ledgers/${ledger1.id}/services`); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service[] }; + expect(data).toHaveLength(1); + expect(data[0]!.ledgerId).toBe(ledger1.id); + }); +}); diff --git a/apps/server/test/integration/v1/services/update.spec.ts b/apps/server/test/integration/v1/services/update.spec.ts new file mode 100644 index 0000000..eb612dc --- /dev/null +++ b/apps/server/test/integration/v1/services/update.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createLedger, createResource, createService, createPolicy } from "../../setup/factories"; +import type { Service } from "@floyd-run/schema/types"; + +describe("PUT /v1/ledgers/:ledgerId/services/:id", () => { + it("returns 200 with updated service", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ ledgerId: ledger.id, name: "Old Name" }); + + const response = await client.put(`/v1/ledgers/${ledger.id}/services/${service.id}`, { + name: "New Name", + resourceIds: [resource.id], + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service }; + expect(data.id).toBe(service.id); + expect(data.name).toBe("New Name"); + expect(data.resourceIds).toEqual([resource.id]); + }); + + it("replaces resourceIds on update", async () => { + const { ledger } = await createLedger(); + const { resource: r1 } = await createResource({ ledgerId: ledger.id }); + const { resource: r2 } = await createResource({ ledgerId: ledger.id }); + const { service } = await createService({ + ledgerId: ledger.id, + resourceIds: [r1.id], + }); + + // Verify initial state + const getResp = await client.get(`/v1/ledgers/${ledger.id}/services/${service.id}`); + const initial = (await getResp.json()) as { data: Service }; + expect(initial.data.resourceIds).toEqual([r1.id]); + + // Update to replace r1 with r2 + const response = await client.put(`/v1/ledgers/${ledger.id}/services/${service.id}`, { + name: service.name, + resourceIds: [r2.id], + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service }; + expect(data.resourceIds).toEqual([r2.id]); + }); + + it("can set policyId on update", async () => { + const { ledger } = await createLedger(); + const { service } = await createService({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id }); + + const response = await client.put(`/v1/ledgers/${ledger.id}/services/${service.id}`, { + name: "Updated", + policyId: policy.id, + resourceIds: [], + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service }; + expect(data.policyId).toBe(policy.id); + }); + + it("can clear policyId on update", async () => { + const { ledger } = await createLedger(); + const { policy } = await createPolicy({ ledgerId: ledger.id }); + const { service } = await createService({ ledgerId: ledger.id, policyId: policy.id }); + + const response = await client.put(`/v1/ledgers/${ledger.id}/services/${service.id}`, { + name: "Updated", + policyId: null, + resourceIds: [], + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service }; + expect(data.policyId).toBeNull(); + }); + + it("returns 404 for non-existent service", async () => { + const { ledger } = await createLedger(); + + const response = await client.put( + `/v1/ledgers/${ledger.id}/services/svc_00000000000000000000000000`, + { name: "Test", resourceIds: [] }, + ); + + expect(response.status).toBe(404); + }); + + it("returns 404 for non-existent resourceId", async () => { + const { ledger } = await createLedger(); + const { service } = await createService({ ledgerId: ledger.id }); + + const response = await client.put(`/v1/ledgers/${ledger.id}/services/${service.id}`, { + name: "Test", + resourceIds: ["rsc_00000000000000000000000000"], + }); + + expect(response.status).toBe(404); + }); + + it("returns 422 for missing name", async () => { + const { ledger } = await createLedger(); + const { service } = await createService({ ledgerId: ledger.id }); + + const response = await client.put(`/v1/ledgers/${ledger.id}/services/${service.id}`, { + resourceIds: [], + }); + + expect(response.status).toBe(422); + }); +}); diff --git a/apps/server/test/v1/webhooks/create.spec.ts b/apps/server/test/integration/v1/webhooks/create.spec.ts similarity index 93% rename from apps/server/test/v1/webhooks/create.spec.ts rename to apps/server/test/integration/v1/webhooks/create.spec.ts index 1de9d75..fc5255a 100644 --- a/apps/server/test/v1/webhooks/create.spec.ts +++ b/apps/server/test/integration/v1/webhooks/create.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; import { createLedger } from "../../setup/factories"; -import { WebhookSubscription } from "../../../src/routes/v1/serializers"; +import type { WebhookSubscription } from "routes/v1/serializers"; describe("POST /v1/ledgers/:ledgerId/webhooks", () => { it("returns 201 for valid webhook subscription", async () => { diff --git a/apps/server/test/v1/webhooks/delete.spec.ts b/apps/server/test/integration/v1/webhooks/delete.spec.ts similarity index 100% rename from apps/server/test/v1/webhooks/delete.spec.ts rename to apps/server/test/integration/v1/webhooks/delete.spec.ts diff --git a/apps/server/test/v1/webhooks/list.spec.ts b/apps/server/test/integration/v1/webhooks/list.spec.ts similarity index 96% rename from apps/server/test/v1/webhooks/list.spec.ts rename to apps/server/test/integration/v1/webhooks/list.spec.ts index 3265a84..6110650 100644 --- a/apps/server/test/v1/webhooks/list.spec.ts +++ b/apps/server/test/integration/v1/webhooks/list.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; import { createLedger, createWebhookSubscription } from "../../setup/factories"; -import { WebhookSubscription } from "../../../src/routes/v1/serializers"; +import type { WebhookSubscription } from "routes/v1/serializers"; describe("GET /v1/ledgers/:ledgerId/webhooks", () => { it("returns empty array when no webhooks exist", async () => { diff --git a/apps/server/test/v1/webhooks/rotate-secret.spec.ts b/apps/server/test/integration/v1/webhooks/rotate-secret.spec.ts similarity index 93% rename from apps/server/test/v1/webhooks/rotate-secret.spec.ts rename to apps/server/test/integration/v1/webhooks/rotate-secret.spec.ts index 6088490..5b85944 100644 --- a/apps/server/test/v1/webhooks/rotate-secret.spec.ts +++ b/apps/server/test/integration/v1/webhooks/rotate-secret.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; import { createLedger, createWebhookSubscription } from "../../setup/factories"; -import { WebhookSubscription } from "../../../src/routes/v1/serializers"; +import type { WebhookSubscription } from "routes/v1/serializers"; describe("POST /v1/ledgers/:ledgerId/webhooks/:subscriptionId/rotate-secret", () => { it("returns 200 with new secret", async () => { diff --git a/apps/server/test/v1/webhooks/update.spec.ts b/apps/server/test/integration/v1/webhooks/update.spec.ts similarity index 93% rename from apps/server/test/v1/webhooks/update.spec.ts rename to apps/server/test/integration/v1/webhooks/update.spec.ts index 9043729..684fc57 100644 --- a/apps/server/test/v1/webhooks/update.spec.ts +++ b/apps/server/test/integration/v1/webhooks/update.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; import { createLedger, createWebhookSubscription } from "../../setup/factories"; -import { WebhookSubscription } from "../../../src/routes/v1/serializers"; +import type { WebhookSubscription } from "routes/v1/serializers"; describe("PATCH /v1/ledgers/:ledgerId/webhooks/:subscriptionId", () => { it("returns 200 when updating url", async () => { diff --git a/apps/server/test/integration/vitest.config.ts b/apps/server/test/integration/vitest.config.ts new file mode 100644 index 0000000..264c699 --- /dev/null +++ b/apps/server/test/integration/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + name: "integration", + include: ["**/*.{test,spec}.ts"], + environment: "node", + globals: true, + globalSetup: ["./setup/global.ts"], + setupFiles: ["./setup/client.ts"], + }, + resolve: { + alias: { + config: path.resolve(__dirname, "../../src/config"), + database: path.resolve(__dirname, "../../src/database"), + domain: path.resolve(__dirname, "../../src/domain"), + infra: path.resolve(__dirname, "../../src/infra"), + lib: path.resolve(__dirname, "../../src/lib"), + middleware: path.resolve(__dirname, "../../src/middleware"), + migrations: path.resolve(__dirname, "../../src/migrations"), + routes: path.resolve(__dirname, "../../src/routes"), + operations: path.resolve(__dirname, "../../src/operations"), + }, + }, +}); diff --git a/apps/server/test/unit/domain/policy/evaluate.spec.ts b/apps/server/test/unit/domain/policy/evaluate.spec.ts new file mode 100644 index 0000000..37d0278 --- /dev/null +++ b/apps/server/test/unit/domain/policy/evaluate.spec.ts @@ -0,0 +1,1723 @@ +import { describe, expect, it } from "vitest"; +import { + evaluatePolicy, + toLocalDate, + msSinceLocalMidnight, + getDayOfWeek, + dateRange, + REASON_CODES, + type PolicyConfig, + type EvaluationInput, + type EvaluationContext, + type EvaluationResult, +} from "domain/policy/evaluate"; + +// ─── Constants ───────────────────────────────────────────────────────────────── + +const HOUR = 3_600_000; +const MINUTE = 60_000; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +/** Shorthand to build a minimal policy. */ +function makePolicy(overrides: Partial = {}): PolicyConfig { + return { + schema_version: 1, + default: "open", + config: {}, + ...overrides, + }; +} + +/** Shorthand for a booking request. */ +function makeRequest(startISO: string, endISO: string): EvaluationInput { + return { + startTime: new Date(startISO), + endTime: new Date(endISO), + }; +} + +/** Shorthand for evaluation context. */ +function makeContext(overrides: Partial = {}): EvaluationContext { + return { + decisionTime: new Date("2026-03-16T08:00:00Z"), + timezone: "UTC", + ...overrides, + }; +} + +/** Assert a result is denied with a specific code. */ +function expectDenied( + result: EvaluationResult, + code: string, +): asserts result is Extract { + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.code).toBe(code); + } +} + +/** Assert a result is allowed. */ +function expectAllowed( + result: EvaluationResult, +): asserts result is Extract { + expect(result.allowed).toBe(true); +} + +// ============================================================================= +// 1. Timezone Helpers +// ============================================================================= + +describe("Timezone helpers", () => { + describe("toLocalDate", () => { + it("returns YYYY-MM-DD in UTC", () => { + const d = new Date("2026-03-16T09:00:00Z"); + expect(toLocalDate(d, "UTC")).toBe("2026-03-16"); + }); + + it("shifts date according to timezone offset", () => { + // 2026-03-16T03:00:00Z in New York (EST, UTC-5) is still Mar 15 at 22:00 + const d = new Date("2026-03-16T03:00:00Z"); + expect(toLocalDate(d, "America/New_York")).toBe("2026-03-15"); + }); + + it("handles exactly midnight UTC", () => { + const d = new Date("2026-03-16T00:00:00Z"); + expect(toLocalDate(d, "UTC")).toBe("2026-03-16"); + }); + + it("handles DST transition (US spring forward)", () => { + // 2026-03-08 is spring-forward day in US. At 07:00 UTC, it's 03:00 EDT. + const d = new Date("2026-03-08T07:00:00Z"); + expect(toLocalDate(d, "America/New_York")).toBe("2026-03-08"); + }); + + it("handles year boundary", () => { + const d = new Date("2026-01-01T04:00:00Z"); + expect(toLocalDate(d, "America/New_York")).toBe("2025-12-31"); + }); + }); + + describe("msSinceLocalMidnight", () => { + it("returns 0 for midnight UTC", () => { + const d = new Date("2026-03-16T00:00:00Z"); + expect(msSinceLocalMidnight(d, "UTC")).toBe(0); + }); + + it("computes correctly for 09:30 UTC", () => { + const d = new Date("2026-03-16T09:30:00Z"); + expect(msSinceLocalMidnight(d, "UTC")).toBe(9 * HOUR + 30 * MINUTE); + }); + + it("accounts for timezone offset", () => { + // 2026-03-16T14:00:00Z in New York (EDT, UTC-4) is 10:00 local + // Note: March 16, 2026 is after spring forward (Mar 8), so EDT (UTC-4) + const d = new Date("2026-03-16T14:00:00Z"); + expect(msSinceLocalMidnight(d, "America/New_York")).toBe(10 * HOUR); + }); + + it("handles end of day (23:59:59)", () => { + const d = new Date("2026-03-16T23:59:59Z"); + expect(msSinceLocalMidnight(d, "UTC")).toBe(23 * HOUR + 59 * MINUTE + 59 * 1000); + }); + }); + + describe("getDayOfWeek", () => { + it("returns monday for 2026-03-16 (a Monday)", () => { + expect(getDayOfWeek("2026-03-16")).toBe("monday"); + }); + + it("returns sunday for 2026-03-15 (a Sunday)", () => { + expect(getDayOfWeek("2026-03-15")).toBe("sunday"); + }); + + it("returns friday for 2026-03-20", () => { + expect(getDayOfWeek("2026-03-20")).toBe("friday"); + }); + + it("returns saturday for 2026-03-21", () => { + expect(getDayOfWeek("2026-03-21")).toBe("saturday"); + }); + + it("returns wednesday for 2025-12-25 (Christmas)", () => { + expect(getDayOfWeek("2025-12-25")).toBe("thursday"); + }); + }); + + describe("dateRange", () => { + it("returns single date when from === to", () => { + expect(dateRange("2026-03-16", "2026-03-16")).toEqual(["2026-03-16"]); + }); + + it("returns inclusive range", () => { + expect(dateRange("2026-03-16", "2026-03-18")).toEqual([ + "2026-03-16", + "2026-03-17", + "2026-03-18", + ]); + }); + + it("handles month boundary", () => { + expect(dateRange("2026-03-30", "2026-04-02")).toEqual([ + "2026-03-30", + "2026-03-31", + "2026-04-01", + "2026-04-02", + ]); + }); + + it("handles year boundary", () => { + expect(dateRange("2025-12-31", "2026-01-02")).toEqual([ + "2025-12-31", + "2026-01-01", + "2026-01-02", + ]); + }); + + it("returns empty array when from > to", () => { + expect(dateRange("2026-03-18", "2026-03-16")).toEqual([]); + }); + }); +}); + +// ============================================================================= +// 2. Step 1: Blackout Pre-Pass +// ============================================================================= + +describe("Step 1: Blackout pre-pass", () => { + it("rejects single-day booking on a closed date", () => { + const policy = makePolicy({ + rules: [ + { + match: { type: "date", date: "2026-12-25" }, + closed: true, + }, + ], + }); + const request = makeRequest("2026-12-25T10:00:00Z", "2026-12-25T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-20T00:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.BLACKOUT_WINDOW); + expect(result.message).toContain("2026-12-25"); + }); + + it("rejects multi-day booking overlapping a closed date (Dec 24-26 hitting Dec 25)", () => { + const policy = makePolicy({ + rules: [ + { + match: { type: "date", date: "2026-12-25" }, + closed: true, + }, + ], + }); + // Booking spans Dec 24 at 14:00 through Dec 26 at 10:00 + const request = makeRequest("2026-12-24T14:00:00Z", "2026-12-26T10:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-20T00:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.BLACKOUT_WINDOW); + expect(result.message).toContain("2026-12-25"); + }); + + it("allows booking ending exactly at midnight (half-open: end exclusive)", () => { + const policy = makePolicy({ + rules: [ + { + match: { type: "date", date: "2026-12-25" }, + closed: true, + }, + ], + }); + // Booking on Dec 24 ending exactly at midnight (start of Dec 25) + // Half-open means the end instant is exclusive, so Dec 25 is NOT overlapped + const request = makeRequest("2026-12-24T22:00:00Z", "2026-12-25T00:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-20T00:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("rejects when any date in a date_range is closed", () => { + const policy = makePolicy({ + rules: [ + { + match: { type: "date_range", from: "2026-12-24", to: "2026-12-26" }, + closed: true, + }, + ], + }); + const request = makeRequest("2026-12-24T10:00:00Z", "2026-12-24T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-20T00:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.BLACKOUT_WINDOW); + }); + + it("rejects when a weekly closed rule matches any overlapped date", () => { + const policy = makePolicy({ + rules: [ + { + match: { type: "weekly", days: ["sunday"] }, + closed: true, + }, + ], + }); + // 2026-03-15 is a Sunday + const request = makeRequest("2026-03-14T20:00:00Z", "2026-03-15T10:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-10T00:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.BLACKOUT_WINDOW); + }); + + it("does not reject when closed date is not overlapped", () => { + const policy = makePolicy({ + rules: [ + { + match: { type: "date", date: "2026-12-25" }, + closed: true, + }, + ], + }); + const request = makeRequest("2026-12-26T10:00:00Z", "2026-12-26T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-20T00:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); +}); + +// ============================================================================= +// 3. Steps 2-3: Rule Resolution + Open/Closed +// ============================================================================= + +describe("Steps 2-3: Rule resolution + open/closed", () => { + it("first-match-wins: earlier weekly rule takes precedence", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "12:00" }], + }, + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // Monday booking at 13:00-14:00 -- first rule only goes to 12:00 + const request = makeRequest("2026-03-16T13:00:00Z", "2026-03-16T14:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("default 'open' with no rules allows all times", () => { + const policy = makePolicy({ default: "open" }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("default 'closed' with no rules rejects", () => { + const policy = makePolicy({ default: "closed" }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + expect(result.message).toContain("default is closed"); + }); + + it("windowed rule allows booking inside window", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("windowed rule rejects booking outside window", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // Booking at 07:00-08:00, before the 09:00-17:00 window + const request = makeRequest("2026-03-16T07:00:00Z", "2026-03-16T08:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("windowed rule rejects booking that starts inside but ends outside window", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // Booking at 16:00-18:00, starts inside but ends outside + const request = makeRequest("2026-03-16T16:00:00Z", "2026-03-16T18:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("no windows on matched rule means open 24h", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + // No windows property at all + }, + ], + }); + // Booking at 23:00-23:30 on Monday -- should be allowed since no windows = 24h open + const request = makeRequest("2026-03-16T23:00:00Z", "2026-03-16T23:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("matched rule with empty windows array means open 24h", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [], + }, + ], + }); + const request = makeRequest("2026-03-16T14:00:00Z", "2026-03-16T15:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("midnight normalization: Mon 22:00-Tue 00:00 treated as same-day ending at 24:00", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "24:00" }], + }, + ], + }); + // Monday 22:00 to Tuesday 00:00 (exactly midnight) + const request = makeRequest("2026-03-16T22:00:00Z", "2026-03-17T00:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("overnight rejection: booking spans midnight with windowed rule", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "23:00" }], + }, + ], + }); + // Monday 22:00 to Tuesday 01:00 -- spans midnight, end is NOT exactly midnight + const request = makeRequest("2026-03-16T22:00:00Z", "2026-03-17T01:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.OVERNIGHT_NOT_SUPPORTED); + }); + + it("booking within one of multiple windows is allowed", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [ + { start: "09:00", end: "12:00" }, + { start: "14:00", end: "18:00" }, + ], + }, + ], + }); + // Booking at 14:00-15:00, fits in second window + const request = makeRequest("2026-03-16T14:00:00Z", "2026-03-16T15:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("booking that falls between two windows is rejected", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [ + { start: "09:00", end: "12:00" }, + { start: "14:00", end: "18:00" }, + ], + }, + ], + }); + // Booking at 12:30-13:30, between windows + const request = makeRequest("2026-03-16T12:30:00Z", "2026-03-16T13:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("date rule matches by exact date", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "date", date: "2026-03-16" }, + windows: [{ start: "10:00", end: "14:00" }], + }, + ], + }); + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("date_range rule with day filter matches correctly", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { + type: "date_range", + from: "2026-03-01", + to: "2026-03-31", + days: ["monday", "wednesday", "friday"], + }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // March 16 is Monday, should match + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("date_range rule with day filter does not match excluded day", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { + type: "date_range", + from: "2026-03-01", + to: "2026-03-31", + days: ["monday", "wednesday", "friday"], + }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // March 17 is Tuesday, not in days list, should fall to default closed + const request = makeRequest("2026-03-17T09:00:00Z", "2026-03-17T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); +}); + +// ============================================================================= +// 4. Step 4: Config Merge +// ============================================================================= + +describe("Step 4: Config merge", () => { + it("rule config overrides base config (section-level replace)", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, + grid: { interval_ms: 30 * MINUTE }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + config: { + duration: { min_ms: 60 * MINUTE, max_ms: 60 * MINUTE }, + }, + }, + ], + }); + // 1 hour booking on Monday + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + // Duration should come from rule config, grid should come from base config + expect(result.resolvedConfig.duration).toEqual({ + min_ms: 60 * MINUTE, + max_ms: 60 * MINUTE, + }); + expect(result.resolvedConfig.grid).toEqual({ interval_ms: 30 * MINUTE }); + }); + + it("base config preserved when rule has no config", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, + buffers: { before_ms: 5 * MINUTE, after_ms: 5 * MINUTE }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + // No config override + }, + ], + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.resolvedConfig.duration).toEqual({ + min_ms: 30 * MINUTE, + max_ms: 120 * MINUTE, + }); + expect(result.resolvedConfig.buffers).toEqual({ + before_ms: 5 * MINUTE, + after_ms: 5 * MINUTE, + }); + }); + + it("rule config fully replaces a section (not deep merge)", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + config: { + // Override duration with only allowed_ms -- min_ms/max_ms from base should NOT survive + duration: { allowed_ms: [60 * MINUTE] }, + }, + }, + ], + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + // The entire duration section is replaced by rule config + expect(result.resolvedConfig.duration).toEqual({ + allowed_ms: [60 * MINUTE], + }); + }); +}); + +// ============================================================================= +// 5. Step 5: Duration +// ============================================================================= + +describe("Step 5: Duration", () => { + it("allowed_ms takes precedence over min/max -- duration in list is allowed", () => { + const policy = makePolicy({ + config: { + duration: { + min_ms: 30 * MINUTE, + max_ms: 120 * MINUTE, + allowed_ms: [30 * MINUTE, 60 * MINUTE], + }, + }, + }); + // 30 min booking is in allowed_ms + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T09:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("allowed_ms takes precedence -- duration NOT in list is rejected even if within min/max", () => { + const policy = makePolicy({ + config: { + duration: { + min_ms: 30 * MINUTE, + max_ms: 120 * MINUTE, + allowed_ms: [30 * MINUTE, 60 * MINUTE], + }, + }, + }); + // 45 min booking is within min/max but NOT in allowed_ms + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T09:45:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + expect(result.details?.["allowed_ms"]).toEqual([30 * MINUTE, 60 * MINUTE]); + }); + + it("duration below min_ms is rejected", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 60 * MINUTE, max_ms: 120 * MINUTE }, + }, + }); + // 30 min booking, below 60 min minimum + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T09:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + expect(result.details?.["min_ms"]).toBe(60 * MINUTE); + }); + + it("duration above max_ms is rejected", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 60 * MINUTE }, + }, + }); + // 90 min booking, above 60 min maximum + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + expect(result.details?.["max_ms"]).toBe(60 * MINUTE); + }); + + it("duration within min/max is allowed", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, + }, + }); + // 60 min booking, within [30, 120] + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("duration exactly at min_ms boundary is allowed", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, + }, + }); + // Exactly 30 min + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T09:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("duration exactly at max_ms boundary is allowed", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, + }, + }); + // Exactly 120 min + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T11:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("no duration config means no duration enforcement", () => { + const policy = makePolicy({ + config: {}, + }); + // Very long booking, no duration limits + const request = makeRequest("2026-03-16T00:00:00Z", "2026-03-16T23:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); +}); + +// ============================================================================= +// 6. Step 6: Grid Alignment +// ============================================================================= + +describe("Step 6: Grid alignment", () => { + it("aligned start time is allowed (30-min grid)", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 30 * MINUTE }, + }, + }); + // 09:00 -> 9h * 60 * 60 * 1000 = 32400000, 32400000 % 1800000 = 0 + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("aligned start time at 09:30 on 30-min grid is allowed", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 30 * MINUTE }, + }, + }); + const request = makeRequest("2026-03-16T09:30:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("misaligned start time is rejected (30-min grid)", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 30 * MINUTE }, + }, + }); + // 09:15 -> 15 min past the grid line + const request = makeRequest("2026-03-16T09:15:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.MISALIGNED_START_TIME); + expect(result.details?.["interval_ms"]).toBe(30 * MINUTE); + expect(result.details?.["remainder"]).toBe(15 * MINUTE); + }); + + it("15-minute grid: start at :45 is aligned", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 15 * MINUTE }, + }, + }); + const request = makeRequest("2026-03-16T09:45:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("1-hour grid: start at :30 is misaligned", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 60 * MINUTE }, + }, + }); + const request = makeRequest("2026-03-16T09:30:00Z", "2026-03-16T10:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.MISALIGNED_START_TIME); + }); + + it("no grid config means no alignment enforcement", () => { + const policy = makePolicy({ + config: {}, + }); + const request = makeRequest("2026-03-16T09:17:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); +}); + +// ============================================================================= +// 7. Steps 7-8: Lead Time + Horizon +// ============================================================================= + +describe("Steps 7-8: Lead time + horizon", () => { + it("booking within lead time is rejected", () => { + const policy = makePolicy({ + config: { + lead_time: { min_ms: 2 * HOUR }, + }, + }); + // Decision at 08:00, booking at 09:00, only 1h lead time, need 2h + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.LEAD_TIME_VIOLATION); + expect(result.details?.["leadTimeMs"]).toBe(1 * HOUR); + expect(result.details?.["min_ms"]).toBe(2 * HOUR); + }); + + it("booking beyond horizon is rejected", () => { + const policy = makePolicy({ + config: { + lead_time: { max_ms: 7 * 24 * HOUR }, + }, + }); + // Decision at 08:00 on Mar 16, booking at Mar 30 (14 days away), horizon is 7 days + const request = makeRequest("2026-03-30T09:00:00Z", "2026-03-30T10:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.HORIZON_EXCEEDED); + }); + + it("booking within both lead time and horizon is allowed", () => { + const policy = makePolicy({ + config: { + lead_time: { + min_ms: 1 * HOUR, + max_ms: 30 * 24 * HOUR, + }, + }, + }); + // Decision at 08:00, booking at 10:00 (2h lead time), within 30d horizon + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("booking exactly at min_lead_time boundary is allowed", () => { + const policy = makePolicy({ + config: { + lead_time: { min_ms: 2 * HOUR }, + }, + }); + // Decision at 08:00, booking at 10:00, exactly 2h lead time + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("booking exactly at max_lead_time boundary is allowed", () => { + const policy = makePolicy({ + config: { + lead_time: { max_ms: 7 * 24 * HOUR }, + }, + }); + // Decision at 08:00 on Mar 16, booking at exactly 7 days later + const request = makeRequest("2026-03-23T08:00:00Z", "2026-03-23T09:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("no lead_time config means no lead time or horizon enforcement", () => { + const policy = makePolicy({ config: {} }); + // Booking 1 minute in the future + const request = makeRequest("2026-03-16T08:01:00Z", "2026-03-16T09:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("lead time check runs before horizon check (correct order)", () => { + const policy = makePolicy({ + config: { + lead_time: { + min_ms: 2 * HOUR, + max_ms: 7 * 24 * HOUR, + }, + }, + }); + // Decision at 08:00, booking at 08:30 -- violates lead time (30 min < 2h) + const request = makeRequest("2026-03-16T08:30:00Z", "2026-03-16T09:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.LEAD_TIME_VIOLATION); + }); +}); + +// ============================================================================= +// 8. Step 9: Buffers +// ============================================================================= + +describe("Step 9: Buffers", () => { + it("buffer before/after computed correctly", () => { + const policy = makePolicy({ + config: { + buffers: { before_ms: 10 * MINUTE, after_ms: 15 * MINUTE }, + }, + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(10 * MINUTE); + expect(result.bufferAfterMs).toBe(15 * MINUTE); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T08:50:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:15:00.000Z"); + }); + + it("no buffers means effective equals original", () => { + const policy = makePolicy({ config: {} }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(0); + expect(result.bufferAfterMs).toBe(0); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T09:00:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + }); + + it("only buffer_before_ms is set", () => { + const policy = makePolicy({ + config: { + buffers: { before_ms: 5 * MINUTE }, + }, + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(5 * MINUTE); + expect(result.bufferAfterMs).toBe(0); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T08:55:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + }); + + it("only buffer_after_ms is set", () => { + const policy = makePolicy({ + config: { + buffers: { after_ms: 10 * MINUTE }, + }, + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(0); + expect(result.bufferAfterMs).toBe(10 * MINUTE); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T09:00:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:10:00.000Z"); + }); +}); + +// ============================================================================= +// 9. Common Patterns (from spec section 8) +// ============================================================================= + +describe("Common patterns", () => { + describe("Simple salon (Mon-Fri 9-5, 30/60 min)", () => { + const salonPolicy: PolicyConfig = { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_ms: [30 * MINUTE, 60 * MINUTE] }, + grid: { interval_ms: 30 * MINUTE }, + lead_time: { + min_ms: 1 * HOUR, + max_ms: 14 * 24 * HOUR, + }, + buffers: { before_ms: 0, after_ms: 10 * MINUTE }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday", "tuesday", "wednesday", "thursday", "friday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }; + + it("allows 30-min appointment at 10:00 on Monday", () => { + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T10:30:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(salonPolicy, request, context); + expectAllowed(result); + expect(result.bufferAfterMs).toBe(10 * MINUTE); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:40:00.000Z"); + }); + + it("allows 60-min appointment at 09:30 on Wednesday", () => { + // 2026-03-18 is Wednesday + const request = makeRequest("2026-03-18T09:30:00Z", "2026-03-18T10:30:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-18T08:00:00Z") }); + + const result = evaluatePolicy(salonPolicy, request, context); + expectAllowed(result); + }); + + it("rejects 45-min appointment (not in allowed_ms)", () => { + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T10:45:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(salonPolicy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + }); + + it("rejects Saturday booking (closed by schedule)", () => { + // 2026-03-21 is Saturday + const request = makeRequest("2026-03-21T10:00:00Z", "2026-03-21T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-20T08:00:00Z") }); + + const result = evaluatePolicy(salonPolicy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("rejects booking at 08:00 (before 9-5 window)", () => { + const request = makeRequest("2026-03-16T08:00:00Z", "2026-03-16T09:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T06:00:00Z") }); + + const result = evaluatePolicy(salonPolicy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("rejects misaligned booking at 10:15", () => { + const request = makeRequest("2026-03-16T10:15:00Z", "2026-03-16T11:15:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(salonPolicy, request, context); + expectDenied(result, REASON_CODES.MISALIGNED_START_TIME); + }); + }); + + describe("24/7 resource (default open, no rules)", () => { + const openPolicy: PolicyConfig = { + schema_version: 1, + default: "open", + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 8 * HOUR }, + }, + }; + + it("allows booking at any time of day", () => { + const request = makeRequest("2026-03-16T03:00:00Z", "2026-03-16T04:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(openPolicy, request, context); + expectAllowed(result); + }); + + it("allows weekend booking", () => { + // 2026-03-21 is Saturday + const request = makeRequest("2026-03-21T14:00:00Z", "2026-03-21T16:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-20T08:00:00Z") }); + + const result = evaluatePolicy(openPolicy, request, context); + expectAllowed(result); + }); + + it("still enforces duration limits", () => { + // 10 hour booking, max is 8h + const request = makeRequest("2026-03-16T08:00:00Z", "2026-03-16T18:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(openPolicy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + }); + }); + + describe("Holiday closure (closed date rule before weekly)", () => { + const holidayPolicy: PolicyConfig = { + schema_version: 1, + default: "closed", + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 2 * HOUR }, + }, + rules: [ + // Holiday closure BEFORE weekly rules so blackout fires first + { + match: { type: "date", date: "2026-12-25" }, + closed: true, + }, + { + match: { type: "weekly", days: ["monday", "tuesday", "wednesday", "thursday", "friday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }; + + it("allows normal weekday booking", () => { + // 2026-12-23 is Wednesday + const request = makeRequest("2026-12-23T10:00:00Z", "2026-12-23T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-20T08:00:00Z") }); + + const result = evaluatePolicy(holidayPolicy, request, context); + expectAllowed(result); + }); + + it("rejects Christmas day booking (blackout)", () => { + // 2026-12-25 is Friday + const request = makeRequest("2026-12-25T10:00:00Z", "2026-12-25T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-20T08:00:00Z") }); + + const result = evaluatePolicy(holidayPolicy, request, context); + expectDenied(result, REASON_CODES.BLACKOUT_WINDOW); + }); + }); + + describe("Multi-day rental (no windows, closed blackout)", () => { + const rentalPolicy: PolicyConfig = { + schema_version: 1, + default: "open", + config: { + duration: { min_ms: 24 * HOUR, max_ms: 7 * 24 * HOUR }, + lead_time: { min_ms: 24 * HOUR }, + }, + rules: [ + { + match: { type: "date_range", from: "2026-12-24", to: "2026-12-26" }, + closed: true, + }, + ], + }; + + it("allows a 3-day rental starting well before blackout", () => { + const request = makeRequest("2026-12-10T12:00:00Z", "2026-12-13T12:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-05T08:00:00Z") }); + + const result = evaluatePolicy(rentalPolicy, request, context); + expectAllowed(result); + }); + + it("rejects rental spanning blackout period", () => { + const request = makeRequest("2026-12-23T12:00:00Z", "2026-12-27T12:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-10T08:00:00Z") }); + + const result = evaluatePolicy(rentalPolicy, request, context); + expectDenied(result, REASON_CODES.BLACKOUT_WINDOW); + }); + + it("rejects rental too short (12 hours, min is 24 hours)", () => { + const request = makeRequest("2026-12-10T12:00:00Z", "2026-12-11T00:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-12-05T08:00:00Z") }); + + const result = evaluatePolicy(rentalPolicy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + }); + }); + + describe("Escape room (fixed duration, grid, buffer)", () => { + const escapeRoomPolicy: PolicyConfig = { + schema_version: 1, + default: "closed", + config: { + duration: { allowed_ms: [60 * MINUTE] }, + grid: { interval_ms: 90 * MINUTE }, + buffers: { before_ms: 15 * MINUTE, after_ms: 15 * MINUTE }, + lead_time: { + min_ms: 2 * HOUR, + max_ms: 30 * 24 * HOUR, + }, + }, + rules: [ + { + match: { + type: "weekly", + days: ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"], + }, + windows: [{ start: "10:00", end: "22:00" }], + }, + ], + }; + + it("allows properly aligned 60-min booking with buffers", () => { + // 10:00 is aligned to 90-min grid (10h * 60 * 60 * 1000 = 36000000, 36000000 % 5400000 = 0? Let's check: 36000000/5400000 = 6.666... no) + // Actually 0:00 is 0ms, 1:30 is 5400000, 3:00 is 10800000, etc. + // 10:00 = 36000000ms. 36000000 % 5400000 = 36000000 - 6*5400000 = 36000000 - 32400000 = 3600000. Not 0. + // Need start at a multiple of 90 min: 0:00, 1:30, 3:00, 4:30, 6:00, 7:30, 9:00, 10:30, 12:00, ... + // 10:30 = 37800000ms. 37800000 % 5400000 = 37800000 - 7*5400000 = 37800000-37800000 = 0. Yes! + const request = makeRequest("2026-03-16T10:30:00Z", "2026-03-16T11:30:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(escapeRoomPolicy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(15 * MINUTE); + expect(result.bufferAfterMs).toBe(15 * MINUTE); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T10:15:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T11:45:00.000Z"); + }); + + it("rejects 90-min booking (only 60 min allowed)", () => { + const request = makeRequest("2026-03-16T10:30:00Z", "2026-03-16T12:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(escapeRoomPolicy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + }); + + it("rejects misaligned start (not on 90-min grid)", () => { + // 10:00 is not aligned to 90-min grid + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T08:00:00Z") }); + + const result = evaluatePolicy(escapeRoomPolicy, request, context); + expectDenied(result, REASON_CODES.MISALIGNED_START_TIME); + }); + + it("rejects booking too soon (within 2h lead time)", () => { + // Decision at 10:00, booking at 10:30, only 30 min lead time + const request = makeRequest("2026-03-16T10:30:00Z", "2026-03-16T11:30:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T10:00:00Z") }); + + const result = evaluatePolicy(escapeRoomPolicy, request, context); + expectDenied(result, REASON_CODES.LEAD_TIME_VIOLATION); + }); + }); +}); + +// ============================================================================= +// 10. Admin Override (skipPolicy) +// ============================================================================= + +describe("Admin override (skipPolicy)", () => { + it("skipPolicy: true bypasses all checks (Steps 1-8)", () => { + const policy = makePolicy({ + default: "closed", + config: { + duration: { allowed_ms: [60 * MINUTE] }, + grid: { interval_ms: 30 * MINUTE }, + lead_time: { min_ms: 24 * HOUR }, + }, + rules: [ + { + match: { type: "date", date: "2026-03-16" }, + closed: true, + }, + ], + }); + + // This booking violates everything: blackout, duration (45 min), grid (09:15), lead time + const request = makeRequest("2026-03-16T09:15:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext({ + decisionTime: new Date("2026-03-16T09:00:00Z"), + skipPolicy: true, + }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("skipPolicy: true still computes buffers from base config", () => { + const policy = makePolicy({ + config: { + buffers: { before_ms: 10 * MINUTE, after_ms: 5 * MINUTE }, + }, + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext({ skipPolicy: true }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(10 * MINUTE); + expect(result.bufferAfterMs).toBe(5 * MINUTE); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T08:50:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:05:00.000Z"); + }); + + it("skipPolicy: true with no buffers returns zero buffers", () => { + const policy = makePolicy({ config: {} }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext({ skipPolicy: true }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(0); + expect(result.bufferAfterMs).toBe(0); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T09:00:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + }); + + it("skipPolicy: false behaves normally (checks enforced)", () => { + const policy = makePolicy({ + default: "closed", + config: {}, + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext({ skipPolicy: false }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); +}); + +// ============================================================================= +// 11. Edge Cases +// ============================================================================= + +describe("Edge cases", () => { + it("invalid schema_version returns policy_invalid_config", () => { + const policy = makePolicy({ schema_version: 2 }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.POLICY_INVALID_CONFIG); + }); + + it("schema_version 0 returns policy_invalid_config", () => { + const policy = makePolicy({ schema_version: 0 }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.POLICY_INVALID_CONFIG); + }); + + it("empty rules + default open allows all times", () => { + const policy = makePolicy({ default: "open", rules: [] }); + const request = makeRequest("2026-03-16T03:00:00Z", "2026-03-16T04:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("undefined rules + default open allows all times", () => { + const policy = makePolicy({ default: "open" }); + const request = makeRequest("2026-03-16T03:00:00Z", "2026-03-16T04:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("empty config means no enforcement beyond schedule", () => { + const policy = makePolicy({ + default: "closed", + config: {}, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // Any duration, any start time within window, should be allowed + const request = makeRequest("2026-03-16T09:17:00Z", "2026-03-16T16:59:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("midnight normalization does NOT apply when end is after midnight (not exactly midnight)", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "24:00" }], + }, + ], + }); + // Monday 22:00 to Tuesday 00:01 -- NOT exactly midnight, so spans multiple days + const request = makeRequest("2026-03-16T22:00:00Z", "2026-03-17T00:01:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.OVERNIGHT_NOT_SUPPORTED); + }); + + it("booking that starts and ends at the exact same time with duration config is rejected", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE }, + }, + }); + // Zero-duration booking + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T09:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + }); + + it("evaluation order: blackout checked before schedule windows", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "date", date: "2026-03-16" }, + closed: true, + }, + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // Even though a weekly rule would match, blackout should fire first + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.BLACKOUT_WINDOW); + }); + + it("evaluation order: schedule checked before duration", () => { + const policy = makePolicy({ + default: "closed", + config: { + duration: { allowed_ms: [60 * MINUTE] }, + }, + }); + // No rules match and default is closed -- should get closed_by_schedule, not invalid_duration + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T09:30:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("evaluation order: duration checked before grid", () => { + const policy = makePolicy({ + config: { + duration: { allowed_ms: [60 * MINUTE] }, + grid: { interval_ms: 30 * MINUTE }, + }, + }); + // 45-min duration (invalid) at 09:00 (aligned) -- should get invalid_duration + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T09:45:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.INVALID_DURATION); + }); + + it("evaluation order: grid checked before lead time", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 60 * MINUTE }, + lead_time: { min_ms: 24 * HOUR }, + }, + }); + // Misaligned start (09:15) and too soon -- should get misaligned_start_time + const request = makeRequest("2026-03-16T09:15:00Z", "2026-03-16T10:15:00Z"); + const context = makeContext({ decisionTime: new Date("2026-03-16T09:00:00Z") }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.MISALIGNED_START_TIME); + }); + + it("rule config with buffers overrides base buffers", () => { + const policy = makePolicy({ + config: { + buffers: { before_ms: 10 * MINUTE, after_ms: 10 * MINUTE }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + config: { + buffers: { before_ms: 30 * MINUTE, after_ms: 0 }, + }, + }, + ], + }); + const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + + expect(result.bufferBeforeMs).toBe(30 * MINUTE); + expect(result.bufferAfterMs).toBe(0); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T08:30:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + }); + + it("closed rule is skipped in Step 2 (rule resolution)", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + // First rule matches Monday but is closed -- blackout handles it + // This tests that a closed rule doesn't "consume" the match for Step 2 + { + match: { type: "date", date: "2026-03-17" }, + closed: true, + }, + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // Monday 2026-03-16 -- the date closed rule is for Mar 17, so it doesn't fire + // The weekly rule for Monday should match + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const context = makeContext(); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + it("timezone-aware date resolution: same UTC instant resolves to different dates", () => { + // 2026-03-16T04:00:00Z is: + // - March 16 in UTC + // - March 15 in America/New_York (EST = UTC-5 on this date... wait, Mar 8 is spring forward) + // - March 16 00:00 EDT = March 16 04:00 UTC. So 04:00 UTC = 00:00 EDT Mar 16. + // - Actually EDT is UTC-4. So 04:00 UTC = 00:00 EDT. + // - That's midnight on March 16 in EDT. + + // Let's use a clearer example: 2026-03-16T03:00:00Z + // In EDT (UTC-4): 2026-03-15 23:00 EDT + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["sunday"] }, + windows: [{ start: "22:00", end: "24:00" }], + }, + ], + }); + + // In UTC: Mar 16 Monday 03:00 + // In New York (EDT, UTC-4): Mar 15 Sunday 23:00 + const request = makeRequest("2026-03-16T03:00:00Z", "2026-03-16T04:00:00Z"); + const contextNY = makeContext({ timezone: "America/New_York" }); + + const resultNY = evaluatePolicy(policy, request, contextNY); + // In New York it's Sunday 23:00-00:00, should fit the Sunday 22:00-24:00 window + expectAllowed(resultNY); + + // Same instant in UTC: it's Monday, no rule matches, default closed + const contextUTC = makeContext({ timezone: "UTC" }); + const resultUTC = evaluatePolicy(policy, request, contextUTC); + expectDenied(resultUTC, REASON_CODES.CLOSED_BY_SCHEDULE); + }); + + it("grid alignment is timezone-aware", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 60 * MINUTE }, + }, + }); + + // 2026-03-16T14:30:00Z in EDT (UTC-4) = 10:30 local + // 10:30 local means ms_since_midnight = 10.5 hours = 37800000 + // 37800000 % 3600000 = 1800000 (not 0, misaligned) + const request = makeRequest("2026-03-16T14:30:00Z", "2026-03-16T15:30:00Z"); + const context = makeContext({ timezone: "America/New_York" }); + + const result = evaluatePolicy(policy, request, context); + expectDenied(result, REASON_CODES.MISALIGNED_START_TIME); + }); + + it("grid alignment passes when local time is aligned but UTC is not", () => { + const policy = makePolicy({ + config: { + grid: { interval_ms: 60 * MINUTE }, + }, + }); + + // 2026-03-16T14:00:00Z in EDT (UTC-4) = 10:00 local + // 10:00 local = 36000000ms. 36000000 % 3600000 = 0 (aligned) + const request = makeRequest("2026-03-16T14:00:00Z", "2026-03-16T15:00:00Z"); + const context = makeContext({ timezone: "America/New_York" }); + + const result = evaluatePolicy(policy, request, context); + expectAllowed(result); + }); + + // ─── hold resolution ───────────────────────────────────────────────────── + + it("resolvedConfig includes hold from base config", () => { + const policy = makePolicy({ + config: { + hold: { duration_ms: 5 * MINUTE }, + }, + }); + + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const result = evaluatePolicy(policy, request, makeContext()); + + expectAllowed(result); + expect(result.resolvedConfig.hold).toEqual({ duration_ms: 5 * MINUTE }); + }); + + it("resolvedConfig hold is overridden by matching rule config", () => { + const policy = makePolicy({ + default: "open", + config: { + hold: { duration_ms: 5 * MINUTE }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + config: { + hold: { duration_ms: 10 * MINUTE }, + }, + }, + ], + }); + + // 2026-03-16 is Monday + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const result = evaluatePolicy(policy, request, makeContext()); + + expectAllowed(result); + expect(result.resolvedConfig.hold).toEqual({ duration_ms: 10 * MINUTE }); + }); + + it("resolvedConfig hold is undefined when not configured", () => { + const policy = makePolicy({ config: {} }); + + const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); + const result = evaluatePolicy(policy, request, makeContext()); + + expectAllowed(result); + expect(result.resolvedConfig.hold).toBeUndefined(); + }); +}); diff --git a/apps/server/test/unit/domain/scheduling/availability.spec.ts b/apps/server/test/unit/domain/scheduling/availability.spec.ts new file mode 100644 index 0000000..6993b69 --- /dev/null +++ b/apps/server/test/unit/domain/scheduling/availability.spec.ts @@ -0,0 +1,849 @@ +import { describe, expect, it } from "vitest"; +import { + resolveDay, + resolveServiceDays, + localToAbsolute, + generateSlots, + computeWindows, + type ResolvedDay, + type BlockingAllocation, +} from "domain/scheduling/availability"; +import type { PolicyConfig } from "domain/policy/evaluate"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const HOUR = 3_600_000; +const MINUTE = 60_000; +const DAY = 24 * HOUR; + +/** Server time far in the past — avoids lead-time filtering in most tests. */ +const PAST = new Date("2020-01-01T00:00:00Z"); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function makePolicy(overrides: Partial = {}): PolicyConfig { + return { schema_version: 1, default: "open", config: {}, ...overrides }; +} + +function makeDay( + date: string, + windowPairs: [number, number][], + config: Record = {}, +): ResolvedDay { + return { + date, + windows: windowPairs.map(([s, e]) => ({ startMs: s, endMs: e })), + config: config as ResolvedDay["config"], + }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 1. localToAbsolute +// ═════════════════════════════════════════════════════════════════════════════ + +describe("localToAbsolute", () => { + it("converts midnight UTC", () => { + expect(localToAbsolute("2026-03-16", 0, "UTC").toISOString()).toBe("2026-03-16T00:00:00.000Z"); + }); + + it("converts 09:30 UTC", () => { + expect(localToAbsolute("2026-03-16", 9 * HOUR + 30 * MINUTE, "UTC").toISOString()).toBe( + "2026-03-16T09:30:00.000Z", + ); + }); + + it("converts 01:30:30.250 UTC with seconds and milliseconds", () => { + // Regression test: ensure seconds/ms aren't double-counted + // 1h 30m 30s 250ms = 5430250ms + expect(localToAbsolute("2026-01-01", 5430250, "UTC").toISOString()).toBe( + "2026-01-01T01:30:30.250Z", + ); + }); + + it("handles 24:00 → next day 00:00", () => { + expect(localToAbsolute("2026-03-16", 24 * HOUR, "UTC").toISOString()).toBe( + "2026-03-17T00:00:00.000Z", + ); + }); + + it("converts America/New_York EST (before spring forward)", () => { + // 2026-03-02 is EST = UTC-5. 09:00 local = 14:00 UTC + expect(localToAbsolute("2026-03-02", 9 * HOUR, "America/New_York").toISOString()).toBe( + "2026-03-02T14:00:00.000Z", + ); + }); + + it("converts America/New_York EDT (after spring forward)", () => { + // 2026-03-16 is EDT = UTC-4. 09:00 local = 13:00 UTC + expect(localToAbsolute("2026-03-16", 9 * HOUR, "America/New_York").toISOString()).toBe( + "2026-03-16T13:00:00.000Z", + ); + }); + + it("DST spring forward: 2:30 AM gap resolves to 3:30 AM EDT", () => { + // 2026-03-08 spring forward: 2:00 AM EST → 3:00 AM EDT (at 07:00 UTC) + // 2:30 AM local doesn't exist → pushed forward 1 hour → 3:30 AM EDT = 07:30 UTC + expect( + localToAbsolute("2026-03-08", 2 * HOUR + 30 * MINUTE, "America/New_York").toISOString(), + ).toBe("2026-03-08T07:30:00.000Z"); + }); + + it("DST fall back: 1:30 AM resolves to first occurrence (EDT)", () => { + // 2026-11-01 fall back: 2:00 AM EDT → 1:00 AM EST (at 06:00 UTC) + // 1:30 AM ambiguous — algorithm resolves to EDT (first occurrence) + // 1:30 AM EDT = 05:30 UTC + expect( + localToAbsolute("2026-11-01", 1 * HOUR + 30 * MINUTE, "America/New_York").toISOString(), + ).toBe("2026-11-01T05:30:00.000Z"); + }); + + it("converts Europe/London BST correctly", () => { + // 2026-03-29 is after UK spring forward (Mar 29). BST = UTC+1. + // 10:00 local = 09:00 UTC + expect(localToAbsolute("2026-03-30", 10 * HOUR, "Europe/London").toISOString()).toBe( + "2026-03-30T09:00:00.000Z", + ); + }); + + it("handles year boundary correctly (regression test)", () => { + // Regression test for bug where roughUtc year was used instead of local year + // Dec 31 2025 23:00 in America/Los_Angeles (UTC-8) = Jan 1 2026 07:00 UTC + // The bug would extract year from roughUtc (2026) instead of from formatter (2025) + expect(localToAbsolute("2025-12-31", 23 * HOUR, "America/Los_Angeles").toISOString()).toBe( + "2026-01-01T07:00:00.000Z", + ); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 2. resolveDay +// ═════════════════════════════════════════════════════════════════════════════ + +describe("resolveDay", () => { + it("no policy → 24h open with empty config", () => { + const result = resolveDay(null, "2026-03-16", "monday"); + expect(result).toEqual({ + date: "2026-03-16", + windows: [{ startMs: 0, endMs: DAY }], + config: {}, + }); + }); + + it("default open, no rules → 24h open with base config", () => { + const policy = makePolicy({ config: { duration: { min_ms: HOUR } } }); + const result = resolveDay(policy, "2026-03-16", "monday"); + + expect(result).not.toBeNull(); + expect(result!.windows).toEqual([{ startMs: 0, endMs: DAY }]); + expect(result!.config.duration).toEqual({ min_ms: HOUR }); + }); + + it("default closed, no rules → null", () => { + const result = resolveDay(makePolicy({ default: "closed" }), "2026-03-16", "monday"); + expect(result).toBeNull(); + }); + + it("blackout date → null", () => { + const policy = makePolicy({ + rules: [{ match: { type: "date", date: "2026-03-16" }, closed: true }], + }); + expect(resolveDay(policy, "2026-03-16", "monday")).toBeNull(); + }); + + it("blackout does not match different date", () => { + const policy = makePolicy({ + rules: [{ match: { type: "date", date: "2026-03-17" }, closed: true }], + }); + expect(resolveDay(policy, "2026-03-16", "monday")).not.toBeNull(); + }); + + it("matching weekly rule with windows → returns windows + merged config", () => { + const policy = makePolicy({ + default: "closed", + config: { grid: { interval_ms: 30 * MINUTE } }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + config: { duration: { allowed_ms: [HOUR] } }, + }, + ], + }); + const result = resolveDay(policy, "2026-03-16", "monday")!; + + expect(result.windows).toEqual([{ startMs: 9 * HOUR, endMs: 17 * HOUR }]); + expect(result.config.grid).toEqual({ interval_ms: 30 * MINUTE }); + expect(result.config.duration).toEqual({ allowed_ms: [HOUR] }); + }); + + it("matching rule without windows → 24h open", () => { + const policy = makePolicy({ + default: "closed", + rules: [{ match: { type: "weekly", days: ["monday"] } }], + }); + const result = resolveDay(policy, "2026-03-16", "monday")!; + expect(result.windows).toEqual([{ startMs: 0, endMs: DAY }]); + }); + + it("first-match-wins: earlier rule takes precedence", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "12:00" }], + }, + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + const result = resolveDay(policy, "2026-03-16", "monday")!; + expect(result.windows).toEqual([{ startMs: 9 * HOUR, endMs: 12 * HOUR }]); + }); + + it("closed rule is skipped in step 2 (rule resolution)", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { match: { type: "date", date: "2026-03-17" }, closed: true }, + { + match: { type: "weekly", days: ["monday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + const result = resolveDay(policy, "2026-03-16", "monday")!; + expect(result.windows).toEqual([{ startMs: 9 * HOUR, endMs: 17 * HOUR }]); + }); + + it("multiple windows on matched rule", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + windows: [ + { start: "09:00", end: "12:00" }, + { start: "14:00", end: "18:00" }, + ], + }, + ], + }); + const result = resolveDay(policy, "2026-03-16", "monday")!; + expect(result.windows).toEqual([ + { startMs: 9 * HOUR, endMs: 12 * HOUR }, + { startMs: 14 * HOUR, endMs: 18 * HOUR }, + ]); + }); + + it("rule config overrides base config at section level", () => { + const policy = makePolicy({ + config: { + duration: { min_ms: 30 * MINUTE, max_ms: 2 * HOUR }, + buffers: { before_ms: 10 * MINUTE }, + }, + rules: [ + { + match: { type: "weekly", days: ["monday"] }, + config: { duration: { allowed_ms: [HOUR] } }, + }, + ], + }); + const result = resolveDay(policy, "2026-03-16", "monday")!; + + // Duration fully replaced by rule config + expect(result.config.duration).toEqual({ allowed_ms: [HOUR] }); + // Buffers preserved from base + expect(result.config.buffers).toEqual({ before_ms: 10 * MINUTE }); + }); + + it("unmatched day with default open → 24h + base config", () => { + const policy = makePolicy({ + default: "open", + config: { buffers: { after_ms: 5 * MINUTE } }, + rules: [ + { + match: { type: "weekly", days: ["tuesday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + const result = resolveDay(policy, "2026-03-16", "monday")!; + expect(result.windows).toEqual([{ startMs: 0, endMs: DAY }]); + expect(result.config.buffers).toEqual({ after_ms: 5 * MINUTE }); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 3. resolveServiceDays +// ═════════════════════════════════════════════════════════════════════════════ + +describe("resolveServiceDays", () => { + it("single day range", () => { + const days = resolveServiceDays( + null, + new Date("2026-03-16T00:00:00Z"), + new Date("2026-03-16T23:59:59Z"), + "UTC", + ); + expect(days).toHaveLength(1); + expect(days[0]!.date).toBe("2026-03-16"); + }); + + it("multi-day range with mixed open/closed", () => { + const policy = makePolicy({ + default: "closed", + rules: [ + { + match: { type: "weekly", days: ["monday", "wednesday"] }, + windows: [{ start: "09:00", end: "17:00" }], + }, + ], + }); + // Mon Mar 16 through Fri Mar 20 (Mon, Tue, Wed, Thu, Fri) + const days = resolveServiceDays( + policy, + new Date("2026-03-16T00:00:00Z"), + new Date("2026-03-21T00:00:00Z"), + "UTC", + ); + expect(days).toHaveLength(2); + expect(days[0]!.date).toBe("2026-03-16"); // Monday + expect(days[1]!.date).toBe("2026-03-18"); // Wednesday + }); + + it("timezone affects which dates are included", () => { + // 2026-03-16T03:00:00Z → UTC: Mar 16, New York (EDT -4): Mar 15 23:00 + const start = new Date("2026-03-16T03:00:00Z"); + const end = new Date("2026-03-16T05:00:00Z"); + + const daysUTC = resolveServiceDays(null, start, end, "UTC"); + const daysNY = resolveServiceDays(null, start, end, "America/New_York"); + + expect(daysUTC).toHaveLength(1); + expect(daysUTC[0]!.date).toBe("2026-03-16"); + + // NY: spans Mar 15 (23:00) to Mar 16 (01:00) → two dates + expect(daysNY).toHaveLength(2); + expect(daysNY[0]!.date).toBe("2026-03-15"); + expect(daysNY[1]!.date).toBe("2026-03-16"); + }); + + it("no policy → all days returned as 24h open", () => { + const days = resolveServiceDays( + null, + new Date("2026-03-16T00:00:00Z"), + new Date("2026-03-18T23:59:59Z"), + "UTC", + ); + expect(days).toHaveLength(3); + for (const day of days) { + expect(day.windows).toEqual([{ startMs: 0, endMs: DAY }]); + expect(day.config).toEqual({}); + } + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 4. generateSlots +// ═════════════════════════════════════════════════════════════════════════════ + +describe("generateSlots", () => { + const Q = { start: new Date("2026-03-16T00:00:00Z"), end: new Date("2026-03-17T00:00:00Z") }; + + it("generates grid-aligned slots within window", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 12 * HOUR]], { + grid: { interval_ms: 30 * MINUTE }, + }); + const slots = generateSlots([day], [], HOUR, PAST, "UTC", false, Q.start, Q.end); + + // 60min slots at 30min grid on 09:00-12:00: 09:00, 09:30, 10:00, 10:30, 11:00 + expect(slots).toHaveLength(5); + expect(slots[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(slots[4]!.startTime).toBe("2026-03-16T11:00:00.000Z"); + expect(slots[4]!.endTime).toBe("2026-03-16T12:00:00.000Z"); + }); + + it("rounds up to grid alignment when window starts off-grid (regression test)", () => { + // Regression test: window starts at 10:15, but grid is 30min aligned to midnight + // First slot should be at 10:30 (next grid point), not 10:15 + const day = makeDay("2026-03-16", [[10 * HOUR + 15 * MINUTE, 12 * HOUR]], { + grid: { interval_ms: 30 * MINUTE }, + }); + const slots = generateSlots([day], [], HOUR, PAST, "UTC", false, Q.start, Q.end); + + // Grid generates slots every 30min, so: 10:30-11:30, 11:00-12:00 + expect(slots).toHaveLength(2); + expect(slots[0]!.startTime).toBe("2026-03-16T10:30:00.000Z"); + expect(slots[0]!.endTime).toBe("2026-03-16T11:30:00.000Z"); + expect(slots[1]!.startTime).toBe("2026-03-16T11:00:00.000Z"); + expect(slots[1]!.endTime).toBe("2026-03-16T12:00:00.000Z"); + }); + + it("no grid → step = durationMs (non-overlapping)", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 13 * HOUR]]); + const slots = generateSlots([day], [], HOUR, PAST, "UTC", false, Q.start, Q.end); + + expect(slots).toHaveLength(4); + expect(slots[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(slots[3]!.startTime).toBe("2026-03-16T12:00:00.000Z"); + }); + + it("skips day where duration not in allowed_ms", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { + duration: { allowed_ms: [30 * MINUTE, 90 * MINUTE] }, + }); + expect(generateSlots([day], [], HOUR, PAST, "UTC", false, Q.start, Q.end)).toHaveLength(0); + }); + + it("skips day where duration below min_ms", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { duration: { min_ms: 2 * HOUR } }); + expect(generateSlots([day], [], HOUR, PAST, "UTC", false, Q.start, Q.end)).toHaveLength(0); + }); + + it("skips day where duration above max_ms", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { + duration: { max_ms: 30 * MINUTE }, + }); + expect(generateSlots([day], [], HOUR, PAST, "UTC", false, Q.start, Q.end)).toHaveLength(0); + }); + + it("filters slots conflicting with allocations", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 13 * HOUR]]); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T10:00:00Z"), + endTime: new Date("2026-03-16T11:00:00Z"), + }, + ]; + const slots = generateSlots([day], allocs, HOUR, PAST, "UTC", false, Q.start, Q.end); + const starts = slots.map((s) => s.startTime); + + expect(starts).toContain("2026-03-16T09:00:00.000Z"); + expect(starts).not.toContain("2026-03-16T10:00:00.000Z"); + expect(starts).toContain("2026-03-16T11:00:00.000Z"); + expect(starts).toContain("2026-03-16T12:00:00.000Z"); + }); + + it("buffer-expanded conflict: after_ms extends conflict window", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 13 * HOUR]], { + buffers: { after_ms: 10 * MINUTE }, + }); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T10:00:00Z"), + endTime: new Date("2026-03-16T11:00:00Z"), + }, + ]; + const slots = generateSlots([day], allocs, HOUR, PAST, "UTC", false, Q.start, Q.end); + const starts = slots.map((s) => s.startTime); + + // Slot at 09:00: effective [09:00, 10:10) overlaps [10:00, 11:00) → excluded + expect(starts).not.toContain("2026-03-16T09:00:00.000Z"); + // Slot at 11:00: effective [11:00, 12:10) does NOT overlap → included + expect(starts).toContain("2026-03-16T11:00:00.000Z"); + }); + + it("buffer-expanded conflict: before_ms extends conflict window", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 13 * HOUR]], { + buffers: { before_ms: 15 * MINUTE }, + }); + // Small allocation at 09:50-10:00 + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T09:50:00Z"), + endTime: new Date("2026-03-16T10:00:00Z"), + }, + ]; + const slots = generateSlots([day], allocs, HOUR, PAST, "UTC", false, Q.start, Q.end); + const starts = slots.map((s) => s.startTime); + + // Slot at 10:00: effective start = 10:00 - 15min = 09:45. Overlaps [09:50, 10:00) → excluded + expect(starts).not.toContain("2026-03-16T10:00:00.000Z"); + // Slot at 11:00: effective start = 10:45. 10:45 < 10:00? No → included + expect(starts).toContain("2026-03-16T11:00:00.000Z"); + }); + + it("lead time filtering excludes too-close slots", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 12 * HOUR]], { + lead_time: { min_ms: 2 * HOUR }, + }); + const serverTime = new Date("2026-03-16T09:30:00Z"); + const slots = generateSlots([day], [], HOUR, serverTime, "UTC", false, Q.start, Q.end); + + // All slots (09:00, 10:00, 11:00) have < 2h lead time from 09:30 + expect(slots).toHaveLength(0); + }); + + it("horizon filtering excludes too-far slots", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 13 * HOUR]], { + lead_time: { max_ms: 2 * HOUR }, + }); + const serverTime = new Date("2026-03-16T09:00:00Z"); + const slots = generateSlots([day], [], HOUR, serverTime, "UTC", false, Q.start, Q.end); + const starts = slots.map((s) => s.startTime); + + expect(starts).toContain("2026-03-16T09:00:00.000Z"); // 0h ≤ 2h + expect(starts).toContain("2026-03-16T10:00:00.000Z"); // 1h ≤ 2h + expect(starts).toContain("2026-03-16T11:00:00.000Z"); // 2h ≤ 2h + expect(starts).not.toContain("2026-03-16T12:00:00.000Z"); // 3h > 2h + }); + + it("includeUnavailable: all slots get status field", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 12 * HOUR]]); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T10:00:00Z"), + endTime: new Date("2026-03-16T11:00:00Z"), + }, + ]; + const slots = generateSlots([day], allocs, HOUR, PAST, "UTC", true, Q.start, Q.end); + + expect(slots).toHaveLength(3); + expect(slots[0]!.status).toBe("available"); // 09:00 + expect(slots[1]!.status).toBe("unavailable"); // 10:00 + expect(slots[2]!.status).toBe("available"); // 11:00 + }); + + it("includeUnavailable false: no status field, only available", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 12 * HOUR]]); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T10:00:00Z"), + endTime: new Date("2026-03-16T11:00:00Z"), + }, + ]; + const slots = generateSlots([day], allocs, HOUR, PAST, "UTC", false, Q.start, Q.end); + + expect(slots).toHaveLength(2); + expect(slots[0]).not.toHaveProperty("status"); + expect(slots[1]).not.toHaveProperty("status"); + }); + + it("query range clamping excludes out-of-range candidates", () => { + const day = makeDay("2026-03-16", [[0, DAY]]); // 24h schedule + const qStart = new Date("2026-03-16T10:00:00Z"); + const qEnd = new Date("2026-03-16T12:00:00Z"); + const slots = generateSlots([day], [], HOUR, PAST, "UTC", false, qStart, qEnd); + + // Only 10:00-11:00 and 11:00-12:00 fit + expect(slots).toHaveLength(2); + expect(slots[0]!.startTime).toBe("2026-03-16T10:00:00.000Z"); + expect(slots[1]!.startTime).toBe("2026-03-16T11:00:00.000Z"); + }); + + it("multiple days: generates slots across days", () => { + const days = [ + makeDay("2026-03-16", [[9 * HOUR, 11 * HOUR]]), + makeDay("2026-03-17", [[9 * HOUR, 11 * HOUR]]), + ]; + const qEnd = new Date("2026-03-18T00:00:00Z"); + const slots = generateSlots(days, [], HOUR, PAST, "UTC", false, Q.start, qEnd); + + expect(slots).toHaveLength(4); + expect(slots[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(slots[2]!.startTime).toBe("2026-03-17T09:00:00.000Z"); + }); + + it("per-day config variation: different grids per day", () => { + const days = [ + makeDay("2026-03-16", [[9 * HOUR, 11 * HOUR]], { grid: { interval_ms: 30 * MINUTE } }), + makeDay("2026-03-17", [[9 * HOUR, 11 * HOUR]], { grid: { interval_ms: HOUR } }), + ]; + const qEnd = new Date("2026-03-18T00:00:00Z"); + const slots = generateSlots(days, [], HOUR, PAST, "UTC", false, Q.start, qEnd); + + const day1 = slots.filter((s) => s.startTime.startsWith("2026-03-16")); + const day2 = slots.filter((s) => s.startTime.startsWith("2026-03-17")); + + // Day 1: 30min grid, 60min duration → 09:00, 09:30, 10:00 = 3 slots + expect(day1).toHaveLength(3); + // Day 2: 60min grid, 60min duration → 09:00, 10:00 = 2 slots + expect(day2).toHaveLength(2); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 5. computeWindows +// ═════════════════════════════════════════════════════════════════════════════ + +describe("computeWindows", () => { + const Q = { start: new Date("2026-03-16T00:00:00Z"), end: new Date("2026-03-17T00:00:00Z") }; + + it("full schedule, no allocations → single window", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]]); + const windows = computeWindows([day], [], PAST, "UTC", false, Q.start, Q.end); + + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T17:00:00.000Z"); + }); + + it("allocation subtraction carves out time", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]]); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T12:00:00Z"), + endTime: new Date("2026-03-16T13:00:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", false, Q.start, Q.end); + + expect(windows).toHaveLength(2); + expect(windows[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T12:00:00.000Z"); + expect(windows[1]!.startTime).toBe("2026-03-16T13:00:00.000Z"); + expect(windows[1]!.endTime).toBe("2026-03-16T17:00:00.000Z"); + }); + + it("respects per-day config boundaries when applying buffers (regression test)", () => { + // Regression test: buffer shrinkage should use per-day configs correctly + // Bug was that merged schedule intervals would use only the first day's config + // Day 1: 16:00-24:00 with buffers (before=10min, after=5min) + // Day 2: 00:00-08:00 with NO buffers + const Q2 = { + start: new Date("2026-03-16T00:00:00Z"), + end: new Date("2026-03-18T00:00:00Z"), + }; + const day1 = makeDay("2026-03-16", [[16 * HOUR, 24 * HOUR]], { + buffers: { before_ms: 10 * MINUTE, after_ms: 5 * MINUTE }, + }); + const day2 = makeDay("2026-03-17", [[0, 8 * HOUR]], { + buffers: { before_ms: 0, after_ms: 0 }, + }); + + // Allocation at 23:00-23:30 on day1 + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T23:00:00Z"), + endTime: new Date("2026-03-16T23:30:00Z"), + }, + ]; + + const windows = computeWindows([day1, day2], allocs, PAST, "UTC", false, Q2.start, Q2.end); + + // After buffer shrinkage, we get: + // 1. 16:00-22:55 (day1 pre-allocation, shrunk end by after=5min) + // 2. 23:40-24:00 (day1 post-allocation, shrunk start by before=10min) + // 3. 00:00-08:00 (day2, no shrinkage - would have been shrunk if config leaked) + // Then 2+3 merge into 23:40-08:00 (contiguous windows merge for presentation) + expect(windows).toHaveLength(2); + expect(windows[0]!.startTime).toBe("2026-03-16T16:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T22:55:00.000Z"); // Shrunk by day1's after_ms=5min + expect(windows[1]!.startTime).toBe("2026-03-16T23:40:00.000Z"); // Shrunk by day1's before_ms=10min + expect(windows[1]!.endTime).toBe("2026-03-17T08:00:00.000Z"); // Day2 untouched, then merged + + // The key assertion: if config leaked, day2 would start at 00:10 (shrunk by day1's before_ms) + // But it correctly starts at 00:00 because day2 has no buffers + }); + + it("asymmetric buffer shrinkage at allocation boundaries", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { + buffers: { before_ms: 15 * MINUTE, after_ms: 10 * MINUTE }, + }); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T09:45:00Z"), + endTime: new Date("2026-03-16T11:10:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", false, Q.start, Q.end); + + // Gap before: [09:00, 09:45) → end adjacent to alloc → shrink by after_ms(10min) → [09:00, 09:35) + // Gap after: [11:10, 17:00) → start adjacent to alloc → shrink by before_ms(15min) → [11:25, 17:00) + expect(windows).toHaveLength(2); + expect(windows[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T09:35:00.000Z"); + expect(windows[1]!.startTime).toBe("2026-03-16T11:25:00.000Z"); + expect(windows[1]!.endTime).toBe("2026-03-16T17:00:00.000Z"); + }); + + it("schedule boundaries do not shrink", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { + buffers: { before_ms: HOUR, after_ms: HOUR }, + }); + const windows = computeWindows([day], [], PAST, "UTC", false, Q.start, Q.end); + + // No allocations → no adjacent allocations → no shrinkage + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T17:00:00.000Z"); + }); + + it("gap between two allocations shrinks from both sides", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { + buffers: { before_ms: 15 * MINUTE, after_ms: 10 * MINUTE }, + }); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T09:00:00Z"), + endTime: new Date("2026-03-16T11:00:00Z"), + }, + { + resourceId: "r", + startTime: new Date("2026-03-16T14:00:00Z"), + endTime: new Date("2026-03-16T17:00:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", false, Q.start, Q.end); + + // Gap [11:00, 14:00): + // start adjacent to alloc1 end → +before_ms(15min) → 11:15 + // end adjacent to alloc2 start → -after_ms(10min) → 13:50 + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-16T11:15:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T13:50:00.000Z"); + }); + + it("buffer shrinkage eliminates gap entirely", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { + buffers: { before_ms: 30 * MINUTE, after_ms: 30 * MINUTE }, + }); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T09:00:00Z"), + endTime: new Date("2026-03-16T12:00:00Z"), + }, + { + resourceId: "r", + startTime: new Date("2026-03-16T12:30:00Z"), + endTime: new Date("2026-03-16T17:00:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", false, Q.start, Q.end); + + // Gap [12:00, 12:30) → shrunk by 30min from each side → eliminated + expect(windows).toHaveLength(0); + }); + + it("sub-minimum filtering discards short windows", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]], { duration: { min_ms: 2 * HOUR } }); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T09:00:00Z"), + endTime: new Date("2026-03-16T12:00:00Z"), + }, + { + resourceId: "r", + startTime: new Date("2026-03-16T13:00:00Z"), + endTime: new Date("2026-03-16T17:00:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", false, Q.start, Q.end); + + // Gap: 12:00-13:00 = 1h < min_ms 2h → discarded + expect(windows).toHaveLength(0); + }); + + it("contiguous merging across day boundaries", () => { + const days = [makeDay("2026-03-16", [[0, DAY]]), makeDay("2026-03-17", [[0, DAY]])]; + const qEnd = new Date("2026-03-18T00:00:00Z"); + const windows = computeWindows(days, [], PAST, "UTC", false, Q.start, qEnd); + + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-16T00:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-18T00:00:00.000Z"); + }); + + it("includeUnavailable: returns both available and unavailable", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]]); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T12:00:00Z"), + endTime: new Date("2026-03-16T13:00:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", true, Q.start, Q.end); + + const available = windows.filter((w) => w.status === "available"); + const unavailable = windows.filter((w) => w.status === "unavailable"); + + expect(available).toHaveLength(2); + expect(unavailable).toHaveLength(1); + expect(unavailable[0]!.startTime).toBe("2026-03-16T12:00:00.000Z"); + expect(unavailable[0]!.endTime).toBe("2026-03-16T13:00:00.000Z"); + + for (const w of windows) { + expect(w.status).toMatch(/^(available|unavailable)$/); + } + }); + + it("includeUnavailable false: no status field", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]]); + const windows = computeWindows([day], [], PAST, "UTC", false, Q.start, Q.end); + + expect(windows).toHaveLength(1); + expect(windows[0]).not.toHaveProperty("status"); + }); + + it("query range clamping clips schedule windows", () => { + const day = makeDay("2026-03-16", [[0, DAY]]); + const qStart = new Date("2026-03-16T10:00:00Z"); + const qEnd = new Date("2026-03-16T14:00:00Z"); + const windows = computeWindows([day], [], PAST, "UTC", false, qStart, qEnd); + + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-16T10:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T14:00:00.000Z"); + }); + + it("multiple schedule windows on same day", () => { + const day = makeDay("2026-03-16", [ + [9 * HOUR, 12 * HOUR], + [14 * HOUR, 18 * HOUR], + ]); + const windows = computeWindows([day], [], PAST, "UTC", false, Q.start, Q.end); + + expect(windows).toHaveLength(2); + expect(windows[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T12:00:00.000Z"); + expect(windows[1]!.startTime).toBe("2026-03-16T14:00:00.000Z"); + expect(windows[1]!.endTime).toBe("2026-03-16T18:00:00.000Z"); + }); + + it("allocation fully outside schedule has no effect", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]]); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T07:00:00Z"), + endTime: new Date("2026-03-16T08:00:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", false, Q.start, Q.end); + + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T17:00:00.000Z"); + }); + + it("allocation partially overlapping schedule start", () => { + const day = makeDay("2026-03-16", [[9 * HOUR, 17 * HOUR]]); + const allocs: BlockingAllocation[] = [ + { + resourceId: "r", + startTime: new Date("2026-03-16T08:00:00Z"), + endTime: new Date("2026-03-16T10:00:00Z"), + }, + ]; + const windows = computeWindows([day], allocs, PAST, "UTC", false, Q.start, Q.end); + + expect(windows).toHaveLength(1); + expect(windows[0]!.startTime).toBe("2026-03-16T10:00:00.000Z"); + expect(windows[0]!.endTime).toBe("2026-03-16T17:00:00.000Z"); + }); +}); diff --git a/apps/server/test/unit/vitest.config.ts b/apps/server/test/unit/vitest.config.ts new file mode 100644 index 0000000..17dab78 --- /dev/null +++ b/apps/server/test/unit/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + name: "unit", + include: ["**/*.{test,spec}.ts"], + environment: "node", + globals: true, + }, + resolve: { + alias: { + domain: path.resolve(__dirname, "../../src/domain"), + }, + }, +}); diff --git a/apps/server/test/v1/allocations/cancel.spec.ts b/apps/server/test/v1/allocations/cancel.spec.ts deleted file mode 100644 index a7813e5..0000000 --- a/apps/server/test/v1/allocations/cancel.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { client } from "../../setup/client"; -import { createAllocation, createResource } from "../../setup/factories"; -import type { AllocationResponse } from "../../setup/types"; - -describe("POST /v1/ledgers/:ledgerId/allocations/:id/cancel", () => { - it("cancels a hold allocation", async () => { - const { resource, ledgerId } = await createResource(); - - // Create a hold - const createResponse = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "hold", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), - expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), - }); - expect(createResponse.status).toBe(201); - const { data: hold } = (await createResponse.json()) as AllocationResponse; - - // Cancel it - const response = await client.post(`/v1/ledgers/${ledgerId}/allocations/${hold.id}/cancel`); - - expect(response.status).toBe(200); - const body = (await response.json()) as AllocationResponse; - expect(body.data.status).toBe("cancelled"); - expect(body.meta?.serverTime).toBeDefined(); - }); - - it("cancels a confirmed allocation", async () => { - const { allocation, ledgerId } = await createAllocation({ status: "confirmed" }); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/cancel`, - ); - - expect(response.status).toBe(200); - const body = (await response.json()) as AllocationResponse; - expect(body.data.status).toBe("cancelled"); - }); - - it("is idempotent - cancelling already cancelled allocation succeeds", async () => { - const { allocation, ledgerId } = await createAllocation({ status: "cancelled" }); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/cancel`, - ); - - expect(response.status).toBe(200); - const body = (await response.json()) as AllocationResponse; - expect(body.data.status).toBe("cancelled"); - }); - - it("returns 409 when cancelling expired allocation", async () => { - const { allocation, ledgerId } = await createAllocation({ status: "expired" }); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/cancel`, - ); - - expect(response.status).toBe(409); - const body = (await response.json()) as AllocationResponse; - expect(body.error?.code).toBe("invalid_state_transition"); - }); - - it("frees up the time slot after cancellation", async () => { - const { resource, ledgerId } = await createResource(); - const startAt = new Date("2026-02-01T10:00:00Z"); - const endAt = new Date("2026-02-01T11:00:00Z"); - - // Create and confirm an allocation - const createResponse = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "confirmed", - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), - }); - expect(createResponse.status).toBe(201); - const { data: first } = (await createResponse.json()) as AllocationResponse; - - // Cancel it - const cancelResponse = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${first.id}/cancel`, - ); - expect(cancelResponse.status).toBe(200); - - // Now we should be able to book the same time slot - const newResponse = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "confirmed", - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), - }); - - expect(newResponse.status).toBe(201); - }); - - it("returns 404 for non-existent allocation", async () => { - const { ledgerId } = await createResource(); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/alc_00000000000000000000000000/cancel`, - ); - - expect(response.status).toBe(404); - }); - - it("returns 422 for invalid allocation ID", async () => { - const { ledgerId } = await createResource(); - - const response = await client.post(`/v1/ledgers/${ledgerId}/allocations/invalid-id/cancel`); - - expect(response.status).toBe(422); - }); -}); diff --git a/apps/server/test/v1/allocations/confirm.spec.ts b/apps/server/test/v1/allocations/confirm.spec.ts deleted file mode 100644 index cfe7f67..0000000 --- a/apps/server/test/v1/allocations/confirm.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { client } from "../../setup/client"; -import { createAllocation, createResource } from "../../setup/factories"; -import type { AllocationResponse } from "../../setup/types"; - -describe("POST /v1/ledgers/:ledgerId/allocations/:id/confirm", () => { - it("confirms a hold allocation", async () => { - const { resource, ledgerId } = await createResource(); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes from now - - // Create a hold - const createResponse = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { - resourceId: resource.id, - status: "hold", - startAt: new Date("2026-02-01T10:00:00Z").toISOString(), - endAt: new Date("2026-02-01T11:00:00Z").toISOString(), - expiresAt: expiresAt.toISOString(), - }); - expect(createResponse.status).toBe(201); - const { data: hold } = (await createResponse.json()) as AllocationResponse; - - // Confirm it - const response = await client.post(`/v1/ledgers/${ledgerId}/allocations/${hold.id}/confirm`); - - expect(response.status).toBe(200); - const body = (await response.json()) as AllocationResponse; - expect(body.data.status).toBe("confirmed"); - expect(body.meta?.serverTime).toBeDefined(); - }); - - it("is idempotent - confirming already confirmed allocation succeeds", async () => { - const { allocation, ledgerId } = await createAllocation({ status: "confirmed" }); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/confirm`, - ); - - expect(response.status).toBe(200); - const body = (await response.json()) as AllocationResponse; - expect(body.data.status).toBe("confirmed"); - }); - - it("returns 409 when confirming cancelled allocation", async () => { - const { allocation, ledgerId } = await createAllocation({ status: "cancelled" }); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/confirm`, - ); - - expect(response.status).toBe(409); - const body = (await response.json()) as AllocationResponse; - expect(body.error?.code).toBe("invalid_state_transition"); - }); - - it("returns 409 when confirming expired allocation", async () => { - const { allocation, ledgerId } = await createAllocation({ status: "expired" }); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/confirm`, - ); - - expect(response.status).toBe(409); - const body = (await response.json()) as AllocationResponse; - expect(body.error?.code).toBe("invalid_state_transition"); - }); - - it("returns 409 when confirming hold that has expired (TTL)", async () => { - const { allocation, ledgerId } = await createAllocation({ - status: "hold", - expiresAt: new Date(Date.now() - 1000), // Expired 1 second ago - }); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/${allocation.id}/confirm`, - ); - - expect(response.status).toBe(409); - const body = (await response.json()) as AllocationResponse; - expect(body.error?.code).toBe("hold_expired"); - }); - - it("returns 404 for non-existent allocation", async () => { - const { ledgerId } = await createResource(); - - const response = await client.post( - `/v1/ledgers/${ledgerId}/allocations/alc_00000000000000000000000000/confirm`, - ); - - expect(response.status).toBe(404); - }); - - it("returns 422 for invalid allocation ID", async () => { - const { ledgerId } = await createResource(); - - const response = await client.post(`/v1/ledgers/${ledgerId}/allocations/invalid-id/confirm`); - - expect(response.status).toBe(422); - }); -}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 942a864..90766b0 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,7 +2,10 @@ "extends": "@floyd-run/typescript/app.json", "compilerOptions": { "baseUrl": "src", - "outDir": "dist" + "outDir": "dist", + "paths": { + "domain/*": ["domain/*"] + } }, "include": ["src", "test", "vitest.config.ts"], "exclude": ["node_modules", "dist"] diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 882b7a3..6f46706 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -1,23 +1,7 @@ import { defineConfig } from "vitest/config"; -import path from "path"; export default defineConfig({ test: { - globals: true, - globalSetup: ["./test/setup/global.ts"], - include: ["test/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], - environment: "node", - }, - resolve: { - alias: { - config: path.resolve(__dirname, "./src/config"), - database: path.resolve(__dirname, "./src/database"), - infra: path.resolve(__dirname, "./src/infra"), - lib: path.resolve(__dirname, "./src/lib"), - middleware: path.resolve(__dirname, "./src/middleware"), - migrations: path.resolve(__dirname, "./src/migrations"), - routes: path.resolve(__dirname, "./src/routes"), - services: path.resolve(__dirname, "./src/services"), - }, + projects: ["test/unit/vitest.config.ts", "test/integration/vitest.config.ts"], }, }); diff --git a/docs/allocations.md b/docs/allocations.md index 76a8002..f450a17 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -1,100 +1,135 @@ # Allocations -Floyd Engine's core promise: +Allocations are time blocks on resources. They are the single source of truth for "is this time taken?" -> **Double-booking is impossible**, even under concurrency and retries. +There are two types: -Floyd achieves this with a **two-phase booking lifecycle**. +1. **Booking allocations** — created and managed automatically by bookings. You don't interact with these directly. +2. **Raw allocations** — created and deleted directly via the API, for ad-hoc time blocking. -## The problem: non-atomic booking +## Raw allocations -Many agents do this: +Raw allocations exist for use cases that don't go through the booking flow: -1. Check availability -2. Ask the user to confirm -3. Book the slot +- Maintenance windows +- External calendar blocks (Google Calendar sync, etc.) +- Manual time blocking -Under latency, two agents can see the same slot as "free" and both try to book it. +### Create a raw allocation -## Floyd's lifecycle +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: unique-request-id" \ + -d '{ + "resourceId": "rsc_01abc123...", + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T11:00:00Z", + "metadata": { "reason": "maintenance" } + }' +``` -An allocation moves through these states: +Response: -- `hold` — temporary reservation with `expiresAt` set. Blocks time. -- `confirmed` — committed allocation. Blocks time. -- `cancelled` — released. Does **not** block time. -- `expired` — expiration time elapsed. Does **not** block time. +```json +{ + "data": { + "id": "alc_01abc123...", + "ledgerId": "ldg_01xyz789...", + "resourceId": "rsc_01abc123...", + "bookingId": null, + "active": true, + "startTime": "2026-01-04T10:00:00.000Z", + "endTime": "2026-01-04T11:00:00.000Z", + "buffer": { "beforeMs": 0, "afterMs": 0 }, + "expiresAt": null, + "metadata": { "reason": "maintenance" }, + "createdAt": "2026-01-04T10:00:00.000Z", + "updatedAt": "2026-01-04T10:00:00.000Z" + }, + "meta": { + "serverTime": "2026-01-04T10:00:00.000Z" + } +} +``` -### State transitions +Raw allocations have no status lifecycle — they are `active: true` when created and block time immediately. No policy evaluation is performed. -| From | To | Trigger | -| ----------- | ----------- | ------------------------------- | -| (none) | `hold` | `POST /allocations` | -| `hold` | `confirmed` | `POST /allocations/:id/confirm` | -| `hold` | `cancelled` | `POST /allocations/:id/cancel` | -| `hold` | `expired` | `expiresAt` elapsed (automatic) | -| `confirmed` | `cancelled` | `POST /allocations/:id/cancel` | +### Temporary blocks -## Holds +Use `expiresAt` for blocks that should auto-expire: -When you create an allocation, it starts as `hold` by default. +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ + -H "Content-Type: application/json" \ + -d '{ + "resourceId": "rsc_...", + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T11:00:00Z", + "expiresAt": "2026-01-04T10:30:00Z" + }' +``` -- It **blocks the time slot** immediately. -- It has an optional `expiresAt` timestamp. -- The expiration window is your agent's "ask the user" time. +Expired raw allocations are cleaned up automatically by the expiration worker (hard-deleted, since they have no booking to preserve). -If not confirmed, the hold expires and the slot becomes available again. +### Delete a raw allocation -### Lazy cleanup +```bash +curl -X DELETE "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations/$ALLOCATION_ID" +``` -Floyd automatically marks expired holds as `expired` when you create a new allocation on the same resource. This "lazy cleanup" ensures expired holds don't block slots indefinitely, while avoiding background jobs. +- Returns `204 No Content` on success +- Returns `409 Conflict` with code `booking_owned_allocation` if the allocation belongs to a booking — use the booking cancel endpoint instead -## Confirm (commit) +### Get an allocation -When the user says "yes", call: +```bash +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations/$ALLOCATION_ID" +``` -- `POST /v1/ledgers/:ledgerId/allocations/:id/confirm` +### List allocations -This: +```bash +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" +``` -- sets `status = confirmed` -- clears `expiresAt` +Returns all allocations for the ledger, including both raw and booking-owned. -Confirm is safe to retry: +## Conflict detection -- confirming an already confirmed allocation returns the confirmed allocation +Both bookings and raw allocations share the same conflict detection. Only `active` and non-expired allocations block time: -## Cancel (release) +- `active = true` and `expiresAt` is null → always blocks +- `active = true` and `expiresAt > now()` → blocks until expiry +- `active = false` → does not block (canceled/expired booking allocations) +- `active = true` and `expiresAt <= now()` → does not block (expired) -Cancel explicitly releases the slot: - -- `POST /v1/ledgers/:ledgerId/allocations/:id/cancel` - -Cancel is safe to retry: - -- cancelling an already cancelled/expired allocation returns the allocation -- cancelling a `confirmed` allocation also works and releases the slot +If two requests try to create overlapping allocations on the same resource at the same moment, **exactly one** will succeed. ## Time overlap semantics -Floyd uses **half-open intervals**: **[startAt, endAt)** - -That means: +Floyd uses **half-open intervals**: **[startTime, endTime)** -- back-to-back allocations are allowed - e.g. `[10:00, 10:30)` and `[10:30, 11:00)` do not overlap +- Back-to-back allocations are allowed: `[10:00, 10:30)` and `[10:30, 11:00)` do not overlap Overlap exists iff: -- `allocation.startAt < query.endAt` **and** -- `allocation.endAt > query.startAt` - -## The "physics" (what you can rely on) - -For a single `resourceId`: - -- If two requests try to hold overlapping time ranges at the same moment: - - **exactly one** will succeed - - the rest will get **409 Conflict** - -This remains true under heavy concurrency. +- `allocation.startTime < query.endTime` **and** +- `allocation.endTime > query.startTime` + +## Allocation fields + +| Field | Type | Description | +| ------------ | ------- | ---------------------------------------------------------------------------------------------------- | +| `id` | string | Allocation ID (`alc_` prefix) | +| `ledgerId` | string | Ledger this allocation belongs to | +| `resourceId` | string | Resource being blocked | +| `bookingId` | string | Booking that owns this allocation, or `null` for raw blocks | +| `active` | boolean | `true` = blocks time, `false` = historical record | +| `startTime` | string | Start of the blocked time window (ISO 8601). Includes buffer if from a booking with a buffer policy. | +| `endTime` | string | End of the blocked time window (ISO 8601). Includes buffer if from a booking with a buffer policy. | +| `buffer` | object | Buffer times: `{ beforeMs, afterMs }` in milliseconds. Both `0` for raw allocations. | +| `expiresAt` | string | Expiration time, or `null` for permanent blocks | +| `metadata` | object | Arbitrary key-value data | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | diff --git a/docs/availability.md b/docs/availability.md index ab8e152..8cd54ba 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -1,16 +1,226 @@ # Availability -Query free/busy timelines for resources before creating allocations. +Floyd provides three ways to query availability depending on your use case: -## Query availability +| Endpoint | Scope | Returns | Best for | +| ----------------------------------------------- | ---------------- | --------------------------- | ------------------------------------ | +| [Resource availability](#resource-availability) | Raw resources | Free/busy timeline | Low-level checks | +| [Slots](#slots) | Service + policy | Discrete bookable positions | Appointment booking (salon, doctor) | +| [Windows](#windows) | Service + policy | Continuous available ranges | Rental booking (kayak, meeting room) | + +## Slots + +Returns discrete, grid-aligned time positions where a booking of the given duration could be placed. Policy rules (working hours, grid, buffers, lead time, horizon) are applied automatically. + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID/availability/slots" \ + -H "Content-Type: application/json" \ + -d '{ + "startTime": "2026-03-16T00:00:00Z", + "endTime": "2026-03-16T23:59:59Z", + "durationMs": 3600000 + }' +``` + +Response: + +```json +{ + "data": [ + { + "resourceId": "rsc_01abc123def456ghi789jkl012", + "timezone": "America/New_York", + "slots": [ + { "startTime": "2026-03-16T13:00:00.000Z", "endTime": "2026-03-16T14:00:00.000Z" }, + { "startTime": "2026-03-16T13:30:00.000Z", "endTime": "2026-03-16T14:30:00.000Z" }, + { "startTime": "2026-03-16T14:00:00.000Z", "endTime": "2026-03-16T15:00:00.000Z" } + ] + } + ], + "meta": { + "serverTime": "2026-03-16T10:00:00.000Z" + } +} +``` + +### Request fields + +| Field | Type | Required | Description | +| -------------------- | -------- | -------- | ----------------------------------------------------------------------------- | +| `startTime` | string | Yes | Start of query window (ISO 8601) | +| `endTime` | string | Yes | End of query window (ISO 8601). Max 7 days from `startTime`. | +| `durationMs` | number | Yes | Desired booking duration in milliseconds | +| `resourceIds` | string[] | No | Filter to specific resources. Defaults to all resources in the service. | +| `includeUnavailable` | boolean | No | When `true`, returns all grid positions with `status` field. Default `false`. | + +### How slots are generated + +1. **Day resolution**: Each day in the query range is resolved against the service's policy. Closed days and blackout dates are skipped. +2. **Grid alignment**: Within each day's open windows, candidate positions are placed at grid intervals (from `config.grid.interval_ms`). If no grid is configured, the step defaults to `durationMs`. +3. **Duration validation**: If the day's config restricts durations (`allowed_ms`, `min_ms`, `max_ms`) and `durationMs` doesn't pass, the entire day is skipped. +4. **Conflict check**: Each candidate is expanded by buffer times (`before_ms` / `after_ms`) and checked against existing allocations. Overlapping candidates are excluded. +5. **Lead time / horizon**: Candidates too close to `serverTime` (below `min_ms`) or too far out (beyond `max_ms`) are excluded. + +### Overlapping slots + +When the grid interval is smaller than the duration, slots overlap. For example, 60-minute slots on a 30-minute grid produce: + +``` +09:00–10:00, 09:30–10:30, 10:00–11:00, ... +``` + +This lets users pick the best start time rather than being locked to non-overlapping blocks. + +### includeUnavailable + +By default, only available slots are returned (no `status` field). With `includeUnavailable: true`, every grid position is returned with a `status` of `"available"` or `"unavailable"`: + +```json +{ + "slots": [ + { "startTime": "...", "endTime": "...", "status": "available" }, + { "startTime": "...", "endTime": "...", "status": "unavailable" }, + { "startTime": "...", "endTime": "...", "status": "available" } + ] +} +``` + +This is useful for rendering a full calendar grid with booked slots grayed out. + +## Windows + +Returns continuous available time ranges after subtracting existing allocations and applying buffer shrinkage. No grid alignment — windows represent the full bookable gaps. + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID/availability/windows" \ + -H "Content-Type: application/json" \ + -d '{ + "startTime": "2026-03-16T00:00:00Z", + "endTime": "2026-03-20T00:00:00Z" + }' +``` + +Response: + +```json +{ + "data": [ + { + "resourceId": "rsc_01abc123def456ghi789jkl012", + "timezone": "America/New_York", + "windows": [ + { "startTime": "2026-03-16T13:00:00.000Z", "endTime": "2026-03-16T22:00:00.000Z" }, + { "startTime": "2026-03-17T13:00:00.000Z", "endTime": "2026-03-17T16:00:00.000Z" }, + { "startTime": "2026-03-17T18:00:00.000Z", "endTime": "2026-03-17T22:00:00.000Z" } + ] + } + ], + "meta": { + "serverTime": "2026-03-16T10:00:00.000Z" + } +} +``` + +### Request fields + +| Field | Type | Required | Description | +| -------------------- | -------- | -------- | --------------------------------------------------------------------------------------- | +| `startTime` | string | Yes | Start of query window (ISO 8601) | +| `endTime` | string | Yes | End of query window (ISO 8601). Max 31 days from `startTime`. | +| `resourceIds` | string[] | No | Filter to specific resources. Defaults to all resources in the service. | +| `includeUnavailable` | boolean | No | When `true`, also returns allocation times as `"unavailable"` windows. Default `false`. | + +### How windows are computed + +1. **Day resolution**: Same as slots — each day resolved against the policy. +2. **Schedule assembly**: Open windows across all days are converted to absolute time intervals and merged if contiguous (e.g., two 24h days merge into one 48h window). +3. **Allocation subtraction**: Existing allocations are carved out of the schedule windows. +4. **Buffer shrinkage**: Gaps adjacent to allocations are shrunk to account for new-booking buffer requirements: + - Gap start touches an allocation → shrink start by `before_ms` + - Gap end touches an allocation → shrink end by `after_ms` + - Gap start/end at a schedule boundary → no shrinkage (buffers extend outside schedule) +5. **Minimum duration filter**: Windows shorter than `config.duration.min_ms` are discarded. +6. **Lead time / horizon**: Windows outside the lead time range are filtered. +7. **Merge**: Adjacent windows are merged into single ranges. + +### Buffer shrinkage example + +Given a schedule of 09:00–17:00 with `before_ms: 15min` and `after_ms: 10min`, and an existing allocation at 12:00–13:00: + +``` +Schedule: |========================================| + 09:00 17:00 + +Alloc: |======| + 12:00 13:00 + +Raw gaps: |=============| |===================| + 09:00 12:00 13:00 17:00 + +After shrinkage: + |=========| |=================| + 09:00 11:50 13:15 17:00 + ↑ -10min ↑ +15min + (after_ms) (before_ms) +``` + +The gap before the allocation shrinks at its end by `after_ms` (the new booking would need a post-buffer). The gap after the allocation shrinks at its start by `before_ms` (the new booking would need a pre-buffer). Schedule boundaries don't shrink — buffers are allowed to extend outside the schedule. + +### includeUnavailable + +With `includeUnavailable: true`, allocation times within schedule hours are also returned with `status: "unavailable"`: + +```json +{ + "windows": [ + { "startTime": "...", "endTime": "...", "status": "available" }, + { "startTime": "...", "endTime": "...", "status": "unavailable" }, + { "startTime": "...", "endTime": "...", "status": "available" } + ] +} +``` + +## Filtering by resource + +Both slots and windows accept an optional `resourceIds` array to query specific resources within the service: + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID/availability/slots" \ + -H "Content-Type: application/json" \ + -d '{ + "startTime": "2026-03-16T00:00:00Z", + "endTime": "2026-03-16T23:59:59Z", + "durationMs": 3600000, + "resourceIds": ["rsc_01abc123def456ghi789jkl012"] + }' +``` + +If omitted, all resources belonging to the service are included. Each resource gets independent results in the `data` array. Returns `422` if a resource ID doesn't belong to the service. + +## No policy behavior + +If the service has no policy attached: + +- All times are considered open (24h schedule) +- No duration, grid, buffer, or lead time constraints +- Only existing allocations block time +- Slots step defaults to `durationMs` + +## Timezone handling + +Every resource has a required `timezone` field (IANA format, e.g., `"America/New_York"`). Schedule windows (e.g., "09:00–17:00") are interpreted in the resource's timezone, including DST transitions. The resolved `timezone` is included in each resource's response entry. + +## Resource availability + +For low-level free/busy checks without policy evaluation, query resource availability directly: ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/availability" \ -H "Content-Type: application/json" \ -d '{ "resourceIds": ["rsc_01abc123def456ghi789jkl012"], - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T18:00:00Z" + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T18:00:00Z" }' ``` @@ -23,18 +233,18 @@ Response: "resourceId": "rsc_01abc123def456ghi789jkl012", "timeline": [ { - "startAt": "2026-01-04T10:00:00.000Z", - "endAt": "2026-01-04T11:00:00.000Z", + "startTime": "2026-01-04T10:00:00.000Z", + "endTime": "2026-01-04T11:00:00.000Z", "status": "free" }, { - "startAt": "2026-01-04T11:00:00.000Z", - "endAt": "2026-01-04T12:00:00.000Z", + "startTime": "2026-01-04T11:00:00.000Z", + "endTime": "2026-01-04T12:00:00.000Z", "status": "busy" }, { - "startAt": "2026-01-04T12:00:00.000Z", - "endAt": "2026-01-04T18:00:00.000Z", + "startTime": "2026-01-04T12:00:00.000Z", + "endTime": "2026-01-04T18:00:00.000Z", "status": "free" } ] @@ -43,53 +253,72 @@ Response: } ``` -## Multiple resources +This endpoint doesn't know about services or policies — it only reports raw allocation conflicts. -Query multiple resources in a single request: +### What counts as "busy" -```bash -curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/availability" \ - -H "Content-Type: application/json" \ - -d '{ - "resourceIds": ["rsc_resource1", "rsc_resource2"], - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T18:00:00Z" - }' -``` - -Each resource gets its own timeline in the response. +A time slot is marked `busy` if it overlaps with an active, non-expired allocation: -## What counts as "busy" +- `active = true` and `expiresAt` is null (permanent block) +- `active = true` and `expiresAt > now()` (temporary block, not yet expired) -A time slot is marked `busy` if it overlaps with: +These do **not** block time: -- A `hold` allocation that hasn't expired -- A `confirmed` allocation +- `active = false` (canceled/expired booking allocations) +- Expired allocations (`expiresAt <= now()`) -Expired holds and cancelled allocations do **not** block time. +Both booking-owned and raw allocations are considered. -## Timeline behavior +### Timeline behavior - **Clamping**: Allocations extending outside the query window are clamped to fit - **Merging**: Overlapping and adjacent busy blocks are merged into single blocks - **Gaps filled**: Free blocks are automatically generated for unoccupied time -## Use case: find available slots +## Use case: slots for an appointment -1. Query availability for the desired time window -2. Find `free` blocks that match your duration requirements -3. Create a hold on the chosen slot +```javascript +const { data, meta } = await fetch( + `${baseUrl}/v1/ledgers/${ledgerId}/services/${serviceId}/availability/slots`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ startTime, endTime, durationMs: 3600000 }), + }, +).then((r) => r.json()); + +// Pick the first available slot for any resource +for (const resource of data) { + if (resource.slots.length > 0) { + const slot = resource.slots[0]; + console.log(`Book ${resource.resourceId} at ${slot.startTime}`); + break; + } +} +``` + +## Use case: find a rental window ```javascript -const { data } = await fetch(`${baseUrl}/v1/ledgers/${ledgerId}/availability`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ resourceIds, startAt, endAt }), -}).then((r) => r.json()); - -const freeSlots = data[0].timeline.filter((block) => block.status === "free"); -const suitableSlot = freeSlots.find((slot) => { - const duration = new Date(slot.endAt) - new Date(slot.startAt); - return duration >= requiredDuration; -}); +const { data } = await fetch( + `${baseUrl}/v1/ledgers/${ledgerId}/services/${serviceId}/availability/windows`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ startTime, endTime }), + }, +).then((r) => r.json()); + +// Find a window that fits the desired rental duration +const minDuration = 4 * 60 * 60 * 1000; // 4 hours +for (const resource of data) { + const suitable = resource.windows.find((w) => { + const duration = new Date(w.endTime) - new Date(w.startTime); + return duration >= minDuration; + }); + if (suitable) { + console.log(`Rent ${resource.resourceId}: ${suitable.startTime} to ${suitable.endTime}`); + break; + } +} ``` diff --git a/docs/bookings.md b/docs/bookings.md new file mode 100644 index 0000000..3e61100 --- /dev/null +++ b/docs/bookings.md @@ -0,0 +1,187 @@ +# Bookings + +Bookings are policy-evaluated reservations created through a service. They own the lifecycle (hold/confirm/cancel/expire) and manage allocations automatically. + +## The problem: non-atomic booking + +Many agents do this: + +1. Check availability +2. Ask the user to confirm +3. Book the slot + +Under latency, two agents can see the same slot as "free" and both try to book it. + +## Floyd's lifecycle + +A booking moves through these states: + +- `hold` — temporary reservation with `expiresAt`. Blocks time. +- `confirmed` — committed reservation. Blocks time. +- `canceled` — released. Does **not** block time. +- `expired` — expiration time elapsed. Does **not** block time. + +### State transitions + +| From | To | Trigger | +| ----------- | ----------- | ------------------------------------------- | +| (none) | `hold` | `POST /bookings` | +| (none) | `confirmed` | `POST /bookings` with `status: "confirmed"` | +| `hold` | `confirmed` | `POST /bookings/:id/confirm` | +| `hold` | `canceled` | `POST /bookings/:id/cancel` | +| `hold` | `expired` | `expiresAt` elapsed (automatic) | +| `confirmed` | `canceled` | `POST /bookings/:id/cancel` | + +## Creating a booking + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: unique-request-id" \ + -d '{ + "serviceId": "svc_...", + "resourceId": "rsc_...", + "startTime": "2026-03-01T10:00:00Z", + "endTime": "2026-03-01T11:00:00Z", + "metadata": { "customerName": "Alice" } + }' +``` + +### What happens on create + +1. Validates the resource belongs to the service +2. If the service has a policy, evaluates it (working hours, duration, grid, buffers, etc.) +3. If the policy defines buffers, expands the allocation's time window (see [Buffers](#buffers)) +4. Checks for time conflicts on the resource +5. Creates a booking with `status=hold` and `expiresAt` (default: 15 minutes from now) +6. Creates an allocation linked to the booking + +### Creating as confirmed + +Skip the hold step by passing `"status": "confirmed"`: + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ + -H "Content-Type: application/json" \ + -d '{ + "serviceId": "svc_...", + "resourceId": "rsc_...", + "startTime": "2026-03-01T10:00:00Z", + "endTime": "2026-03-01T11:00:00Z", + "status": "confirmed" + }' +``` + +This creates the booking without an expiration. + +## Confirm (commit) + +When the user says "yes", confirm the hold: + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/confirm" +``` + +This: + +- Sets `status = confirmed` +- Clears `expiresAt` on both the booking and its allocations + +Confirm is safe to retry — confirming an already confirmed booking returns the confirmed booking. + +Returns `409 Conflict` with code `hold_expired` if the hold expired before confirmation. + +## Cancel (release) + +Cancel explicitly releases the slot: + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/cancel" +``` + +This: + +- Sets `status = canceled` +- Deactivates allocations (`active = false`) + +Cancel works on both `hold` and `confirmed` bookings. It's safe to retry — canceling an already canceled booking returns the booking. + +## Expiration + +Hold bookings expire automatically when `expiresAt` elapses. The expiration worker: + +1. Finds hold bookings where `expiresAt <= now()` +2. Sets `status = expired` +3. Deactivates allocations (`active = false`) +4. Enqueues a `booking.expired` webhook event + +After expiration, the time slot is available for new bookings. + +## Response format + +```json +{ + "data": { + "id": "bkg_...", + "ledgerId": "ldg_...", + "serviceId": "svc_...", + "status": "hold", + "expiresAt": "2026-03-01T10:15:00.000Z", + "allocations": [ + { + "id": "alc_...", + "resourceId": "rsc_...", + "startTime": "2026-03-01T09:45:00.000Z", + "endTime": "2026-03-01T11:10:00.000Z", + "buffer": { + "beforeMs": 900000, + "afterMs": 600000 + }, + "active": true + } + ], + "metadata": { "customerName": "Alice" }, + "createdAt": "2026-03-01T09:45:00.000Z", + "updatedAt": "2026-03-01T09:45:00.000Z" + }, + "meta": { + "serverTime": "2026-03-01T09:45:00.000Z" + } +} +``` + +Mutating endpoints (`create`, `confirm`, `cancel`) return `meta.serverTime`. + +## Buffers + +When a service's policy defines buffers (`before_ms` / `after_ms`), the allocation's `startTime` and `endTime` are expanded to include the buffer time. This is the actual blocked window on the resource. + +For example, a booking at 10:00–11:00 with a 15-minute before-buffer and 10-minute after-buffer creates an allocation blocking 09:45–11:10. + +The allocation includes `buffer.beforeMs` and `buffer.afterMs` so the original customer time is derivable: + +- Customer start = `allocation.startTime` + `buffer.beforeMs` +- Customer end = `allocation.endTime` - `buffer.afterMs` + +When no policy or no buffers are configured, both values are `0` and the allocation times match the requested times exactly. + +Conflict detection uses the buffer-expanded times, so adjacent customer appointments that would overlap in buffer time are correctly rejected. + +## History + +Canceled and expired bookings retain their allocations with `active: false`. This preserves the full record of what was booked — which resource, which time range — even after the booking is no longer blocking time. + +This is useful for AI agents that need context to make decisions (e.g., "the customer canceled their 3pm — rebook at 4pm"). + +## The "physics" (what you can rely on) + +For a single resource: + +- If two requests try to book overlapping time ranges at the same moment, **exactly one** will succeed — the rest will get **409 Conflict** +- This remains true under heavy concurrency + +## Time overlap semantics + +Floyd uses **half-open intervals**: **[startTime, endTime)** + +- Back-to-back bookings are allowed: `[10:00, 10:30)` and `[10:30, 11:00)` do not overlap diff --git a/docs/errors.md b/docs/errors.md index 7265be3..3279ecc 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -4,9 +4,9 @@ Floyd Engine uses HTTP status codes to express semantics. Your agent should bran ## 404 Not Found -- Allocation ID does not exist (or is not accessible). +- Resource, service, booking, or allocation ID does not exist. -## 400 Bad Request (input validation) +## 422 Unprocessable Entity Input didn't match the schema. @@ -30,38 +30,51 @@ Common causes: ### Slot not available -Another allocation already blocks that time range on that resource. +Another active allocation already blocks that time range on that resource. ```json { "error": { - "code": "ALLOCATION_CONFLICT", + "code": "overlap_conflict", "message": "Time slot conflicts with existing allocation" } } ``` -### Idempotency mismatch +### Policy rejected -Same `Idempotency-Key` header but different request body. +The service's policy rejects the booking request (wrong hours, invalid duration, etc.). ```json { "error": { - "code": "IDEMPOTENCY_MISMATCH", - "message": "Idempotency key already used with different parameters" + "code": "policy_rejected", + "message": "Policy evaluation failed" + } +} +``` + +### Resource not in service + +The requested resource does not belong to the specified service. + +```json +{ + "error": { + "code": "resource_not_in_service", + "message": "Resource does not belong to this service" } } ``` ### Hold expired -Trying to confirm an allocation that has already expired. +Trying to confirm a booking whose hold has already expired. ```json { "error": { - "code": "HOLD_EXPIRED", + "code": "hold_expired", "message": "Hold expired" } } @@ -69,13 +82,78 @@ Trying to confirm an allocation that has already expired. ### Invalid state transition -Trying to confirm an allocation that is not in `hold` state. +Trying to confirm a booking that is not in `hold` state. + +```json +{ + "error": { + "code": "invalid_state_transition", + "message": "Cannot transition from current status" + } +} +``` + +### Active bookings exist + +Trying to delete a service that has bookings in `hold` or `confirmed` status. + +```json +{ + "error": { + "code": "service.active_bookings", + "message": "Conflict" + } +} +``` + +### Resource in use + +Trying to delete a resource that has allocations or service associations. ```json { "error": { - "code": "INVALID_STATUS_TRANSITION", - "message": "Cannot confirm allocation with status: cancelled" + "code": "resource_in_use", + "message": "Resource has active allocations or service associations" + } +} +``` + +### Policy in use + +Trying to delete a policy that is referenced by one or more services. + +```json +{ + "error": { + "code": "policy_in_use", + "message": "Policy is referenced by one or more services" + } +} +``` + +### Booking-owned allocation + +Trying to delete an allocation that belongs to a booking. Use the booking cancel endpoint instead. + +```json +{ + "error": { + "code": "booking_owned_allocation", + "message": "Allocation belongs to a booking" + } +} +``` + +### Idempotency mismatch + +Same `Idempotency-Key` header but different request body. + +```json +{ + "error": { + "code": "IDEMPOTENCY_MISMATCH", + "message": "Idempotency key already used with different parameters" } } ``` @@ -86,7 +164,7 @@ Unexpected failure. Retry with backoff and the same `Idempotency-Key` header whe ## Agent handling pattern (recommended) -- `200`/`201` → proceed -- `409` → pick a new time slot or ask the user -- `400` → fix your payload (bug) -- `5xx` → retry with backoff + idempotency +- `200`/`201` - proceed +- `409` - pick a new time slot, adjust parameters, or ask the user +- `422` - fix your payload (bug) +- `5xx` - retry with backoff + idempotency diff --git a/docs/idempotency.md b/docs/idempotency.md index 834c0f9..9cab1be 100644 --- a/docs/idempotency.md +++ b/docs/idempotency.md @@ -2,7 +2,7 @@ Agents retry. Networks fail. Timeouts happen. -Idempotency makes `POST /allocations` safe to retry without creating duplicates or confusing conflicts. +Idempotency makes mutating POST endpoints safe to retry without creating duplicates or confusing conflicts. ## How it works @@ -10,7 +10,16 @@ On create, you can provide: - `Idempotency-Key` header (string, optional) -The key is scoped to your request. If you send the same key with the same significant fields (`resourceId`, `startAt`, `endAt`, `status`, `expiresAt`), you get the cached response. +The key is scoped to your request. If you send the same key with the same significant fields, you get the cached response. + +## Supported endpoints + +| Endpoint | Significant fields | +| ---------------------------- | ----------------------------------------------------------- | +| `POST /allocations` | `resourceId`, `startTime`, `endTime`, `expiresAt` | +| `POST /bookings` | `serviceId`, `resourceId`, `startTime`, `endTime`, `status` | +| `POST /bookings/:id/confirm` | (none — scoped to booking ID) | +| `POST /bookings/:id/cancel` | (none — scoped to booking ID) | ## Behavior @@ -29,18 +38,19 @@ If you reuse the same `Idempotency-Key` but change significant fields: - Floyd returns **409 Conflict** with code `IDEMPOTENCY_MISMATCH` -This prevents a retry from accidentally "turning into" a different allocation. +This prevents a retry from accidentally "turning into" a different booking. ## Usage ```bash -curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: unique-request-id-123" \ -d '{ - "resourceId": "res_01abc123", - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T10:30:00Z" + "serviceId": "svc_01abc123...", + "resourceId": "rsc_01abc123...", + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T10:30:00Z" }' ``` @@ -57,11 +67,11 @@ Examples: - `conversation::turn:` - `workflow::step:book` -## Idempotency on confirm/cancel +## Built-in idempotency on confirm/cancel -The `confirm` and `cancel` endpoints are also idempotent by default: +The `confirm` and `cancel` booking endpoints are idempotent by default: -- Confirming an already confirmed allocation returns the confirmed allocation -- Cancelling an already cancelled allocation returns the cancelled allocation +- Confirming an already confirmed booking returns the confirmed booking +- Canceling an already canceled booking returns the canceled booking You can still use `Idempotency-Key` header for additional safety on these endpoints. diff --git a/docs/introduction.md b/docs/introduction.md index 39d9f23..e45472c 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -2,7 +2,7 @@ > **Dev Preview** - Floyd Engine is in active development. APIs may change. Not recommended for production use yet. -Floyd Engine is a transaction layer for scheduling. +Floyd Engine is a booking engine for AI agents. It helps agent workflows safely book time slots under real-world conditions like: @@ -10,10 +10,10 @@ It helps agent workflows safely book time slots under real-world conditions like - retries/timeouts - high concurrency -Floyd's core primitive is a **two-phase booking**: +Floyd's core flow is a **two-phase booking**: -1. **Create a hold** (`hold`) with an expiration time -2. **Confirm** it (`confirmed`) when the user says "yes" +1. **Create a hold** on a service — reserves the slot with an expiration +2. **Confirm** when the user says "yes" This makes double-booking **impossible by construction**, because overlap rules are enforced at the database layer. @@ -29,17 +29,21 @@ See the [Quickstart](./quickstart) for full setup instructions. ## Key ideas -- **Hold**: temporarily reserves a time slot with `expiresAt` -- **Confirm**: commits the hold and clears expiry -- **Idempotency**: retry-safe create requests using `Idempotency-Key` header -- **409 Conflict**: means "the slot is not available" or "idempotency mismatch" +- **Service**: groups resources with a policy (e.g., "Yoga Class" using Room A with weekday-hours rules) +- **Booking**: a policy-evaluated reservation with lifecycle (hold → confirm → cancel/expire) +- **Allocation**: a time block on a resource. Bookings create allocations automatically; raw allocations exist for ad-hoc blocking. +- **Policy**: scheduling rules (working hours, duration limits, grid alignment, buffers) +- **Idempotency**: retry-safe requests using `Idempotency-Key` header +- **409 Conflict**: means "the slot is not available", "policy rejected", or "idempotency mismatch" ## Where to go next - [Quickstart](./quickstart) - Get running in 5 minutes -- [Allocations](./allocations) - The booking model -- [Availability](./availability) - Query free/busy timelines +- [Services](./services) - Grouping resources with policies +- [Bookings](./bookings) - The reservation lifecycle +- [Allocations](./allocations) - Raw time blocks +- [Policies](./policies) - Scheduling rules +- [Availability](./availability) - Slots, windows, and free/busy timelines - [Webhooks](./webhooks) - Real-time notifications - [Idempotency](./idempotency) - Safe retries - [Errors](./errors) - Error handling -- API Reference (see the sidebar) diff --git a/docs/policies.md b/docs/policies.md new file mode 100644 index 0000000..5e49497 --- /dev/null +++ b/docs/policies.md @@ -0,0 +1,247 @@ +# Policies + +Policies define scheduling rules for a ledger — working hours, duration constraints, grid alignment, lead times, and buffers. When a policy is attached to a ledger, booking requests are evaluated against its rules before conflict detection. + +## How it works + +A policy has a **default** stance (`open` or `closed`) and an ordered list of **rules**. Each rule matches specific days or dates and can override the default with time windows and config overrides. + +When evaluating a booking request, Floyd: + +1. Checks for blackout dates (closed rules on any overlapped day) +2. Finds the first matching rule for the start date +3. Determines if the time is within allowed windows +4. Merges the rule's config with the base config +5. Validates duration, grid alignment, lead time, and horizon +6. Computes buffer times + +## Creating a policy + +```bash +curl -X POST https://api.floyd.run/v1/ledgers/{ledgerId}/policies \ + -H "Content-Type: application/json" \ + -d '{ + "config": { + "schema_version": 1, + "default": "closed", + "config": { + "duration": { + "min_minutes": 30, + "max_minutes": 120, + "allowed_minutes": [30, 60, 90, 120] + }, + "grid": { + "interval_minutes": 30 + }, + "lead_time": { + "min_hours": 1, + "max_days": 30 + }, + "buffers": { + "before_minutes": 5, + "after_minutes": 10 + } + }, + "rules": [ + { + "match": { "type": "weekly", "days": ["weekdays"] }, + "windows": [ + { "start": "09:00", "end": "17:00" } + ] + }, + { + "match": { "type": "date", "date": "2026-12-25" }, + "closed": true + } + ] + } + }' +``` + +## Config format + +Policies use an **authoring format** with friendly units. Floyd normalizes everything to milliseconds for storage. + +| Authoring field | Canonical field | Conversion | +| --------------- | --------------- | ------------ | +| `*_minutes` | `*_ms` | × 60,000 | +| `*_hours` | `*_ms` | × 3,600,000 | +| `*_days` | `*_ms` | × 86,400,000 | + +If both `*_ms` and a friendly unit are provided, `*_ms` takes precedence. + +### Duration + +Controls allowed booking lengths. + +| Field | Description | +| ------------ | ---------------------------------------- | +| `min_ms` | Minimum duration in milliseconds | +| `max_ms` | Maximum duration in milliseconds | +| `allowed_ms` | Exact allowed durations (takes priority) | + +### Grid + +Constrains start times to fixed intervals. + +| Field | Description | +| ------------- | ------------------------------------- | +| `interval_ms` | Start times must be multiples of this | + +### Lead time + +Controls how far in advance bookings can be made. + +| Field | Description | +| -------- | -------------------------------------- | +| `min_ms` | Minimum lead time before booking start | +| `max_ms` | Maximum lead time before booking start | + +### Buffers + +Adds padding around allocations for setup/cleanup time. + +| Field | Description | +| ----------- | ----------------------------------- | +| `before_ms` | Buffer before the allocation starts | +| `after_ms` | Buffer after the allocation ends | + +## Rules + +Rules are evaluated in order — **first match wins**. Each rule has a `match` condition and either marks the day as `closed` or provides time windows and config overrides. + +### Match types + +**Weekly** — matches by day of week: + +```json +{ "type": "weekly", "days": ["monday", "wednesday", "friday"] } +``` + +Day shorthands are supported: `weekdays` (Mon-Fri), `weekends` (Sat-Sun), `everyday` (all days). + +**Date** — matches a specific date: + +```json +{ "type": "date", "date": "2026-12-25" } +``` + +**Date range** — matches a range of dates: + +```json +{ "type": "date_range", "from": "2026-12-23", "to": "2026-12-31" } +``` + +Optionally filter by day within the range: + +```json +{ "type": "date_range", "from": "2026-01-01", "to": "2026-06-30", "days": ["saturday"] } +``` + +### Closed rules + +Mark dates as completely unavailable: + +```json +{ "match": { "type": "date", "date": "2026-12-25" }, "closed": true } +``` + +Closed rules cannot have `windows` or `config`. + +### Windows + +Define open time ranges for a rule: + +```json +{ + "match": { "type": "weekly", "days": ["weekdays"] }, + "windows": [ + { "start": "09:00", "end": "12:00" }, + { "start": "13:00", "end": "17:00" } + ] +} +``` + +### Config overrides + +Rules can override the base config at the section level: + +```json +{ + "match": { "type": "weekly", "days": ["saturday"] }, + "windows": [{ "start": "10:00", "end": "14:00" }], + "config": { + "duration": { "max_minutes": 60 } + } +} +``` + +When a rule matches, its config sections **replace** (not merge) the corresponding base config sections. + +## Default behavior + +- `"default": "open"` — all times are bookable unless restricted by rules +- `"default": "closed"` — nothing is bookable unless opened by rule windows + +## Config hashing + +Each policy config is canonicalized and hashed (SHA-256). The `configHash` field in the response can be used to detect duplicate configurations. + +## Common patterns + +### Salon (weekday business hours) + +```json +{ + "schema_version": 1, + "default": "closed", + "config": { + "duration": { "allowed_minutes": [30, 60, 90] }, + "grid": { "interval_minutes": 30 }, + "buffers": { "after_minutes": 10 } + }, + "rules": [ + { + "match": { "type": "weekly", "days": ["weekdays"] }, + "windows": [{ "start": "09:00", "end": "18:00" }] + } + ] +} +``` + +### Doctor (weekdays with lunch break) + +```json +{ + "schema_version": 1, + "default": "closed", + "config": { + "duration": { "allowed_minutes": [15, 30] }, + "grid": { "interval_minutes": 15 }, + "lead_time": { "min_hours": 2 } + }, + "rules": [ + { + "match": { "type": "weekly", "days": ["weekdays"] }, + "windows": [ + { "start": "08:00", "end": "12:00" }, + { "start": "13:00", "end": "17:00" } + ] + } + ] +} +``` + +### 24/7 (always open with constraints) + +```json +{ + "schema_version": 1, + "default": "open", + "config": { + "duration": { "min_minutes": 60, "max_hours": 4 }, + "grid": { "interval_minutes": 60 } + }, + "rules": [] +} +``` diff --git a/docs/quickstart.md b/docs/quickstart.md index fa3d014..ab0029f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -82,9 +82,9 @@ curl -H "Authorization: Bearer your-secret-key" \ If `FLOYD_API_KEY` is not set, authentication is disabled (useful for local development). -## 0) Create a ledger (if needed) +## 0) Create a ledger -Ledgers are containers for your resources and allocations. +Ledgers are containers for your resources, services, and bookings. ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers" \ @@ -105,12 +105,12 @@ Response: ## 1) Create a resource -Resources represent bookable entities (rooms, people, services, etc.). You need at least one resource before creating allocations. +Resources represent bookable entities (rooms, people, equipment, etc.). ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/resources" \ -H "Content-Type: application/json" \ - -d '{}' + -d '{ "timezone": "America/New_York" }' ``` Response: @@ -120,23 +120,54 @@ Response: "data": { "id": "rsc_01abc123def456ghi789jkl012", "ledgerId": "ldg_01abc123def456ghi789jkl012", + "timezone": "America/New_York", "createdAt": "2026-01-04T10:00:00.000Z", "updatedAt": "2026-01-04T10:00:00.000Z" } } ``` -## 2) Create a hold +## 2) Create a service + +A service groups resources and optionally attaches a policy. + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Haircut", + "resourceIds": ["rsc_01abc123def456ghi789jkl012"] + }' +``` + +Response: + +```json +{ + "data": { + "id": "svc_01abc123def456ghi789jkl012", + "ledgerId": "ldg_01abc123def456ghi789jkl012", + "name": "Haircut", + "policyId": null, + "resourceIds": ["rsc_01abc123def456ghi789jkl012"], + "metadata": null, + "createdAt": "2026-01-04T10:00:00.000Z", + "updatedAt": "2026-01-04T10:00:00.000Z" + } +} +``` + +## 3) Create a hold ```bash -curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: demo-001" \ -d '{ + "serviceId": "svc_01abc123def456ghi789jkl012", "resourceId": "rsc_01abc123def456ghi789jkl012", - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T10:30:00Z", - "expiresAt": "2026-01-04T10:05:00Z", + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T10:30:00Z", "metadata": { "source": "quickstart" } }' ``` @@ -146,13 +177,20 @@ Response: ```json { "data": { - "id": "alc_01abc123def456ghi789jkl012", + "id": "bkg_01abc123def456ghi789jkl012", "ledgerId": "ldg_01abc123def456ghi789jkl012", - "resourceId": "rsc_01abc123def456ghi789jkl012", - "startAt": "2026-01-04T10:00:00.000Z", - "endAt": "2026-01-04T10:30:00.000Z", + "serviceId": "svc_01abc123def456ghi789jkl012", "status": "hold", - "expiresAt": "2026-01-04T10:05:00.000Z", + "expiresAt": "2026-01-04T10:15:00.000Z", + "allocations": [ + { + "id": "alc_01abc123def456ghi789jkl012", + "resourceId": "rsc_01abc123def456ghi789jkl012", + "startTime": "2026-01-04T10:00:00.000Z", + "endTime": "2026-01-04T10:30:00.000Z", + "active": true + } + ], "metadata": { "source": "quickstart" }, "createdAt": "2026-01-04T10:00:00.000Z", "updatedAt": "2026-01-04T10:00:00.000Z" @@ -165,78 +203,56 @@ Response: Error responses: -- `409 Conflict` if someone already holds/confirmed that slot -- `400 Bad Request` if your input is invalid +- `409 Conflict` if the slot overlaps with an existing active allocation +- `409 Conflict` if the service's policy rejects the request +- `422 Unprocessable Entity` if your input is invalid -## 3) Confirm the hold +## 4) Confirm the booking ```bash -curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations/$ALLOCATION_ID/confirm" \ +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/confirm" \ -H "Content-Type: application/json" ``` Expected: -- `200 OK` with status `confirmed` +- `200 OK` with status `confirmed` and `expiresAt: null` - `409 Conflict` if the hold expired before you confirmed -- Safe to retry (confirming an already confirmed allocation returns the confirmed allocation) +- Safe to retry (confirming an already confirmed booking returns the confirmed booking) -## 4) Cancel an allocation +## 5) Cancel a booking ```bash -curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations/$ALLOCATION_ID/cancel" \ +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/cancel" \ -H "Content-Type: application/json" ``` Expected: -- `200 OK` with status `cancelled` -- Safe to retry (cancelling an already cancelled/expired allocation returns the allocation) +- `200 OK` with status `canceled` and allocations deactivated (`active: false`) +- Safe to retry (canceling an already canceled booking returns the booking) -## 5) Get an allocation +## 6) Get a booking ```bash -curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations/$ALLOCATION_ID" \ - -H "Content-Type: application/json" +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID" ``` -Expected: - -- `200 OK` with the allocation object -- `404 Not Found` if the allocation doesn't exist +Returns the booking with nested allocations. -## 6) List allocations +## 7) List bookings ```bash -curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ - -H "Content-Type: application/json" -``` - -Response: - -```json -{ - "data": [ - { - "id": "alc_01abc123def456ghi789jkl012", - "ledgerId": "ldg_01abc123def456ghi789jkl012", - "resourceId": "rsc_01abc123def456ghi789jkl012", - "status": "confirmed", - "startAt": "2026-01-04T10:00:00.000Z", - "endAt": "2026-01-04T10:30:00.000Z", - "expiresAt": null, - "metadata": { "source": "quickstart" }, - "createdAt": "2026-01-04T10:00:00.000Z", - "updatedAt": "2026-01-04T10:00:00.000Z" - } - ] -} +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" ``` ## Next -- [Allocations](./allocations.md) - Deep dive into the booking model -- [Availability](./availability.md) - Query free/busy timelines +- [Services](./services.md) - Grouping resources with policies +- [Bookings](./bookings.md) - The reservation lifecycle +- [Allocations](./allocations.md) - Raw time blocks and ad-hoc blocking +- [Availability](./availability.md) - Slots, windows, and free/busy timelines +- [Policies](./policies.md) - Scheduling rules - [Idempotency](./idempotency.md) - Safe retries - [Webhooks](./webhooks.md) - Real-time notifications - [Errors](./errors.md) - Error handling diff --git a/docs/services.md b/docs/services.md new file mode 100644 index 0000000..f456f0e --- /dev/null +++ b/docs/services.md @@ -0,0 +1,149 @@ +# Services + +A service represents a bookable offering. It groups resources and optionally attaches a policy to enforce scheduling rules. + +## Creating a service + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Yoga Class", + "policyId": "pol_01abc123...", + "resourceIds": ["rsc_01abc123...", "rsc_01def456..."], + "metadata": { "category": "wellness" } + }' +``` + +Response: + +```json +{ + "data": { + "id": "svc_01abc123...", + "ledgerId": "ldg_01xyz789...", + "name": "Yoga Class", + "policyId": "pol_01abc123...", + "resourceIds": ["rsc_01abc123...", "rsc_01def456..."], + "metadata": { "category": "wellness" }, + "createdAt": "2026-01-04T10:00:00.000Z", + "updatedAt": "2026-01-04T10:00:00.000Z" + } +} +``` + +### Fields + +| Field | Type | Required | Description | +| ------------- | -------- | -------- | ----------------------------------------------------------- | +| `name` | string | Yes | Display name (1-255 characters) | +| `policyId` | string | No | Policy to enforce on bookings. `null` means no constraints. | +| `resourceIds` | string[] | No | Resources available for this service. Defaults to `[]`. | +| `metadata` | object | No | Arbitrary key-value data | + +All resources and the policy (if provided) must belong to the same ledger. + +## Updating a service + +Updates use **full replace** semantics (PUT). All fields are replaced, including `resourceIds`. + +```bash +curl -X PUT "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Advanced Yoga", + "policyId": null, + "resourceIds": ["rsc_01def456..."], + "metadata": null + }' +``` + +To add or remove resources, send the full desired list. There is no PATCH — send all fields on every update. + +## Deleting a service + +```bash +curl -X DELETE "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID" +``` + +- Returns `204 No Content` on success +- Returns `409 Conflict` with code `service.active_bookings` if the service has bookings in `hold` or `confirmed` status +- Canceled and expired bookings do not block deletion + +## Getting a service + +```bash +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID" +``` + +## Listing services + +```bash +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services" +``` + +## How services connect to bookings + +When a booking is created against a service: + +1. The resource must belong to the service +2. If the service has a policy, the booking is evaluated against its rules +3. The policy uses the resource's timezone for time-of-day rules + +A resource can belong to multiple services. For example, "Room A" could be used by both "Yoga Class" and "Meeting Rental" services, each with different policies. + +## Querying availability + +Once a service is set up, query when it's bookable: + +- **[Slots](./availability#slots)** — grid-aligned positions for appointment-style booking (`POST /services/:id/availability/slots`) +- **[Windows](./availability#windows)** — continuous available ranges for rental-style booking (`POST /services/:id/availability/windows`) + +Both endpoints respect the service's policy (working hours, grid, buffers, lead time) and return per-resource results. + +## Example: salon setup + +```bash +# Create a policy with weekday hours +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/policies" \ + -H "Content-Type: application/json" \ + -d '{ + "config": { + "schema_version": 1, + "default": "closed", + "config": { + "duration": { "allowed_minutes": [30, 60, 90] }, + "grid": { "interval_minutes": 30 }, + "buffers": { "after_minutes": 10 } + }, + "rules": [ + { + "match": { "type": "weekly", "days": ["weekdays"] }, + "windows": [{ "start": "09:00", "end": "18:00" }] + } + ] + } + }' + +# Create resources for two stylists +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/resources" \ + -H "Content-Type: application/json" \ + -d '{ "timezone": "America/New_York" }' +# → rsc_stylist1 + +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/resources" \ + -H "Content-Type: application/json" \ + -d '{ "timezone": "America/New_York" }' +# → rsc_stylist2 + +# Create a service grouping both stylists with the policy +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Haircut", + "policyId": "pol_...", + "resourceIds": ["rsc_stylist1", "rsc_stylist2"] + }' +``` + +Now bookings against the "Haircut" service will be validated against weekday 9-18 hours in New York time, with 30-minute grid slots and 10-minute cleanup buffers. diff --git a/docs/time-format.md b/docs/time-format.md index b37a3b6..56f86dd 100644 --- a/docs/time-format.md +++ b/docs/time-format.md @@ -4,7 +4,7 @@ Floyd Engine stores timestamps in UTC and expects ISO 8601 inputs. ## Required format -Send `startAt` and `endAt` as ISO 8601 timestamps with either: +Send `startTime` and `endTime` as ISO 8601 timestamps with either: - `Z` (UTC), e.g. `2026-01-04T10:00:00Z` - an explicit offset, e.g. `2026-01-04T10:00:00+03:00` @@ -18,5 +18,5 @@ Avoid “naive” timestamps (no timezone), e.g. `2026-01-04T10:00:00`. ## Interval rules -- `endAt` must be strictly greater than `startAt` -- intervals use `[startAt, endAt)` semantics (back-to-back allowed) +- `endTime` must be strictly greater than `startTime` +- intervals use `[startTime, endTime)` semantics (back-to-back allowed) diff --git a/docs/webhooks.md b/docs/webhooks.md index 5a957ab..1dc4926 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -1,36 +1,81 @@ # Webhooks -Floyd sends webhook notifications when allocation events occur. Subscribe to receive real-time updates about your allocations. +Floyd sends webhook notifications when booking and allocation events occur. Subscribe to receive real-time updates. ## Events -| Event | Description | -| ---------------------- | ---------------------------- | -| `allocation.created` | A new allocation was created | -| `allocation.confirmed` | A hold was confirmed | -| `allocation.cancelled` | An allocation was cancelled | -| `allocation.expired` | A hold expired | +### 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 | + +### Allocation events + +| Event | Description | +| -------------------- | ---------------------------- | +| `allocation.created` | A raw allocation was created | +| `allocation.deleted` | A raw allocation was deleted | ## Payload format +### Booking event payload + +```json +{ + "id": "whd_01abc123...", + "type": "booking.created", + "ledgerId": "ldg_01xyz789...", + "createdAt": "2026-01-15T10:00:00Z", + "data": { + "booking": { + "id": "bkg_01def456...", + "ledgerId": "ldg_01xyz789...", + "serviceId": "svc_01ghi789...", + "status": "hold", + "expiresAt": "2026-01-15T10:15:00Z", + "allocations": [ + { + "id": "alc_01jkl012...", + "resourceId": "rsc_01mno345...", + "startTime": "2026-01-15T14:00:00Z", + "endTime": "2026-01-15T15:00:00Z", + "active": true + } + ], + "metadata": null, + "createdAt": "2026-01-15T10:00:00Z", + "updatedAt": "2026-01-15T10:00:00Z" + } + } +} +``` + +### Allocation event payload + ```json { "id": "whd_01abc123...", "type": "allocation.created", "ledgerId": "ldg_01xyz789...", - "createdAt": "2024-01-15T10:00:00Z", + "createdAt": "2026-01-15T10:00:00Z", "data": { "allocation": { "id": "alc_01def456...", "ledgerId": "ldg_01xyz789...", "resourceId": "rsc_01ghi789...", - "status": "hold", - "startAt": "2024-01-15T14:00:00Z", - "endAt": "2024-01-15T15:00:00Z", - "expiresAt": "2024-01-15T10:15:00Z", + "bookingId": null, + "active": true, + "startTime": "2026-01-15T14:00:00Z", + "endTime": "2026-01-15T15:00:00Z", + "buffer": { "beforeMs": 0, "afterMs": 0 }, + "expiresAt": null, "metadata": null, - "createdAt": "2024-01-15T10:00:00Z", - "updatedAt": "2024-01-15T10:00:00Z" + "createdAt": "2026-01-15T10:00:00Z", + "updatedAt": "2026-01-15T10:00:00Z" } } } @@ -64,9 +109,8 @@ app.post("/webhook", (req, res) => { return res.status(401).send("Invalid signature"); } - // Process the event const event = req.body; - console.log(`Received ${event.type} for allocation ${event.data.allocation.id}`); + console.log(`Received ${event.type}`); res.status(200).send("OK"); }); diff --git a/packages/schema/constants/index.ts b/packages/schema/constants/index.ts index ffee231..288b436 100644 --- a/packages/schema/constants/index.ts +++ b/packages/schema/constants/index.ts @@ -1,10 +1,3 @@ -export const AllocationStatus = { - HOLD: "hold", - CONFIRMED: "confirmed", - CANCELLED: "cancelled", - EXPIRED: "expired", -} as const; - export const IdempotencyStatus = { IN_PROGRESS: "in_progress", COMPLETED: "completed", @@ -17,3 +10,15 @@ export const WebhookDeliveryStatus = { FAILED: "failed", EXHAUSTED: "exhausted", } as const; + +export const BookingStatus = { + HOLD: "hold", + CONFIRMED: "confirmed", + CANCELED: "canceled", + EXPIRED: "expired", +} as const; + +export const ScheduleDefault = { + OPEN: "open", + CLOSED: "closed", +} as const; diff --git a/packages/schema/inputs/allocation.ts b/packages/schema/inputs/allocation.ts index e08a91b..3dddbd4 100644 --- a/packages/schema/inputs/allocation.ts +++ b/packages/schema/inputs/allocation.ts @@ -1,35 +1,30 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -import { AllocationStatus } from "../constants"; -export const createSchema = z.object({ - ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - resourceId: z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), - status: z - .enum([AllocationStatus.HOLD, AllocationStatus.CONFIRMED]) - .default(AllocationStatus.HOLD), - startAt: z.coerce.date(), - endAt: z.coerce.date(), - expiresAt: z.coerce.date().nullable().optional(), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), -}); +export const create = z + .object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + resourceId: z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), + startTime: z.coerce.date(), + endTime: z.coerce.date(), + expiresAt: z.coerce.date().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .refine((data) => data.endTime > data.startTime, { + message: "endTime must be after startTime", + path: ["endTime"], + }); -export const getSchema = z.object({ +export const get = z.object({ id: z.string().refine((id) => isValidId(id, "alc"), { message: "Invalid allocation ID" }), -}); - -export const listSchema = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); -export const removeSchema = z.object({ - id: z.string().refine((id) => isValidId(id, "alc"), { message: "Invalid allocation ID" }), -}); - -export const confirmSchema = z.object({ - id: z.string().refine((id) => isValidId(id, "alc"), { message: "Invalid allocation ID" }), +export const list = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); -export const cancelSchema = z.object({ +export const remove = z.object({ id: z.string().refine((id) => isValidId(id, "alc"), { message: "Invalid allocation ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); diff --git a/packages/schema/inputs/availability.ts b/packages/schema/inputs/availability.ts index 0cb0b34..975feaa 100644 --- a/packages/schema/inputs/availability.ts +++ b/packages/schema/inputs/availability.ts @@ -1,11 +1,38 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -export const querySchema = z.object({ +export const query = z + .object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + resourceIds: z.array( + z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), + ), + startTime: z.coerce.date(), + endTime: z.coerce.date(), + }) + .refine((data) => data.endTime > data.startTime, { + message: "endTime must be after startTime", + path: ["endTime"], + }); + +// ─── Service Availability ──────────────────────────────────────────────────── + +const serviceAvailabilityBase = { ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - resourceIds: z.array( - z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), - ), - startAt: z.coerce.date(), - endAt: z.coerce.date(), + serviceId: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service ID" }), + startTime: z.coerce.date(), + endTime: z.coerce.date(), + resourceIds: z + .array(z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" })) + .optional(), + includeUnavailable: z.boolean().default(false), +}; + +export const slots = z.object({ + ...serviceAvailabilityBase, + durationMs: z.number().int().positive(), +}); + +export const windows = z.object({ + ...serviceAvailabilityBase, }); diff --git a/packages/schema/inputs/booking.ts b/packages/schema/inputs/booking.ts new file mode 100644 index 0000000..d88ef15 --- /dev/null +++ b/packages/schema/inputs/booking.ts @@ -0,0 +1,37 @@ +import z from "zod"; +import { isValidId } from "@floyd-run/utils"; +import { BookingStatus } from "../constants"; + +export const create = z + .object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + serviceId: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service ID" }), + resourceId: z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), + startTime: z.coerce.date(), + endTime: z.coerce.date(), + status: z.enum([BookingStatus.HOLD, BookingStatus.CONFIRMED]).default(BookingStatus.HOLD), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .refine((data) => data.endTime > data.startTime, { + message: "endTime must be after startTime", + path: ["endTime"], + }); + +export const get = 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 list = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); + +export const confirm = 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 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" }), +}); diff --git a/packages/schema/inputs/index.ts b/packages/schema/inputs/index.ts index 20fe92e..72e8568 100644 --- a/packages/schema/inputs/index.ts +++ b/packages/schema/inputs/index.ts @@ -1,5 +1,8 @@ -export * as allocation from "./allocation"; -export * as availability from "./availability"; -export * as resource from "./resource"; -export * as ledger from "./ledger"; -export * as webhook from "./webhook"; +export * as allocationInput from "./allocation"; +export * as availabilityInput from "./availability"; +export * as resourceInput from "./resource"; +export * as ledgerInput from "./ledger"; +export * as webhookInput from "./webhook"; +export * as policyInput from "./policy"; +export * as serviceInput from "./service"; +export * as bookingInput from "./booking"; diff --git a/packages/schema/inputs/ledger.ts b/packages/schema/inputs/ledger.ts index 1ed4a8e..f1921f3 100644 --- a/packages/schema/inputs/ledger.ts +++ b/packages/schema/inputs/ledger.ts @@ -1,6 +1,6 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -export const getSchema = z.object({ +export const get = z.object({ id: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts new file mode 100644 index 0000000..1797a2a --- /dev/null +++ b/packages/schema/inputs/policy.ts @@ -0,0 +1,188 @@ +import z from "zod"; +import { isValidId } from "@floyd-run/utils"; +import { ScheduleDefault } from "../constants"; + +// Time string: HH:MM (00:00-23:59) or 24:00 for end-of-day +const timeStringSchema = z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d$|^24:00$/, "Invalid time format (HH:MM or 24:00)"); + +// Convert time string to minutes since midnight +function timeToMinutes(time: string): number { + if (time === "24:00") return 1440; + const [h, m] = time.split(":").map(Number); + return h! * 60 + m!; +} + +// Time window with validation +const timeWindowSchema = z + .object({ + start: timeStringSchema, + end: timeStringSchema, + }) + .refine((w) => w.start !== "24:00", { message: "start cannot be 24:00" }) + .refine((w) => timeToMinutes(w.end) > timeToMinutes(w.start), { + message: "end must be after start", + }); + +// Day names +const dayNames = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +] as const; +type DayName = (typeof dayNames)[number]; + +const dayShorthands = ["weekdays", "weekends", "everyday"] as const; +const dayOrShorthand = z.enum([...dayNames, ...dayShorthands]); + +// Duration section (authoring: accepts friendly units) +const durationAuthoringSchema = z + .object({ + min_ms: z.number().int().positive().optional(), + max_ms: z.number().int().positive().optional(), + allowed_ms: z.array(z.number().int().positive()).nonempty().optional(), + min_minutes: z.number().positive().optional(), + max_minutes: z.number().positive().optional(), + allowed_minutes: z.array(z.number().positive()).nonempty().optional(), + min_hours: z.number().positive().optional(), + max_hours: z.number().positive().optional(), + min_days: z.number().positive().optional(), + max_days: z.number().positive().optional(), + }) + .passthrough(); + +// Grid section (authoring) +const gridAuthoringSchema = z + .object({ + interval_ms: z.number().int().positive().optional(), + interval_minutes: z.number().positive().optional(), + }) + .passthrough(); + +// Lead time section (authoring) +const leadTimeAuthoringSchema = z + .object({ + min_ms: z.number().int().nonnegative().optional(), + max_ms: z.number().int().nonnegative().optional(), + min_minutes: z.number().nonnegative().optional(), + max_minutes: z.number().nonnegative().optional(), + min_hours: z.number().nonnegative().optional(), + max_hours: z.number().nonnegative().optional(), + min_days: z.number().nonnegative().optional(), + max_days: z.number().nonnegative().optional(), + }) + .passthrough(); + +// Buffers section (authoring) +const buffersAuthoringSchema = z + .object({ + before_ms: z.number().int().nonnegative().optional(), + after_ms: z.number().int().nonnegative().optional(), + before_minutes: z.number().nonnegative().optional(), + after_minutes: z.number().nonnegative().optional(), + }) + .passthrough(); + +// Hold section (authoring) +const holdAuthoringSchema = z + .object({ + duration_ms: z.number().int().positive().optional(), + duration_minutes: z.number().positive().optional(), + }) + .passthrough(); + +// Config section (authoring) +const configAuthoringSchema = z + .object({ + duration: durationAuthoringSchema.optional(), + grid: gridAuthoringSchema.optional(), + lead_time: leadTimeAuthoringSchema.optional(), + buffers: buffersAuthoringSchema.optional(), + hold: holdAuthoringSchema.optional(), + }) + .passthrough(); + +// Match conditions +const weeklyMatchSchema = z.object({ + type: z.literal("weekly"), + days: z.array(dayOrShorthand).nonempty(), +}); + +const dateMatchSchema = z.object({ + type: z.literal("date"), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"), +}); + +const dateRangeMatchSchema = z + .object({ + type: z.literal("date_range"), + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"), + days: z.array(dayOrShorthand).nonempty().optional(), + }) + .refine((m) => m.from <= m.to, { message: "from must be <= to" }); + +const matchSchema = z.discriminatedUnion("type", [ + weeklyMatchSchema, + dateMatchSchema, + dateRangeMatchSchema, +]); + +// Rule schema (authoring) +const ruleSchema = z + .object({ + match: matchSchema, + closed: z.literal(true).optional(), + windows: z.array(timeWindowSchema).nonempty().optional(), + config: configAuthoringSchema.optional(), + }) + .refine( + (rule) => { + if (rule.closed === true) { + return rule.windows === undefined && rule.config === undefined; + } + return true; + }, + { message: "closed rule must not have windows or config" }, + ); + +// Full policy config schema (authoring format) +const policyConfigSchema = z + .object({ + schema_version: z.literal(1), + default: z.enum([ScheduleDefault.OPEN, ScheduleDefault.CLOSED]), + config: configAuthoringSchema, + rules: z.array(ruleSchema).default([]), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + +export const create = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + config: policyConfigSchema, +}); + +export const update = z.object({ + id: z.string().refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + config: policyConfigSchema, +}); + +export const get = z.object({ + id: z.string().refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); + +export const list = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); + +export const remove = z.object({ + id: z.string().refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); diff --git a/packages/schema/inputs/resource.ts b/packages/schema/inputs/resource.ts index 864de5d..ba59efd 100644 --- a/packages/schema/inputs/resource.ts +++ b/packages/schema/inputs/resource.ts @@ -1,18 +1,34 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -export const createSchema = z.object({ +export const create = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + timezone: z + .string() + .max(64) + .refine( + (tz) => { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } + }, + { message: "Invalid IANA timezone" }, + ), }); -export const getSchema = z.object({ +export const get = z.object({ id: z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); -export const listSchema = z.object({ +export const list = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); -export const removeSchema = z.object({ +export const remove = z.object({ id: z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); diff --git a/packages/schema/inputs/service.ts b/packages/schema/inputs/service.ts new file mode 100644 index 0000000..af3127c --- /dev/null +++ b/packages/schema/inputs/service.ts @@ -0,0 +1,45 @@ +import z from "zod"; +import { isValidId } from "@floyd-run/utils"; + +export const create = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + name: z.string().min(1).max(255), + policyId: z + .string() + .refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }) + .nullable() + .optional(), + resourceIds: z + .array(z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" })) + .default([]), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), +}); + +export const update = z.object({ + id: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + name: z.string().min(1).max(255), + policyId: z + .string() + .refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }) + .nullable() + .optional(), + resourceIds: z + .array(z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" })) + .default([]), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), +}); + +export const get = z.object({ + id: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); + +export const list = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); + +export const remove = z.object({ + id: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); diff --git a/packages/schema/inputs/webhook.ts b/packages/schema/inputs/webhook.ts index b1b7392..2ecfc4e 100644 --- a/packages/schema/inputs/webhook.ts +++ b/packages/schema/inputs/webhook.ts @@ -1,30 +1,33 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -export const createSubscriptionSchema = z.object({ +export const createSubscription = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), url: z.string().url(), }); -export const listSubscriptionsSchema = z.object({ +export const listSubscriptions = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); -export const updateSubscriptionSchema = z.object({ +export const updateSubscription = z.object({ id: z .string() .refine((id) => isValidId(id, "whs"), { message: "Invalid webhook subscription ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), url: z.string().url().optional(), }); -export const deleteSubscriptionSchema = z.object({ +export const deleteSubscription = z.object({ id: z .string() .refine((id) => isValidId(id, "whs"), { message: "Invalid webhook subscription ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); -export const rotateSecretSchema = z.object({ +export const rotateSecret = z.object({ id: z .string() .refine((id) => isValidId(id, "whs"), { message: "Invalid webhook subscription ID" }), + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); diff --git a/packages/schema/outputs/allocation.ts b/packages/schema/outputs/allocation.ts index 36b6325..b008167 100644 --- a/packages/schema/outputs/allocation.ts +++ b/packages/schema/outputs/allocation.ts @@ -1,21 +1,25 @@ import { z } from "./zod"; -import { AllocationStatus } from "../constants"; -export const schema = z.object({ +export const base = z.object({ id: z.string(), ledgerId: z.string(), resourceId: z.string(), - status: z.enum(AllocationStatus), - startAt: z.string(), - endAt: z.string(), + bookingId: z.string().nullable(), + active: z.boolean(), + startTime: z.string(), + endTime: z.string(), + buffer: z.object({ + beforeMs: z.number(), + afterMs: z.number(), + }), expiresAt: z.string().nullable(), metadata: z.record(z.string(), z.unknown()).nullable(), createdAt: z.string(), updatedAt: z.string(), }); -export const getSchema = z.object({ - data: schema, +export const get = z.object({ + data: base, meta: z .object({ serverTime: z.string(), @@ -23,6 +27,6 @@ export const getSchema = z.object({ .optional(), }); -export const listSchema = z.object({ - data: z.array(schema), +export const list = z.object({ + data: z.array(base), }); diff --git a/packages/schema/outputs/availability.ts b/packages/schema/outputs/availability.ts index 6d48228..a876325 100644 --- a/packages/schema/outputs/availability.ts +++ b/packages/schema/outputs/availability.ts @@ -1,16 +1,52 @@ import { z } from "./zod"; -export const timelineBlockSchema = z.object({ - startAt: z.string(), - endAt: z.string(), +export const timelineBlock = z.object({ + startTime: z.string(), + endTime: z.string(), status: z.enum(["free", "busy"]), }); -export const itemSchema = z.object({ +export const item = z.object({ resourceId: z.string(), - timeline: z.array(timelineBlockSchema), + timeline: z.array(timelineBlock), }); -export const querySchema = z.object({ - data: z.array(itemSchema), +export const query = z.object({ + data: z.array(item), +}); + +// ─── Service Availability ──────────────────────────────────────────────────── + +export const slot = z.object({ + startTime: z.string(), + endTime: z.string(), + status: z.enum(["available", "unavailable"]).optional(), +}); + +export const resourceSlots = z.object({ + resourceId: z.string(), + timezone: z.string(), + slots: z.array(slot), +}); + +export const slotsResponse = z.object({ + data: z.array(resourceSlots), + meta: z.object({ serverTime: z.string() }), +}); + +export const window = z.object({ + startTime: z.string(), + endTime: z.string(), + status: z.enum(["available", "unavailable"]).optional(), +}); + +export const resourceWindows = z.object({ + resourceId: z.string(), + timezone: z.string(), + windows: z.array(window), +}); + +export const windowsResponse = z.object({ + data: z.array(resourceWindows), + meta: z.object({ serverTime: z.string() }), }); diff --git a/packages/schema/outputs/booking.ts b/packages/schema/outputs/booking.ts new file mode 100644 index 0000000..110df44 --- /dev/null +++ b/packages/schema/outputs/booking.ts @@ -0,0 +1,45 @@ +import { z } from "./zod"; +import { BookingStatus } from "../constants"; + +const bookingAllocationSchema = z.object({ + id: z.string(), + resourceId: z.string(), + startTime: z.string(), + endTime: z.string(), + buffer: z.object({ + beforeMs: z.number(), + afterMs: z.number(), + }), + active: z.boolean(), +}); + +export const base = z.object({ + id: z.string(), + ledgerId: z.string(), + serviceId: z.string(), + policyId: z.string().nullable(), + status: z.enum([ + BookingStatus.HOLD, + BookingStatus.CONFIRMED, + BookingStatus.CANCELED, + BookingStatus.EXPIRED, + ]), + expiresAt: z.string().nullable(), + allocations: z.array(bookingAllocationSchema), + metadata: z.record(z.string(), z.unknown()).nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const get = z.object({ + data: base, + meta: z + .object({ + serverTime: z.string(), + }) + .optional(), +}); + +export const list = z.object({ + data: z.array(base), +}); diff --git a/packages/schema/outputs/error.ts b/packages/schema/outputs/error.ts index 8c36dd8..026919c 100644 --- a/packages/schema/outputs/error.ts +++ b/packages/schema/outputs/error.ts @@ -1,12 +1,10 @@ import { z } from "./zod"; export const schema = z.object({ - error: z.union([ - z.string(), - z.object({ - code: z.string(), - message: z.string().optional(), - details: z.record(z.string(), z.unknown()).optional(), - }), - ]), + error: z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + issues: z.array(z.unknown()).optional(), + }), }); diff --git a/packages/schema/outputs/index.ts b/packages/schema/outputs/index.ts index 59367f3..d13ddbb 100644 --- a/packages/schema/outputs/index.ts +++ b/packages/schema/outputs/index.ts @@ -4,3 +4,6 @@ export * as resource from "./resource"; export * as ledger from "./ledger"; export * as webhook from "./webhook"; export * as error from "./error"; +export * as policy from "./policy"; +export * as service from "./service"; +export * as booking from "./booking"; diff --git a/packages/schema/outputs/ledger.ts b/packages/schema/outputs/ledger.ts index 1b256ee..1564646 100644 --- a/packages/schema/outputs/ledger.ts +++ b/packages/schema/outputs/ledger.ts @@ -1,15 +1,15 @@ import { z } from "./zod"; -export const schema = z.object({ +export const base = z.object({ id: z.string(), createdAt: z.string(), updatedAt: z.string(), }); -export const getSchema = z.object({ - data: schema, +export const get = z.object({ + data: base, }); -export const listSchema = z.object({ - data: z.array(schema), +export const list = z.object({ + data: z.array(base), }); diff --git a/packages/schema/outputs/policy.ts b/packages/schema/outputs/policy.ts new file mode 100644 index 0000000..9d87f49 --- /dev/null +++ b/packages/schema/outputs/policy.ts @@ -0,0 +1,18 @@ +import { z } from "./zod"; + +export const base = z.object({ + id: z.string(), + ledgerId: z.string(), + config: z.record(z.string(), z.unknown()), + configHash: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const get = z.object({ + data: base, +}); + +export const list = z.object({ + data: z.array(base), +}); diff --git a/packages/schema/outputs/resource.ts b/packages/schema/outputs/resource.ts index 746a2c0..b9119d7 100644 --- a/packages/schema/outputs/resource.ts +++ b/packages/schema/outputs/resource.ts @@ -1,16 +1,17 @@ import { z } from "./zod"; -export const schema = z.object({ +export const base = z.object({ id: z.string(), ledgerId: z.string(), + timezone: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), }); -export const getSchema = z.object({ - data: schema, +export const get = z.object({ + data: base, }); -export const listSchema = z.object({ - data: z.array(schema), +export const list = z.object({ + data: z.array(base), }); diff --git a/packages/schema/outputs/service.ts b/packages/schema/outputs/service.ts new file mode 100644 index 0000000..eaf307a --- /dev/null +++ b/packages/schema/outputs/service.ts @@ -0,0 +1,20 @@ +import { z } from "./zod"; + +export const base = z.object({ + id: z.string(), + ledgerId: z.string(), + name: z.string(), + policyId: z.string().nullable(), + resourceIds: z.array(z.string()), + metadata: z.record(z.string(), z.unknown()).nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const get = z.object({ + data: base, +}); + +export const list = z.object({ + data: z.array(base), +}); diff --git a/packages/schema/outputs/webhook.ts b/packages/schema/outputs/webhook.ts index 9d67347..9330aaf 100644 --- a/packages/schema/outputs/webhook.ts +++ b/packages/schema/outputs/webhook.ts @@ -1,6 +1,6 @@ import { z } from "./zod"; -export const subscriptionSchema = z.object({ +export const subscription = z.object({ id: z.string(), ledgerId: z.string(), url: z.string(), @@ -8,22 +8,22 @@ export const subscriptionSchema = z.object({ updatedAt: z.string(), }); -export const subscriptionWithSecretSchema = subscriptionSchema.extend({ +export const subscriptionWithSecret = subscription.extend({ secret: z.string(), }); -export const createSubscriptionSchema = z.object({ - data: subscriptionWithSecretSchema, +export const createSubscription = z.object({ + data: subscriptionWithSecret, }); -export const listSubscriptionsSchema = z.object({ - data: z.array(subscriptionSchema), +export const listSubscriptions = z.object({ + data: z.array(subscription), }); -export const updateSubscriptionSchema = z.object({ - data: subscriptionSchema, +export const updateSubscription = z.object({ + data: subscription, }); -export const rotateSecretSchema = z.object({ - data: subscriptionWithSecretSchema, +export const rotateSecret = z.object({ + data: subscriptionWithSecret, }); diff --git a/packages/schema/types/index.ts b/packages/schema/types/index.ts index a8fbe6d..6bd9696 100644 --- a/packages/schema/types/index.ts +++ b/packages/schema/types/index.ts @@ -1,14 +1,23 @@ import type { z } from "zod"; import type * as outputs from "../outputs"; -import { AllocationStatus, IdempotencyStatus, WebhookDeliveryStatus } from "../constants"; +import { + BookingStatus, + IdempotencyStatus, + WebhookDeliveryStatus, + ScheduleDefault, +} from "../constants"; import { ConstantType } from "./utils"; -export type AllocationStatus = ConstantType; +export type BookingStatus = ConstantType; export type IdempotencyStatus = ConstantType; export type WebhookDeliveryStatus = ConstantType; +export type ScheduleDefault = ConstantType; -export type Allocation = z.infer; -export type Resource = z.infer; -export type Ledger = z.infer; -export type AvailabilityItem = z.infer; -export type TimelineBlock = z.infer; +export type Allocation = z.infer; +export type Resource = z.infer; +export type Ledger = z.infer; +export type AvailabilityItem = z.infer; +export type TimelineBlock = z.infer; +export type Policy = z.infer; +export type Service = z.infer; +export type Booking = z.infer; diff --git a/packages/utils/id.ts b/packages/utils/id.ts index 9de8480..8f1dc47 100644 --- a/packages/utils/id.ts +++ b/packages/utils/id.ts @@ -1,13 +1,13 @@ import { ulid } from "ulid"; -export type IdPrefix = "ldg" | "rsc" | "alc" | "whs" | "whd"; +export type IdPrefix = "ldg" | "rsc" | "alc" | "whs" | "whd" | "pol" | "svc" | "bkg"; export function generateId(prefix: IdPrefix): string { return `${prefix}_${ulid().toLowerCase()}`; } export function parseId(id: string): { prefix: IdPrefix; ulid: string } | null { - const match = id.match(/^(ldg|rsc|alc|whs|whd)_([a-z0-9]{26})$/); + const match = id.match(/^(ldg|rsc|alc|whs|whd|pol|svc|bkg)_([a-z0-9]{26})$/); if (!match) return null; return { prefix: match[1] as IdPrefix, ulid: match[2]! }; } diff --git a/scalar.config.json b/scalar.config.json deleted file mode 100644 index 335083d..0000000 --- a/scalar.config.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "https://cdn.scalar.com/schema/scalar-config.json", - "subdomain": "floyd", - "customDomain": "docs.floyd.run", - "theme": "deepSpace", - "guides": [ - { - "name": "Docs", - "sidebar": [ - { - "type": "page", - "name": "Introduction", - "icon": "phosphor/regular/info", - "path": "docs/introduction.md" - }, - { - "type": "page", - "name": "Quickstart", - "icon": "phosphor/regular/rocket", - "path": "docs/quickstart.md" - }, - { - "name": "Concepts", - "type": "folder", - "icon": "phosphor/regular/puzzle-piece", - "children": [ - { "type": "page", "name": "Allocations", "path": "docs/allocations.md" }, - { "type": "page", "name": "Availability", "path": "docs/availability.md" }, - { "type": "page", "name": "Webhooks", "path": "docs/webhooks.md" }, - { "type": "page", "name": "Idempotency", "path": "docs/idempotency.md" }, - { "type": "page", "name": "Errors", "path": "docs/errors.md" }, - { "type": "page", "name": "Time Format", "path": "docs/time-format.md" } - ] - } - ] - } - ], - "references": [ - { - "name": "API Reference", - "path": "apps/server/openapi.json" - } - ] -}