From 490fd56288a706948c9666a8fc12c89a522c879e Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 04:18:59 +0300 Subject: [PATCH 01/31] Initial policies --- apps/server/src/database/schema.ts | 14 + .../20260212000920_create-policies-table.ts | 29 + apps/server/src/routes/v1/index.ts | 4 +- apps/server/src/routes/v1/policies.ts | 53 + apps/server/src/routes/v1/serializers.ts | 40 +- apps/server/src/services/index.ts | 2 + .../src/services/policy/canonicalize.ts | 111 ++ apps/server/src/services/policy/create.ts | 36 + apps/server/src/services/policy/evaluate.ts | 482 +++++ apps/server/src/services/policy/get.ts | 16 + apps/server/src/services/policy/index.ts | 13 + apps/server/src/services/policy/list.ts | 16 + apps/server/src/services/policy/normalize.ts | 147 ++ apps/server/src/services/policy/remove.ts | 12 + apps/server/src/services/policy/update.ts | 42 + apps/server/src/services/policy/validate.ts | 97 + apps/server/test/setup/client.ts | 3 +- apps/server/test/setup/factories/index.ts | 1 + .../test/setup/factories/policy.factory.ts | 52 + apps/server/test/v1/policies/create.spec.ts | 184 ++ apps/server/test/v1/policies/delete.spec.ts | 30 + apps/server/test/v1/policies/evaluate.spec.ts | 1673 +++++++++++++++++ apps/server/test/v1/policies/get.spec.ts | 31 + apps/server/test/v1/policies/list.spec.ts | 28 + apps/server/test/v1/policies/update.spec.ts | 107 ++ packages/schema/constants/index.ts | 5 + packages/schema/inputs/index.ts | 1 + packages/schema/inputs/policy.ts | 176 ++ packages/schema/outputs/index.ts | 1 + packages/schema/outputs/policy.ts | 18 + packages/schema/types/index.ts | 9 +- packages/utils/id.ts | 4 +- 32 files changed, 3430 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/migrations/20260212000920_create-policies-table.ts create mode 100644 apps/server/src/routes/v1/policies.ts create mode 100644 apps/server/src/services/policy/canonicalize.ts create mode 100644 apps/server/src/services/policy/create.ts create mode 100644 apps/server/src/services/policy/evaluate.ts create mode 100644 apps/server/src/services/policy/get.ts create mode 100644 apps/server/src/services/policy/index.ts create mode 100644 apps/server/src/services/policy/list.ts create mode 100644 apps/server/src/services/policy/normalize.ts create mode 100644 apps/server/src/services/policy/remove.ts create mode 100644 apps/server/src/services/policy/update.ts create mode 100644 apps/server/src/services/policy/validate.ts create mode 100644 apps/server/test/setup/factories/policy.factory.ts create mode 100644 apps/server/test/v1/policies/create.spec.ts create mode 100644 apps/server/test/v1/policies/delete.spec.ts create mode 100644 apps/server/test/v1/policies/evaluate.spec.ts create mode 100644 apps/server/test/v1/policies/get.spec.ts create mode 100644 apps/server/test/v1/policies/list.spec.ts create mode 100644 apps/server/test/v1/policies/update.spec.ts create mode 100644 packages/schema/inputs/policy.ts create mode 100644 packages/schema/outputs/policy.ts diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index 643dfec..a67a37b 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -67,11 +67,21 @@ 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; idempotencyKeys: IdempotencyKeysTable; resources: ResourcesTable; ledgers: LedgersTable; + policies: PoliciesTable; webhookSubscriptions: WebhookSubscriptionsTable; webhookDeliveries: WebhookDeliveriesTable; } @@ -97,3 +107,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/migrations/20260212000920_create-policies-table.ts b/apps/server/src/migrations/20260212000920_create-policies-table.ts new file mode 100644 index 0000000..c6fbcb6 --- /dev/null +++ b/apps/server/src/migrations/20260212000920_create-policies-table.ts @@ -0,0 +1,29 @@ +import type { Database } from "database/schema"; +import { 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/routes/v1/index.ts b/apps/server/src/routes/v1/index.ts index 9643d92..6a981bf 100644 --- a/apps/server/src/routes/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -4,10 +4,12 @@ import { availability } from "./availability"; import { resources } from "./resources"; import { ledgers } from "./ledgers"; import { webhooks } from "./webhooks"; +import { policies } from "./policies"; 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); diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts new file mode 100644 index 0000000..0aa6f82 --- /dev/null +++ b/apps/server/src/routes/v1/policies.ts @@ -0,0 +1,53 @@ +import { Hono } from "hono"; +import { services } from "../../services/index.js"; +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 services.policy.list({ + ledgerId: c.req.param("ledgerId")!, + }); + return c.json({ data: policies.map(serializePolicy) }); + }) + + .get("/:id", async (c) => { + const { policy } = await services.policy.get({ id: c.req.param("id") }); + 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 services.policy.create({ + ...(body as object), + ledgerId: c.req.param("ledgerId")!, + } as Parameters[0]); + + const responseBody: Record = { data: serializePolicy(policy) }; + if (warnings.length > 0) { + responseBody["warnings"] = warnings; + } + return c.json(responseBody, 201); + }) + + .put("/:id", async (c) => { + const body = await c.req.json(); + const { policy, warnings } = await services.policy.update({ + ...(body as object), + id: c.req.param("id")!, + } as Parameters[0]); + + const responseBody: Record = { data: serializePolicy(policy) }; + if (warnings.length > 0) { + responseBody["warnings"] = warnings; + } + return c.json(responseBody); + }) + + .delete("/:id", async (c) => { + const { deleted } = await services.policy.remove({ id: c.req.param("id") }); + if (!deleted) throw new NotFoundError("Policy not found"); + 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..cebae64 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -1,5 +1,11 @@ -import { AllocationRow, ResourceRow, LedgerRow, WebhookSubscriptionRow } from "database/schema"; -import { Allocation, Resource, Ledger } from "@floyd-run/schema/types"; +import { + AllocationRow, + ResourceRow, + LedgerRow, + WebhookSubscriptionRow, + PolicyRow, +} from "database/schema"; +import { Allocation, Resource, Ledger, Policy } from "@floyd-run/schema/types"; export function serializeResource(resource: ResourceRow): Resource { return { @@ -50,3 +56,33 @@ export function serializeWebhookSubscription(sub: WebhookSubscriptionRow): Webho updatedAt: sub.updatedAt.toISOString(), }; } + +function camelToSnake(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); +} + +/** Recursively convert camelCase keys back to snake_case (undoes CamelCasePlugin on JSONB) */ +function snakeCaseKeys(obj: unknown): unknown { + if (Array.isArray(obj)) { + return obj.map(snakeCaseKeys); + } + if (obj !== null && typeof obj === "object" && !(obj instanceof Date)) { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + result[camelToSnake(key)] = snakeCaseKeys(value); + } + return result; + } + return obj; +} + +export function serializePolicy(policy: PolicyRow): Policy { + return { + id: policy.id, + ledgerId: policy.ledgerId, + config: snakeCaseKeys(policy.config) as Record, + configHash: policy.configHash, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + }; +} diff --git a/apps/server/src/services/index.ts b/apps/server/src/services/index.ts index 10ba2f1..478223e 100644 --- a/apps/server/src/services/index.ts +++ b/apps/server/src/services/index.ts @@ -3,6 +3,7 @@ import { availability } from "./availability"; import { resource } from "./resource"; import { ledger } from "./ledger"; import { webhook } from "./webhook"; +import { policy } from "./policy"; export const services = { allocation, @@ -10,4 +11,5 @@ export const services = { resource, ledger, webhook, + policy, }; diff --git a/apps/server/src/services/policy/canonicalize.ts b/apps/server/src/services/policy/canonicalize.ts new file mode 100644 index 0000000..44c564c --- /dev/null +++ b/apps/server/src/services/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: Array<{ 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, parentKey?: string): 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 Array<{ 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) => { + if (typeof item === "object" && item !== null) { + return canonicalizeObject(item as Record); + } + return item; + }); + } + return value.map((item) => { + if (typeof item === "object" && item !== null) { + return canonicalizeObject(item as Record); + } + return item; + }); + } + + 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/services/policy/create.ts b/apps/server/src/services/policy/create.ts new file mode 100644 index 0000000..c91f1a1 --- /dev/null +++ b/apps/server/src/services/policy/create.ts @@ -0,0 +1,36 @@ +import { db } from "database"; +import { createService } from "lib/service"; +import { generateId } from "@floyd-run/utils"; +import { policy } from "@floyd-run/schema/inputs"; +import { normalizePolicyConfig } from "./normalize"; +import { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; +import { validatePolicyConfig } from "./validate"; + +export default createService({ + input: policy.createSchema, + execute: async (input) => { + // 1. Normalize authoring format → canonical ms format + const normalized = normalizePolicyConfig(input.config as unknown as Record); + + // 2. Validate and generate warnings + const { warnings } = validatePolicyConfig(normalized); + + // 3. Canonicalize → hash + const canonicalJson = canonicalizePolicyConfig(normalized); + const configHash = hashPolicyConfig(canonicalJson); + + // 4. Insert + 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/services/policy/evaluate.ts b/apps/server/src/services/policy/evaluate.ts new file mode 100644 index 0000000..9595260 --- /dev/null +++ b/apps/server/src/services/policy/evaluate.ts @@ -0,0 +1,482 @@ +/** + * Pure policy evaluator implementing SPEC-POLICY-001 Steps 1-9. + * + * Takes a canonical policy config, a booking request, and evaluation context. + * Returns whether the booking is allowed and the resolved config + effective windows. + * + * Step 10 (conflict detection) is NOT here — it stays in the allocation service. + */ + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export const REASON_CODES = { + BLACKOUT_WINDOW: "blackout_window", + CLOSED_BY_SCHEDULE: "closed_by_schedule", + OVERNIGHT_NOT_SUPPORTED: "overnight_not_supported", + INVALID_DURATION: "invalid_duration", + MISALIGNED_START_TIME: "misaligned_start_time", + LEAD_TIME_VIOLATION: "lead_time_violation", + HORIZON_EXCEEDED: "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 { + startAt: Date; + endAt: Date; +} + +export interface EvaluationContext { + decisionTime: Date; + timezone: string; + skipPolicy?: boolean; +} + +interface DurationConfig { + min_ms?: number; + max_ms?: number; + allowed_ms?: number[]; +} + +interface GridConfig { + interval_ms: number; +} + +interface BookingWindowConfig { + min_lead_time_ms?: number; + max_lead_time_ms?: number; +} + +interface BuffersConfig { + before_ms?: number; + after_ms?: number; +} + +export interface ResolvedConfig { + duration?: DurationConfig | undefined; + grid?: GridConfig | undefined; + booking_window?: BookingWindowConfig | undefined; + buffers?: BuffersConfig | 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; + effectiveStartAt: Date; + effectiveEndAt: 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()]! as ReturnType; +} + +/** + * 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. + */ +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 ───────────────────────────────────────────────────────────── + +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.startAt, context.timezone); + const localEndDate = toLocalDate(request.endAt, context.timezone); + const localStartMs = msSinceLocalMidnight(request.startAt, context.timezone); + const localEndMs = msSinceLocalMidnight(request.endAt, context.timezone); + const dayOfWeek = getDayOfWeek(localStartDate); + const durationMs = request.endAt.getTime() - request.startAt.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, + effectiveStartAt: new Date(request.startAt.getTime() - bufferBeforeMs), + effectiveEndAt: new Date(request.endAt.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.endAt.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 ?? {}) as Record; + const ruleConfig = (matchedRule?.config ?? {}) as Record; + const resolvedRaw = { ...baseConfig, ...ruleConfig }; + const resolved = { + duration: resolvedRaw["duration"] as DurationConfig | undefined, + grid: resolvedRaw["grid"] as GridConfig | undefined, + booking_window: resolvedRaw["booking_window"] as BookingWindowConfig | undefined, + buffers: resolvedRaw["buffers"] as BuffersConfig | 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.startAt, 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.booking_window?.min_lead_time_ms !== undefined) { + const leadTime = request.startAt.getTime() - context.decisionTime.getTime(); + if (leadTime < resolved.booking_window.min_lead_time_ms) { + return { + allowed: false, + code: REASON_CODES.LEAD_TIME_VIOLATION, + message: "Booking too close to current time", + details: { + leadTimeMs: leadTime, + min_lead_time_ms: resolved.booking_window.min_lead_time_ms, + }, + }; + } + } + + // ─── Step 8: Horizon ─────────────────────────────────────────────────── + + if (resolved.booking_window?.max_lead_time_ms !== undefined) { + const leadTime = request.startAt.getTime() - context.decisionTime.getTime(); + if (leadTime > resolved.booking_window.max_lead_time_ms) { + return { + allowed: false, + code: REASON_CODES.HORIZON_EXCEEDED, + message: "Booking too far in the future", + details: { + leadTimeMs: leadTime, + max_lead_time_ms: resolved.booking_window.max_lead_time_ms, + }, + }; + } + } + + // ─── Step 9: Buffer computation (never rejects) ──────────────────────── + + const bufferBeforeMs = resolved.buffers?.before_ms ?? 0; + const bufferAfterMs = resolved.buffers?.after_ms ?? 0; + const effectiveStartAt = new Date(request.startAt.getTime() - bufferBeforeMs); + const effectiveEndAt = new Date(request.endAt.getTime() + bufferAfterMs); + + return { + allowed: true, + resolvedConfig: resolved, + effectiveStartAt, + effectiveEndAt, + 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/services/policy/get.ts b/apps/server/src/services/policy/get.ts new file mode 100644 index 0000000..b28cc5c --- /dev/null +++ b/apps/server/src/services/policy/get.ts @@ -0,0 +1,16 @@ +import { db } from "database"; +import { createService } from "lib/service"; +import { policy } from "@floyd-run/schema/inputs"; + +export default createService({ + input: policy.getSchema, + execute: async (input) => { + const row = await db + .selectFrom("policies") + .selectAll() + .where("id", "=", input.id) + .executeTakeFirst(); + + return { policy: row ?? null }; + }, +}); diff --git a/apps/server/src/services/policy/index.ts b/apps/server/src/services/policy/index.ts new file mode 100644 index 0000000..8f89ab9 --- /dev/null +++ b/apps/server/src/services/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/services/policy/list.ts b/apps/server/src/services/policy/list.ts new file mode 100644 index 0000000..6b2b3ab --- /dev/null +++ b/apps/server/src/services/policy/list.ts @@ -0,0 +1,16 @@ +import { db } from "database"; +import { createService } from "lib/service"; +import { policy } from "@floyd-run/schema/inputs"; + +export default createService({ + input: policy.listSchema, + execute: async (input) => { + const policies = await db + .selectFrom("policies") + .selectAll() + .where("ledgerId", "=", input.ledgerId) + .execute(); + + return { policies }; + }, +}); diff --git a/apps/server/src/services/policy/normalize.ts b/apps/server/src/services/policy/normalize.ts new file mode 100644 index 0000000..3ddf140 --- /dev/null +++ b/apps/server/src/services/policy/normalize.ts @@ -0,0 +1,147 @@ +/** + * 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 === "booking_window" || key === "buffers") && + 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/services/policy/remove.ts b/apps/server/src/services/policy/remove.ts new file mode 100644 index 0000000..04542fb --- /dev/null +++ b/apps/server/src/services/policy/remove.ts @@ -0,0 +1,12 @@ +import { db } from "database"; +import { createService } from "lib/service"; +import { policy } from "@floyd-run/schema/inputs"; + +export default createService({ + input: policy.removeSchema, + execute: async (input) => { + const result = await db.deleteFrom("policies").where("id", "=", input.id).executeTakeFirst(); + + return { deleted: result.numDeletedRows > 0n }; + }, +}); diff --git a/apps/server/src/services/policy/update.ts b/apps/server/src/services/policy/update.ts new file mode 100644 index 0000000..ce599fc --- /dev/null +++ b/apps/server/src/services/policy/update.ts @@ -0,0 +1,42 @@ +import { db } from "database"; +import { createService } from "lib/service"; +import { policy } from "@floyd-run/schema/inputs"; +import { NotFoundError } from "lib/errors"; +import { normalizePolicyConfig } from "./normalize"; +import { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; +import { validatePolicyConfig } from "./validate"; + +export default createService({ + input: policy.updateSchema, + execute: async (input) => { + // 1. Verify policy exists + const existing = await db + .selectFrom("policies") + .selectAll() + .where("id", "=", input.id) + .executeTakeFirst(); + + if (!existing) { + throw new NotFoundError("Policy not found"); + } + + // 2. Normalize, validate, canonicalize, hash + const normalized = normalizePolicyConfig(input.config as unknown as Record); + const { warnings } = validatePolicyConfig(normalized); + const canonicalJson = canonicalizePolicyConfig(normalized); + const configHash = hashPolicyConfig(canonicalJson); + + // 3. Update + const row = await db + .updateTable("policies") + .set({ + config: normalized, + configHash, + }) + .where("id", "=", input.id) + .returningAll() + .executeTakeFirstOrThrow(); + + return { policy: row, warnings }; + }, +}); diff --git a/apps/server/src/services/policy/validate.ts b/apps/server/src/services/policy/validate.ts new file mode 100644 index 0000000..3249f92 --- /dev/null +++ b/apps/server/src/services/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?: Array<{ 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/test/setup/client.ts b/apps/server/test/setup/client.ts index 328ec6a..836047b 100644 --- a/apps/server/test/setup/client.ts +++ b/apps/server/test/setup/client.ts @@ -1,6 +1,6 @@ import type { Hono } from "hono"; -type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE"; +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RequestOptions { headers?: Record; @@ -9,6 +9,7 @@ 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; } diff --git a/apps/server/test/setup/factories/index.ts b/apps/server/test/setup/factories/index.ts index 8f49713..e669172 100644 --- a/apps/server/test/setup/factories/index.ts +++ b/apps/server/test/setup/factories/index.ts @@ -2,3 +2,4 @@ export * from "./allocation.factory"; export * from "./resource.factory"; export * from "./ledger.factory"; export * from "./webhook.factory"; +export * from "./policy.factory"; diff --git a/apps/server/test/setup/factories/policy.factory.ts b/apps/server/test/setup/factories/policy.factory.ts new file mode 100644 index 0000000..4b6e2d9 --- /dev/null +++ b/apps/server/test/setup/factories/policy.factory.ts @@ -0,0 +1,52 @@ +import { db } from "database"; +import { generateId } from "@floyd-run/utils"; +import { createLedger } from "./ledger.factory"; +import { normalizePolicyConfig } from "../../../src/services/policy/normalize"; +import { + canonicalizePolicyConfig, + hashPolicyConfig, +} from "../../../src/services/policy/canonicalize"; + +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 = normalizePolicyConfig(rawConfig); + const canonicalJson = canonicalizePolicyConfig(normalized); + const configHash = hashPolicyConfig(canonicalJson); + + 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/v1/policies/create.spec.ts b/apps/server/test/v1/policies/create.spec.ts new file mode 100644 index 0000000..31c1208 --- /dev/null +++ b/apps/server/test/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 as Record; + 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 Array<{ + 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; + warnings: Array<{ code: string; message: string }>; + }; + expect(body.warnings).toBeDefined(); + expect(body.warnings.length).toBeGreaterThan(0); + expect(body.warnings[0]!.code).toBe("unreachable_weekly_rule"); + }); +}); diff --git a/apps/server/test/v1/policies/delete.spec.ts b/apps/server/test/v1/policies/delete.spec.ts new file mode 100644 index 0000000..ea4d5ea --- /dev/null +++ b/apps/server/test/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/v1/policies/evaluate.spec.ts b/apps/server/test/v1/policies/evaluate.spec.ts new file mode 100644 index 0000000..0afec51 --- /dev/null +++ b/apps/server/test/v1/policies/evaluate.spec.ts @@ -0,0 +1,1673 @@ +import { describe, expect, it } from "vitest"; +import { + evaluatePolicy, + toLocalDate, + msSinceLocalMidnight, + getDayOfWeek, + dateRange, + REASON_CODES, + type PolicyConfig, + type EvaluationInput, + type EvaluationContext, + type EvaluationResult, +} from "services/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 { + startAt: new Date(startISO), + endAt: 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: { + booking_window: { min_lead_time_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_lead_time_ms).toBe(2 * HOUR); + }); + + it("booking beyond horizon is rejected", () => { + const policy = makePolicy({ + config: { + booking_window: { max_lead_time_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: { + booking_window: { + min_lead_time_ms: 1 * HOUR, + max_lead_time_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: { + booking_window: { min_lead_time_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: { + booking_window: { max_lead_time_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 booking_window 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: { + booking_window: { + min_lead_time_ms: 2 * HOUR, + max_lead_time_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.effectiveStartAt.toISOString()).toBe("2026-03-16T08:50:00.000Z"); + expect(result.effectiveEndAt.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.effectiveStartAt.toISOString()).toBe("2026-03-16T09:00:00.000Z"); + expect(result.effectiveEndAt.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.effectiveStartAt.toISOString()).toBe("2026-03-16T08:55:00.000Z"); + expect(result.effectiveEndAt.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.effectiveStartAt.toISOString()).toBe("2026-03-16T09:00:00.000Z"); + expect(result.effectiveEndAt.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 }, + booking_window: { + min_lead_time_ms: 1 * HOUR, + max_lead_time_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.effectiveEndAt.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 }, + booking_window: { min_lead_time_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 }, + booking_window: { + min_lead_time_ms: 2 * HOUR, + max_lead_time_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.effectiveStartAt.toISOString()).toBe("2026-03-16T10:15:00.000Z"); + expect(result.effectiveEndAt.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 }, + booking_window: { min_lead_time_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.effectiveStartAt.toISOString()).toBe("2026-03-16T08:50:00.000Z"); + expect(result.effectiveEndAt.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.effectiveStartAt.toISOString()).toBe("2026-03-16T09:00:00.000Z"); + expect(result.effectiveEndAt.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 }, + booking_window: { min_lead_time_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.effectiveStartAt.toISOString()).toBe("2026-03-16T08:30:00.000Z"); + expect(result.effectiveEndAt.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); + }); +}); diff --git a/apps/server/test/v1/policies/get.spec.ts b/apps/server/test/v1/policies/get.spec.ts new file mode 100644 index 0000000..58a08b9 --- /dev/null +++ b/apps/server/test/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/v1/policies/list.spec.ts b/apps/server/test/v1/policies/list.spec.ts new file mode 100644 index 0000000..b0bfee5 --- /dev/null +++ b/apps/server/test/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/v1/policies/update.spec.ts b/apps/server/test/v1/policies/update.spec.ts new file mode 100644 index 0000000..43c5663 --- /dev/null +++ b/apps/server/test/v1/policies/update.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { createLedger, createPolicy } from "../../setup/factories"; +import type { Policy } from "@floyd-run/schema/types"; +import type { Hono } from "hono"; + +// The shared client does not expose a PUT method, so we construct requests directly. +const { default: app } = await import("../../../src/app"); + +async function put(path: string, body: unknown): Promise { + const request = new Request(`http://localhost${path}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return (app as Hono).request(request as Request); +} + +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 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 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 as Record; + 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 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 put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + config: { + schema_version: 2, + default: "closed", + config: {}, + }, + }); + + expect(response.status).toBe(422); + }); +}); diff --git a/packages/schema/constants/index.ts b/packages/schema/constants/index.ts index ffee231..670e648 100644 --- a/packages/schema/constants/index.ts +++ b/packages/schema/constants/index.ts @@ -17,3 +17,8 @@ export const WebhookDeliveryStatus = { FAILED: "failed", EXHAUSTED: "exhausted", } as const; + +export const PolicyDefault = { + OPEN: "open", + CLOSED: "closed", +} as const; diff --git a/packages/schema/inputs/index.ts b/packages/schema/inputs/index.ts index 20fe92e..dccff01 100644 --- a/packages/schema/inputs/index.ts +++ b/packages/schema/inputs/index.ts @@ -3,3 +3,4 @@ export * as availability from "./availability"; export * as resource from "./resource"; export * as ledger from "./ledger"; export * as webhook from "./webhook"; +export * as policy from "./policy"; diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts new file mode 100644 index 0000000..5e5302a --- /dev/null +++ b/packages/schema/inputs/policy.ts @@ -0,0 +1,176 @@ +import z from "zod"; +import { isValidId } from "@floyd-run/utils"; +import { PolicyDefault } 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(); + +// Booking window section (authoring) +const bookingWindowAuthoringSchema = z + .object({ + min_lead_time_ms: z.number().int().nonnegative().optional(), + max_lead_time_ms: z.number().int().nonnegative().optional(), + min_lead_time_minutes: z.number().nonnegative().optional(), + max_lead_time_minutes: z.number().nonnegative().optional(), + min_lead_time_hours: z.number().nonnegative().optional(), + max_lead_time_hours: z.number().nonnegative().optional(), + min_lead_time_days: z.number().nonnegative().optional(), + max_lead_time_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(); + +// Config section (authoring) +const configAuthoringSchema = z + .object({ + duration: durationAuthoringSchema.optional(), + grid: gridAuthoringSchema.optional(), + booking_window: bookingWindowAuthoringSchema.optional(), + buffers: buffersAuthoringSchema.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([PolicyDefault.OPEN, PolicyDefault.CLOSED]), + config: configAuthoringSchema, + rules: z.array(ruleSchema).default([]), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + +export const createSchema = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + config: policyConfigSchema, +}); + +export const updateSchema = z.object({ + id: z.string().refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }), + config: policyConfigSchema, +}); + +export const getSchema = z.object({ + id: z.string().refine((id) => isValidId(id, "pol"), { message: "Invalid policy 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, "pol"), { message: "Invalid policy ID" }), +}); diff --git a/packages/schema/outputs/index.ts b/packages/schema/outputs/index.ts index 59367f3..f69713c 100644 --- a/packages/schema/outputs/index.ts +++ b/packages/schema/outputs/index.ts @@ -4,3 +4,4 @@ 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"; diff --git a/packages/schema/outputs/policy.ts b/packages/schema/outputs/policy.ts new file mode 100644 index 0000000..5fa83ec --- /dev/null +++ b/packages/schema/outputs/policy.ts @@ -0,0 +1,18 @@ +import { z } from "./zod"; + +export const schema = 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 getSchema = z.object({ + data: schema, +}); + +export const listSchema = z.object({ + data: z.array(schema), +}); diff --git a/packages/schema/types/index.ts b/packages/schema/types/index.ts index a8fbe6d..9db0406 100644 --- a/packages/schema/types/index.ts +++ b/packages/schema/types/index.ts @@ -1,14 +1,21 @@ import type { z } from "zod"; import type * as outputs from "../outputs"; -import { AllocationStatus, IdempotencyStatus, WebhookDeliveryStatus } from "../constants"; +import { + AllocationStatus, + IdempotencyStatus, + WebhookDeliveryStatus, + PolicyDefault, +} from "../constants"; import { ConstantType } from "./utils"; export type AllocationStatus = ConstantType; export type IdempotencyStatus = ConstantType; export type WebhookDeliveryStatus = ConstantType; +export type PolicyDefault = 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 Policy = z.infer; diff --git a/packages/utils/id.ts b/packages/utils/id.ts index 9de8480..f9024a4 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"; 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)_([a-z0-9]{26})$/); if (!match) return null; return { prefix: match[1] as IdPrefix, ulid: match[2]! }; } From 46fe8114b648bc1dd340967abd835f36b2553a62 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 04:44:21 +0300 Subject: [PATCH 02/31] Update docs --- apps/server/openapi.json | 511 ++++++++++++++++++++ apps/server/src/scripts/generate-openapi.ts | 128 +++++ docs/introduction.md | 2 + docs/policies.md | 247 ++++++++++ scalar.config.json | 1 + 5 files changed, 889 insertions(+) create mode 100644 docs/policies.md diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 72b023b..9a26f9d 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -166,6 +166,31 @@ }, "required": ["startAt", "endAt", "status"] }, + "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"] + }, "Error": { "type": "object", "properties": { @@ -1842,6 +1867,492 @@ } } } + }, + "/v1/ledgers/{ledgerId}/policies": { + "get": { + "tags": ["Policies"], + "summary": "List policies for a ledger", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "List of policies", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "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"] + } + } + } + } + } + }, + "post": { + "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": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "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": { + "201": { + "description": "Policy created (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"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/policies/{id}": { + "get": { + "tags": ["Policies"], + "summary": "Get a policy by ID", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Policy details", + "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": "Policy 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": ["error"] + } + } + } + } + } + }, + "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": { + "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": { + "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": { + "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": "Policy 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": ["error"] + } + } + } + } + } + }, + "delete": { + "tags": ["Policies"], + "summary": "Delete a policy", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Policy deleted" + }, + "404": { + "description": "Policy 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": ["error"] + } + } + } + } + } + } } }, "webhooks": {} diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 3874da3..55007d7 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -7,6 +7,7 @@ import { resource, ledger, webhook, + policy, error, availability, } from "@floyd-run/schema/outputs"; @@ -20,6 +21,7 @@ registry.register("Allocation", allocation.schema); registry.register("WebhookSubscription", webhook.subscriptionSchema); registry.register("AvailabilityItem", availability.itemSchema); registry.register("TimelineBlock", availability.timelineBlockSchema); +registry.register("Policy", policy.schema); registry.register("Error", error.schema); // Ledger routes @@ -422,6 +424,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.listSchema } }, + }, + }, +}); + +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.getSchema } }, + }, + 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({}).passthrough(), + rules: z.array(z.object({}).passthrough()).optional(), + }) + .passthrough() + .openapi({ description: "Policy configuration in authoring format" }), + }), + }, + }, + }, + }, + responses: { + 201: { + description: "Policy created (may include warnings)", + content: { "application/json": { schema: policy.getSchema } }, + }, + }, +}); + +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({}).passthrough(), + rules: z.array(z.object({}).passthrough()).optional(), + }) + .passthrough() + .openapi({ description: "Policy configuration in authoring format" }), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Policy updated (may include warnings)", + content: { "application/json": { schema: policy.getSchema } }, + }, + 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({ diff --git a/docs/introduction.md b/docs/introduction.md index 39d9f23..f3f44eb 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -31,6 +31,7 @@ See the [Quickstart](./quickstart) for full setup instructions. - **Hold**: temporarily reserves a time slot with `expiresAt` - **Confirm**: commits the hold and clears expiry +- **Policy**: scheduling rules (working hours, duration limits, grid alignment, buffers) - **Idempotency**: retry-safe create requests using `Idempotency-Key` header - **409 Conflict**: means "the slot is not available" or "idempotency mismatch" @@ -38,6 +39,7 @@ See the [Quickstart](./quickstart) for full setup instructions. - [Quickstart](./quickstart) - Get running in 5 minutes - [Allocations](./allocations) - The booking model +- [Policies](./policies) - Scheduling rules - [Availability](./availability) - Query free/busy timelines - [Webhooks](./webhooks) - Real-time notifications - [Idempotency](./idempotency) - Safe retries diff --git a/docs/policies.md b/docs/policies.md new file mode 100644 index 0000000..5e66f11 --- /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 + }, + "booking_window": { + "min_lead_time_hours": 1, + "max_lead_time_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 | + +### Booking window + +Controls how far in advance bookings can be made. + +| Field | Description | +| ------------------ | ------------------------------------------ | +| `min_lead_time_ms` | Minimum time between now and booking start | +| `max_lead_time_ms` | Maximum time between now and 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 }, + "booking_window": { "min_lead_time_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/scalar.config.json b/scalar.config.json index 335083d..93b04eb 100644 --- a/scalar.config.json +++ b/scalar.config.json @@ -25,6 +25,7 @@ "icon": "phosphor/regular/puzzle-piece", "children": [ { "type": "page", "name": "Allocations", "path": "docs/allocations.md" }, + { "type": "page", "name": "Policies", "path": "docs/policies.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" }, From b81f1332121dc833ea0d823527c269f92b8034cb Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 05:07:01 +0300 Subject: [PATCH 03/31] Reshape project structure --- apps/server/eslint.config.js | 20 +++++++++++++- apps/server/package.json | 2 ++ .../policy/canonicalize.ts | 0 .../{services => domain}/policy/evaluate.ts | 4 +-- apps/server/src/domain/policy/index.ts | 17 ++++++++++++ .../{services => domain}/policy/normalize.ts | 0 .../{services => domain}/policy/validate.ts | 0 apps/server/src/domain/scheduling/index.ts | 1 + .../{lib => domain/scheduling}/timeline.ts | 0 .../server/src/services/availability/query.ts | 2 +- apps/server/src/services/policy/create.ts | 6 ++--- apps/server/src/services/policy/update.ts | 6 ++--- .../test/{ => integration}/setup/client.ts | 4 ++- .../setup/factories/allocation.factory.ts | 0 .../setup/factories/index.ts | 0 .../setup/factories/ledger.factory.ts | 0 .../setup/factories/policy.factory.ts | 7 ++--- .../setup/factories/resource.factory.ts | 0 .../setup/factories/webhook.factory.ts | 0 .../test/{ => integration}/setup/global.ts | 0 .../test/{ => integration}/setup/types.ts | 0 .../v1/allocations/cancel.spec.ts | 0 .../v1/allocations/confirm.spec.ts | 0 .../v1/allocations/create.spec.ts | 0 .../v1/allocations/get.spec.ts | 0 .../v1/allocations/idempotency.spec.ts | 0 .../v1/allocations/list.spec.ts | 0 .../test/{ => integration}/v1/auth.spec.ts | 0 .../v1/availability/query.spec.ts | 0 .../v1/ledgers/create.spec.ts | 0 .../{ => integration}/v1/ledgers/get.spec.ts | 0 .../{ => integration}/v1/ledgers/list.spec.ts | 0 .../v1/policies/create.spec.ts | 0 .../v1/policies/delete.spec.ts | 0 .../{ => integration}/v1/policies/get.spec.ts | 0 .../v1/policies/list.spec.ts | 0 .../v1/policies/update.spec.ts | 27 ++++++------------- .../v1/resources/create.spec.ts | 0 .../v1/resources/get.spec.ts | 0 .../v1/resources/list.spec.ts | 0 .../v1/webhooks/create.spec.ts | 2 +- .../v1/webhooks/delete.spec.ts | 0 .../v1/webhooks/list.spec.ts | 2 +- .../v1/webhooks/rotate-secret.spec.ts | 2 +- .../v1/webhooks/update.spec.ts | 2 +- apps/server/test/integration/vitest.config.ts | 26 ++++++++++++++++++ .../domain/policy}/evaluate.spec.ts | 2 +- apps/server/test/unit/vitest.config.ts | 16 +++++++++++ apps/server/tsconfig.json | 5 +++- apps/server/vitest.config.ts | 18 +------------ 50 files changed, 113 insertions(+), 58 deletions(-) rename apps/server/src/{services => domain}/policy/canonicalize.ts (100%) rename apps/server/src/{services => domain}/policy/evaluate.ts (99%) create mode 100644 apps/server/src/domain/policy/index.ts rename apps/server/src/{services => domain}/policy/normalize.ts (100%) rename apps/server/src/{services => domain}/policy/validate.ts (100%) create mode 100644 apps/server/src/domain/scheduling/index.ts rename apps/server/src/{lib => domain/scheduling}/timeline.ts (100%) rename apps/server/test/{ => integration}/setup/client.ts (90%) rename apps/server/test/{ => integration}/setup/factories/allocation.factory.ts (100%) rename apps/server/test/{ => integration}/setup/factories/index.ts (100%) rename apps/server/test/{ => integration}/setup/factories/ledger.factory.ts (100%) rename apps/server/test/{ => integration}/setup/factories/policy.factory.ts (86%) rename apps/server/test/{ => integration}/setup/factories/resource.factory.ts (100%) rename apps/server/test/{ => integration}/setup/factories/webhook.factory.ts (100%) rename apps/server/test/{ => integration}/setup/global.ts (100%) rename apps/server/test/{ => integration}/setup/types.ts (100%) rename apps/server/test/{ => integration}/v1/allocations/cancel.spec.ts (100%) rename apps/server/test/{ => integration}/v1/allocations/confirm.spec.ts (100%) rename apps/server/test/{ => integration}/v1/allocations/create.spec.ts (100%) rename apps/server/test/{ => integration}/v1/allocations/get.spec.ts (100%) rename apps/server/test/{ => integration}/v1/allocations/idempotency.spec.ts (100%) rename apps/server/test/{ => integration}/v1/allocations/list.spec.ts (100%) rename apps/server/test/{ => integration}/v1/auth.spec.ts (100%) rename apps/server/test/{ => integration}/v1/availability/query.spec.ts (100%) rename apps/server/test/{ => integration}/v1/ledgers/create.spec.ts (100%) rename apps/server/test/{ => integration}/v1/ledgers/get.spec.ts (100%) rename apps/server/test/{ => integration}/v1/ledgers/list.spec.ts (100%) rename apps/server/test/{ => integration}/v1/policies/create.spec.ts (100%) rename apps/server/test/{ => integration}/v1/policies/delete.spec.ts (100%) rename apps/server/test/{ => integration}/v1/policies/get.spec.ts (100%) rename apps/server/test/{ => integration}/v1/policies/list.spec.ts (100%) rename apps/server/test/{ => integration}/v1/policies/update.spec.ts (72%) rename apps/server/test/{ => integration}/v1/resources/create.spec.ts (100%) rename apps/server/test/{ => integration}/v1/resources/get.spec.ts (100%) rename apps/server/test/{ => integration}/v1/resources/list.spec.ts (100%) rename apps/server/test/{ => integration}/v1/webhooks/create.spec.ts (93%) rename apps/server/test/{ => integration}/v1/webhooks/delete.spec.ts (100%) rename apps/server/test/{ => integration}/v1/webhooks/list.spec.ts (96%) rename apps/server/test/{ => integration}/v1/webhooks/rotate-secret.spec.ts (93%) rename apps/server/test/{ => integration}/v1/webhooks/update.spec.ts (93%) create mode 100644 apps/server/test/integration/vitest.config.ts rename apps/server/test/{v1/policies => unit/domain/policy}/evaluate.spec.ts (99%) create mode 100644 apps/server/test/unit/vitest.config.ts diff --git a/apps/server/eslint.config.js b/apps/server/eslint.config.js index 0cc4414..dead95e 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/*", "services/*", "config/*", "routes/*", "workers/*"], + message: "domain/ must be pure — no imports from app infrastructure layers.", + }, + ], + }, + ], + }, + }, +]; 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/services/policy/canonicalize.ts b/apps/server/src/domain/policy/canonicalize.ts similarity index 100% rename from apps/server/src/services/policy/canonicalize.ts rename to apps/server/src/domain/policy/canonicalize.ts diff --git a/apps/server/src/services/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts similarity index 99% rename from apps/server/src/services/policy/evaluate.ts rename to apps/server/src/domain/policy/evaluate.ts index 9595260..3354bc5 100644 --- a/apps/server/src/services/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -1,10 +1,10 @@ /** - * Pure policy evaluator implementing SPEC-POLICY-001 Steps 1-9. + * 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. * - * Step 10 (conflict detection) is NOT here — it stays in the allocation service. + * Conflict detection is NOT here — it stays in the allocation service. */ // ─── Types ─────────────────────────────────────────────────────────────────── diff --git a/apps/server/src/domain/policy/index.ts b/apps/server/src/domain/policy/index.ts new file mode 100644 index 0000000..7290f87 --- /dev/null +++ b/apps/server/src/domain/policy/index.ts @@ -0,0 +1,17 @@ +export { normalizePolicyConfig } from "./normalize"; +export { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; +export { validatePolicyConfig, type PolicyWarning } from "./validate"; +export { + evaluatePolicy, + toLocalDate, + msSinceLocalMidnight, + getDayOfWeek, + dateRange, + REASON_CODES, + type PolicyConfig, + type EvaluationInput, + type EvaluationContext, + type EvaluationResult, + type ResolvedConfig, + type ReasonCode, +} from "./evaluate"; diff --git a/apps/server/src/services/policy/normalize.ts b/apps/server/src/domain/policy/normalize.ts similarity index 100% rename from apps/server/src/services/policy/normalize.ts rename to apps/server/src/domain/policy/normalize.ts diff --git a/apps/server/src/services/policy/validate.ts b/apps/server/src/domain/policy/validate.ts similarity index 100% rename from apps/server/src/services/policy/validate.ts rename to apps/server/src/domain/policy/validate.ts diff --git a/apps/server/src/domain/scheduling/index.ts b/apps/server/src/domain/scheduling/index.ts new file mode 100644 index 0000000..e959b4d --- /dev/null +++ b/apps/server/src/domain/scheduling/index.ts @@ -0,0 +1 @@ +export { clampInterval, mergeIntervals, buildTimeline, type Interval } from "./timeline"; diff --git a/apps/server/src/lib/timeline.ts b/apps/server/src/domain/scheduling/timeline.ts similarity index 100% rename from apps/server/src/lib/timeline.ts rename to apps/server/src/domain/scheduling/timeline.ts diff --git a/apps/server/src/services/availability/query.ts b/apps/server/src/services/availability/query.ts index 16aa39b..17ea8da 100644 --- a/apps/server/src/services/availability/query.ts +++ b/apps/server/src/services/availability/query.ts @@ -3,7 +3,7 @@ import { db } from "database"; import { createService } from "lib/service"; import { availability } 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; diff --git a/apps/server/src/services/policy/create.ts b/apps/server/src/services/policy/create.ts index c91f1a1..de981f0 100644 --- a/apps/server/src/services/policy/create.ts +++ b/apps/server/src/services/policy/create.ts @@ -2,9 +2,9 @@ import { db } from "database"; import { createService } from "lib/service"; import { generateId } from "@floyd-run/utils"; import { policy } from "@floyd-run/schema/inputs"; -import { normalizePolicyConfig } from "./normalize"; -import { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; -import { validatePolicyConfig } from "./validate"; +import { normalizePolicyConfig } from "domain/policy/normalize"; +import { canonicalizePolicyConfig, hashPolicyConfig } from "domain/policy/canonicalize"; +import { validatePolicyConfig } from "domain/policy/validate"; export default createService({ input: policy.createSchema, diff --git a/apps/server/src/services/policy/update.ts b/apps/server/src/services/policy/update.ts index ce599fc..cf49f9f 100644 --- a/apps/server/src/services/policy/update.ts +++ b/apps/server/src/services/policy/update.ts @@ -2,9 +2,9 @@ import { db } from "database"; import { createService } from "lib/service"; import { policy } from "@floyd-run/schema/inputs"; import { NotFoundError } from "lib/errors"; -import { normalizePolicyConfig } from "./normalize"; -import { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; -import { validatePolicyConfig } from "./validate"; +import { normalizePolicyConfig } from "domain/policy/normalize"; +import { canonicalizePolicyConfig, hashPolicyConfig } from "domain/policy/canonicalize"; +import { validatePolicyConfig } from "domain/policy/validate"; export default createService({ input: policy.updateSchema, diff --git a/apps/server/test/setup/client.ts b/apps/server/test/integration/setup/client.ts similarity index 90% rename from apps/server/test/setup/client.ts rename to apps/server/test/integration/setup/client.ts index 836047b..b97b3e7 100644 --- a/apps/server/test/setup/client.ts +++ b/apps/server/test/integration/setup/client.ts @@ -15,7 +15,7 @@ export interface Client { } export const createClient = async (): Promise => { - const { default: app } = await import("../../src/app"); + const { default: app } = await import("../../../src/app"); const makeRequest = async ( method: HttpMethod, @@ -43,6 +43,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 100% rename from apps/server/test/setup/factories/allocation.factory.ts rename to apps/server/test/integration/setup/factories/allocation.factory.ts diff --git a/apps/server/test/setup/factories/index.ts b/apps/server/test/integration/setup/factories/index.ts similarity index 100% rename from apps/server/test/setup/factories/index.ts rename to apps/server/test/integration/setup/factories/index.ts 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/setup/factories/policy.factory.ts b/apps/server/test/integration/setup/factories/policy.factory.ts similarity index 86% rename from apps/server/test/setup/factories/policy.factory.ts rename to apps/server/test/integration/setup/factories/policy.factory.ts index 4b6e2d9..1139e84 100644 --- a/apps/server/test/setup/factories/policy.factory.ts +++ b/apps/server/test/integration/setup/factories/policy.factory.ts @@ -1,11 +1,8 @@ import { db } from "database"; import { generateId } from "@floyd-run/utils"; import { createLedger } from "./ledger.factory"; -import { normalizePolicyConfig } from "../../../src/services/policy/normalize"; -import { - canonicalizePolicyConfig, - hashPolicyConfig, -} from "../../../src/services/policy/canonicalize"; +import { normalizePolicyConfig } from "domain/policy/normalize"; +import { canonicalizePolicyConfig, hashPolicyConfig } from "domain/policy/canonicalize"; const DEFAULT_CONFIG = { schema_version: 1, diff --git a/apps/server/test/setup/factories/resource.factory.ts b/apps/server/test/integration/setup/factories/resource.factory.ts similarity index 100% rename from apps/server/test/setup/factories/resource.factory.ts rename to apps/server/test/integration/setup/factories/resource.factory.ts 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 100% rename from apps/server/test/setup/global.ts rename to apps/server/test/integration/setup/global.ts diff --git a/apps/server/test/setup/types.ts b/apps/server/test/integration/setup/types.ts similarity index 100% rename from apps/server/test/setup/types.ts rename to apps/server/test/integration/setup/types.ts diff --git a/apps/server/test/v1/allocations/cancel.spec.ts b/apps/server/test/integration/v1/allocations/cancel.spec.ts similarity index 100% rename from apps/server/test/v1/allocations/cancel.spec.ts rename to apps/server/test/integration/v1/allocations/cancel.spec.ts diff --git a/apps/server/test/v1/allocations/confirm.spec.ts b/apps/server/test/integration/v1/allocations/confirm.spec.ts similarity index 100% rename from apps/server/test/v1/allocations/confirm.spec.ts rename to apps/server/test/integration/v1/allocations/confirm.spec.ts diff --git a/apps/server/test/v1/allocations/create.spec.ts b/apps/server/test/integration/v1/allocations/create.spec.ts similarity index 100% rename from apps/server/test/v1/allocations/create.spec.ts rename to apps/server/test/integration/v1/allocations/create.spec.ts diff --git a/apps/server/test/v1/allocations/get.spec.ts b/apps/server/test/integration/v1/allocations/get.spec.ts similarity index 100% rename from apps/server/test/v1/allocations/get.spec.ts rename to apps/server/test/integration/v1/allocations/get.spec.ts diff --git a/apps/server/test/v1/allocations/idempotency.spec.ts b/apps/server/test/integration/v1/allocations/idempotency.spec.ts similarity index 100% rename from apps/server/test/v1/allocations/idempotency.spec.ts rename to apps/server/test/integration/v1/allocations/idempotency.spec.ts diff --git a/apps/server/test/v1/allocations/list.spec.ts b/apps/server/test/integration/v1/allocations/list.spec.ts similarity index 100% rename from apps/server/test/v1/allocations/list.spec.ts rename to apps/server/test/integration/v1/allocations/list.spec.ts 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 100% rename from apps/server/test/v1/availability/query.spec.ts rename to apps/server/test/integration/v1/availability/query.spec.ts 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 100% rename from apps/server/test/v1/ledgers/list.spec.ts rename to apps/server/test/integration/v1/ledgers/list.spec.ts diff --git a/apps/server/test/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts similarity index 100% rename from apps/server/test/v1/policies/create.spec.ts rename to apps/server/test/integration/v1/policies/create.spec.ts diff --git a/apps/server/test/v1/policies/delete.spec.ts b/apps/server/test/integration/v1/policies/delete.spec.ts similarity index 100% rename from apps/server/test/v1/policies/delete.spec.ts rename to apps/server/test/integration/v1/policies/delete.spec.ts diff --git a/apps/server/test/v1/policies/get.spec.ts b/apps/server/test/integration/v1/policies/get.spec.ts similarity index 100% rename from apps/server/test/v1/policies/get.spec.ts rename to apps/server/test/integration/v1/policies/get.spec.ts diff --git a/apps/server/test/v1/policies/list.spec.ts b/apps/server/test/integration/v1/policies/list.spec.ts similarity index 100% rename from apps/server/test/v1/policies/list.spec.ts rename to apps/server/test/integration/v1/policies/list.spec.ts diff --git a/apps/server/test/v1/policies/update.spec.ts b/apps/server/test/integration/v1/policies/update.spec.ts similarity index 72% rename from apps/server/test/v1/policies/update.spec.ts rename to apps/server/test/integration/v1/policies/update.spec.ts index 43c5663..acc5efc 100644 --- a/apps/server/test/v1/policies/update.spec.ts +++ b/apps/server/test/integration/v1/policies/update.spec.ts @@ -1,19 +1,7 @@ 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"; -import type { Hono } from "hono"; - -// The shared client does not expose a PUT method, so we construct requests directly. -const { default: app } = await import("../../../src/app"); - -async function put(path: string, body: unknown): Promise { - const request = new Request(`http://localhost${path}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return (app as Hono).request(request as Request); -} const validConfig = { schema_version: 1, @@ -49,7 +37,7 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { it("returns 200 for valid update", async () => { const { policy, ledgerId } = await createPolicy(); - const response = await put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { config: updatedConfig, }); @@ -65,7 +53,7 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { const { policy, ledgerId } = await createPolicy(); const originalHash = policy.configHash; - const response = await put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { config: updatedConfig, }); @@ -84,9 +72,10 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { it("returns 404 for non-existent policy", async () => { const { ledger } = await createLedger(); - const response = await put(`/v1/ledgers/${ledger.id}/policies/pol_00000000000000000000000000`, { - config: validConfig, - }); + const response = await client.put( + `/v1/ledgers/${ledger.id}/policies/pol_00000000000000000000000000`, + { config: validConfig }, + ); expect(response.status).toBe(404); }); @@ -94,7 +83,7 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { it("returns 422 for invalid config", async () => { const { policy, ledgerId } = await createPolicy(); - const response = await put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { config: { schema_version: 2, default: "closed", diff --git a/apps/server/test/v1/resources/create.spec.ts b/apps/server/test/integration/v1/resources/create.spec.ts similarity index 100% rename from apps/server/test/v1/resources/create.spec.ts rename to apps/server/test/integration/v1/resources/create.spec.ts 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 100% rename from apps/server/test/v1/resources/list.spec.ts rename to apps/server/test/integration/v1/resources/list.spec.ts 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..7f1ca71 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 { 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..fae908f 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 { 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..1fc25ba 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 { 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..f31d384 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 { 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..3971c95 --- /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"), + services: path.resolve(__dirname, "../../src/services"), + }, + }, +}); diff --git a/apps/server/test/v1/policies/evaluate.spec.ts b/apps/server/test/unit/domain/policy/evaluate.spec.ts similarity index 99% rename from apps/server/test/v1/policies/evaluate.spec.ts rename to apps/server/test/unit/domain/policy/evaluate.spec.ts index 0afec51..385378e 100644 --- a/apps/server/test/v1/policies/evaluate.spec.ts +++ b/apps/server/test/unit/domain/policy/evaluate.spec.ts @@ -10,7 +10,7 @@ import { type EvaluationInput, type EvaluationContext, type EvaluationResult, -} from "services/policy/evaluate"; +} from "domain/policy/evaluate"; // ─── Constants ───────────────────────────────────────────────────────────────── 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/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"], }, }); From 9b3f438d1a0724395c769779650953fd88265433 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 05:30:50 +0300 Subject: [PATCH 04/31] Deduplicate shared helpers and clean up stale naming --- apps/server/src/database/index.ts | 1 + apps/server/src/database/server-time.ts | 9 ++++++ apps/server/src/domain/policy/index.ts | 1 + apps/server/src/domain/policy/prepare.ts | 15 +++++++++ apps/server/src/infra/idempotency.ts | 32 +++++++------------ apps/server/src/services/allocation/cancel.ts | 8 ++--- .../server/src/services/allocation/confirm.ts | 8 ++--- apps/server/src/services/allocation/create.ts | 8 ++--- apps/server/src/services/policy/create.ts | 17 +++------- apps/server/src/services/policy/update.ts | 11 +++---- apps/server/src/services/webhook/create.ts | 6 +--- .../src/services/webhook/generate-secret.ts | 5 +++ .../src/services/webhook/rotate-secret.ts | 6 +--- apps/server/src/workers/expiration-worker.ts | 9 ++---- .../setup/factories/policy.factory.ts | 7 ++-- .../test/integration/v1/ledgers/list.spec.ts | 8 ++--- .../integration/v1/resources/list.spec.ts | 10 +++--- 17 files changed, 72 insertions(+), 89 deletions(-) create mode 100644 apps/server/src/database/server-time.ts create mode 100644 apps/server/src/domain/policy/prepare.ts create mode 100644 apps/server/src/services/webhook/generate-secret.ts 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/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/index.ts b/apps/server/src/domain/policy/index.ts index 7290f87..41c5583 100644 --- a/apps/server/src/domain/policy/index.ts +++ b/apps/server/src/domain/policy/index.ts @@ -1,6 +1,7 @@ export { normalizePolicyConfig } from "./normalize"; export { canonicalizePolicyConfig, hashPolicyConfig } from "./canonicalize"; export { validatePolicyConfig, type PolicyWarning } from "./validate"; +export { preparePolicyConfig } from "./prepare"; export { evaluatePolicy, toLocalDate, 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/infra/idempotency.ts b/apps/server/src/infra/idempotency.ts index 22eb9f2..07636d1 100644 --- a/apps/server/src/infra/idempotency.ts +++ b/apps/server/src/infra/idempotency.ts @@ -138,7 +138,18 @@ 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 const isConflict = @@ -259,22 +270,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/services/allocation/cancel.ts b/apps/server/src/services/allocation/cancel.ts index 42d6a9b..adb0850 100644 --- a/apps/server/src/services/allocation/cancel.ts +++ b/apps/server/src/services/allocation/cancel.ts @@ -1,5 +1,4 @@ -import { sql } from "kysely"; -import { db } from "database"; +import { db, getServerTime } from "database"; import { createService } from "lib/service"; import { allocation } from "@floyd-run/schema/inputs"; import { ConflictError, NotFoundError } from "lib/errors"; @@ -22,10 +21,7 @@ export default createService({ } // 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; + const serverTime = await getServerTime(trx); // 3. Validate state transition if (existing.status === "cancelled") { diff --git a/apps/server/src/services/allocation/confirm.ts b/apps/server/src/services/allocation/confirm.ts index ca41d32..29b1733 100644 --- a/apps/server/src/services/allocation/confirm.ts +++ b/apps/server/src/services/allocation/confirm.ts @@ -1,5 +1,4 @@ -import { db } from "database"; -import { sql } from "kysely"; +import { db, getServerTime } from "database"; import { createService } from "lib/service"; import { allocation } from "@floyd-run/schema/inputs"; import { ConflictError, NotFoundError } from "lib/errors"; @@ -22,10 +21,7 @@ export default createService({ } // 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; + const serverTime = await getServerTime(trx); // 3. Validate state transition if (existing.status === "confirmed") { diff --git a/apps/server/src/services/allocation/create.ts b/apps/server/src/services/allocation/create.ts index 0095eb8..e7160cf 100644 --- a/apps/server/src/services/allocation/create.ts +++ b/apps/server/src/services/allocation/create.ts @@ -1,5 +1,4 @@ -import { sql } from "kysely"; -import { db } from "database"; +import { db, getServerTime } from "database"; import { createService } from "lib/service"; import { generateId } from "@floyd-run/utils"; import { allocation } from "@floyd-run/schema/inputs"; @@ -23,10 +22,7 @@ export default createService({ } // 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; + const serverTime = await getServerTime(trx); // 3. Check for overlapping allocations that would block this request // Blocking allocations are: confirmed OR (hold AND not expired) diff --git a/apps/server/src/services/policy/create.ts b/apps/server/src/services/policy/create.ts index de981f0..4e485bb 100644 --- a/apps/server/src/services/policy/create.ts +++ b/apps/server/src/services/policy/create.ts @@ -2,24 +2,15 @@ import { db } from "database"; import { createService } from "lib/service"; import { generateId } from "@floyd-run/utils"; import { policy } from "@floyd-run/schema/inputs"; -import { normalizePolicyConfig } from "domain/policy/normalize"; -import { canonicalizePolicyConfig, hashPolicyConfig } from "domain/policy/canonicalize"; -import { validatePolicyConfig } from "domain/policy/validate"; +import { preparePolicyConfig } from "domain/policy"; export default createService({ input: policy.createSchema, execute: async (input) => { - // 1. Normalize authoring format → canonical ms format - const normalized = normalizePolicyConfig(input.config as unknown as Record); + const { normalized, configHash, warnings } = preparePolicyConfig( + input.config as unknown as Record, + ); - // 2. Validate and generate warnings - const { warnings } = validatePolicyConfig(normalized); - - // 3. Canonicalize → hash - const canonicalJson = canonicalizePolicyConfig(normalized); - const configHash = hashPolicyConfig(canonicalJson); - - // 4. Insert const row = await db .insertInto("policies") .values({ diff --git a/apps/server/src/services/policy/update.ts b/apps/server/src/services/policy/update.ts index cf49f9f..5106e96 100644 --- a/apps/server/src/services/policy/update.ts +++ b/apps/server/src/services/policy/update.ts @@ -2,9 +2,7 @@ import { db } from "database"; import { createService } from "lib/service"; import { policy } from "@floyd-run/schema/inputs"; import { NotFoundError } from "lib/errors"; -import { normalizePolicyConfig } from "domain/policy/normalize"; -import { canonicalizePolicyConfig, hashPolicyConfig } from "domain/policy/canonicalize"; -import { validatePolicyConfig } from "domain/policy/validate"; +import { preparePolicyConfig } from "domain/policy"; export default createService({ input: policy.updateSchema, @@ -21,10 +19,9 @@ export default createService({ } // 2. Normalize, validate, canonicalize, hash - const normalized = normalizePolicyConfig(input.config as unknown as Record); - const { warnings } = validatePolicyConfig(normalized); - const canonicalJson = canonicalizePolicyConfig(normalized); - const configHash = hashPolicyConfig(canonicalJson); + const { normalized, configHash, warnings } = preparePolicyConfig( + input.config as unknown as Record, + ); // 3. Update const row = await db diff --git a/apps/server/src/services/webhook/create.ts b/apps/server/src/services/webhook/create.ts index 7f1cb0d..aa6f5a7 100644 --- a/apps/server/src/services/webhook/create.ts +++ b/apps/server/src/services/webhook/create.ts @@ -2,11 +2,7 @@ import { db } from "database"; import { createService } from "lib/service"; import { generateId } from "@floyd-run/utils"; import { webhook } from "@floyd-run/schema/inputs"; -import { randomBytes } from "crypto"; - -function generateSecret(): string { - return `whsec_${randomBytes(24).toString("base64url")}`; -} +import { generateSecret } from "./generate-secret"; export default createService({ input: webhook.createSubscriptionSchema, diff --git a/apps/server/src/services/webhook/generate-secret.ts b/apps/server/src/services/webhook/generate-secret.ts new file mode 100644 index 0000000..fc82957 --- /dev/null +++ b/apps/server/src/services/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/rotate-secret.ts b/apps/server/src/services/webhook/rotate-secret.ts index 7f13308..559550c 100644 --- a/apps/server/src/services/webhook/rotate-secret.ts +++ b/apps/server/src/services/webhook/rotate-secret.ts @@ -1,11 +1,7 @@ import { db } from "database"; import { createService } from "lib/service"; import { webhook } from "@floyd-run/schema/inputs"; -import { randomBytes } from "crypto"; - -function generateSecret(): string { - return `whsec_${randomBytes(24).toString("base64url")}`; -} +import { generateSecret } from "./generate-secret"; export default createService({ input: webhook.rotateSecretSchema, diff --git a/apps/server/src/workers/expiration-worker.ts b/apps/server/src/workers/expiration-worker.ts index 63ef2c8..93093b8 100644 --- a/apps/server/src/workers/expiration-worker.ts +++ b/apps/server/src/workers/expiration-worker.ts @@ -1,5 +1,4 @@ -import { db } from "database"; -import { sql } from "kysely"; +import { db, getServerTime } from "database"; import { logger } from "infra/logger"; import { enqueueWebhookEvent } from "infra/webhooks"; @@ -10,11 +9,7 @@ let isRunning = false; async function processExpiredHolds(): 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; + const serverTime = await getServerTime(trx); // Find expired holds and lock them const expiredHolds = await trx diff --git a/apps/server/test/integration/setup/factories/policy.factory.ts b/apps/server/test/integration/setup/factories/policy.factory.ts index 1139e84..8fdb2d9 100644 --- a/apps/server/test/integration/setup/factories/policy.factory.ts +++ b/apps/server/test/integration/setup/factories/policy.factory.ts @@ -1,8 +1,7 @@ import { db } from "database"; import { generateId } from "@floyd-run/utils"; import { createLedger } from "./ledger.factory"; -import { normalizePolicyConfig } from "domain/policy/normalize"; -import { canonicalizePolicyConfig, hashPolicyConfig } from "domain/policy/canonicalize"; +import { preparePolicyConfig } from "domain/policy"; const DEFAULT_CONFIG = { schema_version: 1, @@ -30,9 +29,7 @@ export async function createPolicy(overrides?: { } const rawConfig = overrides?.config ?? DEFAULT_CONFIG; - const normalized = normalizePolicyConfig(rawConfig); - const canonicalJson = canonicalizePolicyConfig(normalized); - const configHash = hashPolicyConfig(canonicalJson); + const { normalized, configHash } = preparePolicyConfig(rawConfig); const policy = await db .insertInto("policies") diff --git a/apps/server/test/integration/v1/ledgers/list.spec.ts b/apps/server/test/integration/v1/ledgers/list.spec.ts index afa1bb0..f54910f 100644 --- a/apps/server/test/integration/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/resources/list.spec.ts b/apps/server/test/integration/v1/resources/list.spec.ts index a2c9f64..f3354a8 100644 --- a/apps/server/test/integration/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; From b2582b3d2d8bfe0019453e5ecdbb254db0083c48 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 07:24:35 +0300 Subject: [PATCH 05/31] Services to operations --- apps/server/eslint.config.js | 2 +- apps/server/src/lib/{service.ts => operation.ts} | 6 +++--- .../allocation/cancel.ts | 4 ++-- .../allocation/confirm.ts | 4 ++-- .../allocation/create.ts | 4 ++-- .../{services => operations}/allocation/get.ts | 4 ++-- .../{services => operations}/allocation/index.ts | 0 .../{services => operations}/allocation/list.ts | 4 ++-- .../availability/index.ts | 0 .../availability/query.ts | 4 ++-- .../server/src/{services => operations}/index.ts | 2 +- .../{services => operations}/ledger/create.ts | 4 ++-- .../src/{services => operations}/ledger/get.ts | 4 ++-- .../src/{services => operations}/ledger/index.ts | 0 .../src/{services => operations}/ledger/list.ts | 4 ++-- .../{services => operations}/policy/create.ts | 4 ++-- .../src/{services => operations}/policy/get.ts | 4 ++-- .../src/{services => operations}/policy/index.ts | 0 .../src/{services => operations}/policy/list.ts | 4 ++-- .../{services => operations}/policy/remove.ts | 4 ++-- .../{services => operations}/policy/update.ts | 4 ++-- .../{services => operations}/resource/create.ts | 4 ++-- .../src/{services => operations}/resource/get.ts | 4 ++-- .../{services => operations}/resource/index.ts | 0 .../{services => operations}/resource/list.ts | 4 ++-- .../{services => operations}/resource/remove.ts | 4 ++-- .../{services => operations}/webhook/create.ts | 4 ++-- .../webhook/generate-secret.ts | 0 .../{services => operations}/webhook/index.ts | 0 .../src/{services => operations}/webhook/list.ts | 4 ++-- .../{services => operations}/webhook/remove.ts | 4 ++-- .../webhook/rotate-secret.ts | 4 ++-- .../{services => operations}/webhook/update.ts | 4 ++-- apps/server/src/routes/v1/allocations.ts | 14 +++++++------- apps/server/src/routes/v1/availability.ts | 4 ++-- apps/server/src/routes/v1/ledgers.ts | 8 ++++---- apps/server/src/routes/v1/policies.ts | 16 ++++++++-------- apps/server/src/routes/v1/resources.ts | 10 +++++----- apps/server/src/routes/v1/webhooks.ts | 12 ++++++------ apps/server/test/integration/vitest.config.ts | 2 +- 40 files changed, 84 insertions(+), 84 deletions(-) rename apps/server/src/lib/{service.ts => operation.ts} (78%) rename apps/server/src/{services => operations}/allocation/cancel.ts (95%) rename apps/server/src/{services => operations}/allocation/confirm.ts (96%) rename apps/server/src/{services => operations}/allocation/create.ts (97%) rename apps/server/src/{services => operations}/allocation/get.ts (80%) rename apps/server/src/{services => operations}/allocation/index.ts (100%) rename apps/server/src/{services => operations}/allocation/list.ts (80%) rename apps/server/src/{services => operations}/availability/index.ts (100%) rename apps/server/src/{services => operations}/availability/query.ts (96%) rename apps/server/src/{services => operations}/index.ts (91%) rename apps/server/src/{services => operations}/ledger/create.ts (78%) rename apps/server/src/{services => operations}/ledger/get.ts (79%) rename apps/server/src/{services => operations}/ledger/index.ts (100%) rename apps/server/src/{services => operations}/ledger/list.ts (66%) rename apps/server/src/{services => operations}/policy/create.ts (89%) rename apps/server/src/{services => operations}/policy/get.ts (79%) rename apps/server/src/{services => operations}/policy/index.ts (100%) rename apps/server/src/{services => operations}/policy/list.ts (79%) rename apps/server/src/{services => operations}/policy/remove.ts (78%) rename apps/server/src/{services => operations}/policy/update.ts (92%) rename apps/server/src/{services => operations}/resource/create.ts (84%) rename apps/server/src/{services => operations}/resource/get.ts (79%) rename apps/server/src/{services => operations}/resource/index.ts (100%) rename apps/server/src/{services => operations}/resource/list.ts (79%) rename apps/server/src/{services => operations}/resource/remove.ts (75%) rename apps/server/src/{services => operations}/webhook/create.ts (87%) rename apps/server/src/{services => operations}/webhook/generate-secret.ts (100%) rename apps/server/src/{services => operations}/webhook/index.ts (100%) rename apps/server/src/{services => operations}/webhook/list.ts (82%) rename apps/server/src/{services => operations}/webhook/remove.ts (81%) rename apps/server/src/{services => operations}/webhook/rotate-secret.ts (86%) rename apps/server/src/{services => operations}/webhook/update.ts (84%) diff --git a/apps/server/eslint.config.js b/apps/server/eslint.config.js index dead95e..89caffe 100644 --- a/apps/server/eslint.config.js +++ b/apps/server/eslint.config.js @@ -10,7 +10,7 @@ export default [ { patterns: [ { - group: ["database/*", "infra/*", "services/*", "config/*", "routes/*", "workers/*"], + group: ["database/*", "infra/*", "operations/*", "config/*", "routes/*", "workers/*"], message: "domain/ must be pure — no imports from app infrastructure layers.", }, ], diff --git a/apps/server/src/lib/service.ts b/apps/server/src/lib/operation.ts similarity index 78% rename from apps/server/src/lib/service.ts rename to apps/server/src/lib/operation.ts index 4809fd6..4d1396f 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 { 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/services/allocation/cancel.ts b/apps/server/src/operations/allocation/cancel.ts similarity index 95% rename from apps/server/src/services/allocation/cancel.ts rename to apps/server/src/operations/allocation/cancel.ts index adb0850..8f177d0 100644 --- a/apps/server/src/services/allocation/cancel.ts +++ b/apps/server/src/operations/allocation/cancel.ts @@ -1,10 +1,10 @@ import { db, getServerTime } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { allocation } from "@floyd-run/schema/inputs"; import { ConflictError, NotFoundError } from "lib/errors"; import { enqueueWebhookEvent } from "infra/webhooks"; -export default createService({ +export default createOperation({ input: allocation.cancelSchema, execute: async (input) => { return await db.transaction().execute(async (trx) => { diff --git a/apps/server/src/services/allocation/confirm.ts b/apps/server/src/operations/allocation/confirm.ts similarity index 96% rename from apps/server/src/services/allocation/confirm.ts rename to apps/server/src/operations/allocation/confirm.ts index 29b1733..b42369f 100644 --- a/apps/server/src/services/allocation/confirm.ts +++ b/apps/server/src/operations/allocation/confirm.ts @@ -1,10 +1,10 @@ import { db, getServerTime } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { allocation } from "@floyd-run/schema/inputs"; import { ConflictError, NotFoundError } from "lib/errors"; import { enqueueWebhookEvent } from "infra/webhooks"; -export default createService({ +export default createOperation({ input: allocation.confirmSchema, execute: async (input) => { return await db.transaction().execute(async (trx) => { diff --git a/apps/server/src/services/allocation/create.ts b/apps/server/src/operations/allocation/create.ts similarity index 97% rename from apps/server/src/services/allocation/create.ts rename to apps/server/src/operations/allocation/create.ts index e7160cf..6887602 100644 --- a/apps/server/src/services/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -1,11 +1,11 @@ import { db, getServerTime } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; 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({ +export default createOperation({ input: allocation.createSchema, execute: async (input) => { return await db.transaction().execute(async (trx) => { diff --git a/apps/server/src/services/allocation/get.ts b/apps/server/src/operations/allocation/get.ts similarity index 80% rename from apps/server/src/services/allocation/get.ts rename to apps/server/src/operations/allocation/get.ts index f36294e..bb3c6ca 100644 --- a/apps/server/src/services/allocation/get.ts +++ b/apps/server/src/operations/allocation/get.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { allocation } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: allocation.getSchema, execute: async (input) => { const allocation = await db diff --git a/apps/server/src/services/allocation/index.ts b/apps/server/src/operations/allocation/index.ts similarity index 100% rename from apps/server/src/services/allocation/index.ts rename to apps/server/src/operations/allocation/index.ts diff --git a/apps/server/src/services/allocation/list.ts b/apps/server/src/operations/allocation/list.ts similarity index 80% rename from apps/server/src/services/allocation/list.ts rename to apps/server/src/operations/allocation/list.ts index 8ddcca9..0f49528 100644 --- a/apps/server/src/services/allocation/list.ts +++ b/apps/server/src/operations/allocation/list.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { allocation } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: allocation.listSchema, execute: async (input) => { const allocations = await db diff --git a/apps/server/src/services/availability/index.ts b/apps/server/src/operations/availability/index.ts similarity index 100% rename from apps/server/src/services/availability/index.ts rename to apps/server/src/operations/availability/index.ts diff --git a/apps/server/src/services/availability/query.ts b/apps/server/src/operations/availability/query.ts similarity index 96% rename from apps/server/src/services/availability/query.ts rename to apps/server/src/operations/availability/query.ts index 17ea8da..060c70d 100644 --- a/apps/server/src/services/availability/query.ts +++ b/apps/server/src/operations/availability/query.ts @@ -1,6 +1,6 @@ import { sql } from "kysely"; import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { availability } from "@floyd-run/schema/inputs"; import type { AvailabilityItem } from "@floyd-run/schema/types"; import { clampInterval, mergeIntervals, buildTimeline } from "domain/scheduling/timeline"; @@ -11,7 +11,7 @@ interface BlockingAllocation { endAt: Date; } -export default createService({ +export default createOperation({ input: availability.querySchema, execute: async (input): Promise<{ items: AvailabilityItem[] }> => { const { ledgerId, resourceIds, startAt, endAt } = input; diff --git a/apps/server/src/services/index.ts b/apps/server/src/operations/index.ts similarity index 91% rename from apps/server/src/services/index.ts rename to apps/server/src/operations/index.ts index 478223e..65289a7 100644 --- a/apps/server/src/services/index.ts +++ b/apps/server/src/operations/index.ts @@ -5,7 +5,7 @@ import { ledger } from "./ledger"; import { webhook } from "./webhook"; import { policy } from "./policy"; -export const services = { +export const operations = { allocation, availability, resource, 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 79% rename from apps/server/src/services/ledger/get.ts rename to apps/server/src/operations/ledger/get.ts index 5efccc1..9f826b7 100644 --- a/apps/server/src/services/ledger/get.ts +++ b/apps/server/src/operations/ledger/get.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { ledger } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: ledger.getSchema, execute: async (input) => { const ledger = await db 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/services/policy/create.ts b/apps/server/src/operations/policy/create.ts similarity index 89% rename from apps/server/src/services/policy/create.ts rename to apps/server/src/operations/policy/create.ts index 4e485bb..2e24a81 100644 --- a/apps/server/src/services/policy/create.ts +++ b/apps/server/src/operations/policy/create.ts @@ -1,10 +1,10 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; import { policy } from "@floyd-run/schema/inputs"; import { preparePolicyConfig } from "domain/policy"; -export default createService({ +export default createOperation({ input: policy.createSchema, execute: async (input) => { const { normalized, configHash, warnings } = preparePolicyConfig( diff --git a/apps/server/src/services/policy/get.ts b/apps/server/src/operations/policy/get.ts similarity index 79% rename from apps/server/src/services/policy/get.ts rename to apps/server/src/operations/policy/get.ts index b28cc5c..556add3 100644 --- a/apps/server/src/services/policy/get.ts +++ b/apps/server/src/operations/policy/get.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { policy } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: policy.getSchema, execute: async (input) => { const row = await db diff --git a/apps/server/src/services/policy/index.ts b/apps/server/src/operations/policy/index.ts similarity index 100% rename from apps/server/src/services/policy/index.ts rename to apps/server/src/operations/policy/index.ts diff --git a/apps/server/src/services/policy/list.ts b/apps/server/src/operations/policy/list.ts similarity index 79% rename from apps/server/src/services/policy/list.ts rename to apps/server/src/operations/policy/list.ts index 6b2b3ab..0c67a28 100644 --- a/apps/server/src/services/policy/list.ts +++ b/apps/server/src/operations/policy/list.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { policy } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: policy.listSchema, execute: async (input) => { const policies = await db diff --git a/apps/server/src/services/policy/remove.ts b/apps/server/src/operations/policy/remove.ts similarity index 78% rename from apps/server/src/services/policy/remove.ts rename to apps/server/src/operations/policy/remove.ts index 04542fb..8994ac7 100644 --- a/apps/server/src/services/policy/remove.ts +++ b/apps/server/src/operations/policy/remove.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { policy } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: policy.removeSchema, execute: async (input) => { const result = await db.deleteFrom("policies").where("id", "=", input.id).executeTakeFirst(); diff --git a/apps/server/src/services/policy/update.ts b/apps/server/src/operations/policy/update.ts similarity index 92% rename from apps/server/src/services/policy/update.ts rename to apps/server/src/operations/policy/update.ts index 5106e96..56db225 100644 --- a/apps/server/src/services/policy/update.ts +++ b/apps/server/src/operations/policy/update.ts @@ -1,10 +1,10 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { policy } from "@floyd-run/schema/inputs"; import { NotFoundError } from "lib/errors"; import { preparePolicyConfig } from "domain/policy"; -export default createService({ +export default createOperation({ input: policy.updateSchema, execute: async (input) => { // 1. Verify policy exists diff --git a/apps/server/src/services/resource/create.ts b/apps/server/src/operations/resource/create.ts similarity index 84% rename from apps/server/src/services/resource/create.ts rename to apps/server/src/operations/resource/create.ts index 7387b65..92763ce 100644 --- a/apps/server/src/services/resource/create.ts +++ b/apps/server/src/operations/resource/create.ts @@ -1,9 +1,9 @@ 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"; -export default createService({ +export default createOperation({ input: resource.createSchema, execute: async (input) => { const resource = await db diff --git a/apps/server/src/services/resource/get.ts b/apps/server/src/operations/resource/get.ts similarity index 79% rename from apps/server/src/services/resource/get.ts rename to apps/server/src/operations/resource/get.ts index 503ac1c..e8670d0 100644 --- a/apps/server/src/services/resource/get.ts +++ b/apps/server/src/operations/resource/get.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { resource } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: resource.getSchema, execute: async (input) => { const resource = await db 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 79% rename from apps/server/src/services/resource/list.ts rename to apps/server/src/operations/resource/list.ts index 3bcaded..7fff786 100644 --- a/apps/server/src/services/resource/list.ts +++ b/apps/server/src/operations/resource/list.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { resource } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: resource.listSchema, execute: async (input) => { const resources = await db diff --git a/apps/server/src/services/resource/remove.ts b/apps/server/src/operations/resource/remove.ts similarity index 75% rename from apps/server/src/services/resource/remove.ts rename to apps/server/src/operations/resource/remove.ts index bc09b88..d13c0bb 100644 --- a/apps/server/src/services/resource/remove.ts +++ b/apps/server/src/operations/resource/remove.ts @@ -1,8 +1,8 @@ import { resource } from "@floyd-run/schema/inputs"; import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; -export default createService({ +export default createOperation({ input: resource.removeSchema, execute: async (input) => { await db.deleteFrom("resources").where("id", "=", input.id).executeTakeFirstOrThrow(); diff --git a/apps/server/src/services/webhook/create.ts b/apps/server/src/operations/webhook/create.ts similarity index 87% rename from apps/server/src/services/webhook/create.ts rename to apps/server/src/operations/webhook/create.ts index aa6f5a7..6643e97 100644 --- a/apps/server/src/services/webhook/create.ts +++ b/apps/server/src/operations/webhook/create.ts @@ -1,10 +1,10 @@ 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 { generateSecret } from "./generate-secret"; -export default createService({ +export default createOperation({ input: webhook.createSubscriptionSchema, execute: async (input) => { const subscription = await db diff --git a/apps/server/src/services/webhook/generate-secret.ts b/apps/server/src/operations/webhook/generate-secret.ts similarity index 100% rename from apps/server/src/services/webhook/generate-secret.ts rename to apps/server/src/operations/webhook/generate-secret.ts 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 82% rename from apps/server/src/services/webhook/list.ts rename to apps/server/src/operations/webhook/list.ts index 36a788c..91faa16 100644 --- a/apps/server/src/services/webhook/list.ts +++ b/apps/server/src/operations/webhook/list.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { webhook } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: webhook.listSubscriptionsSchema, execute: async (input) => { const subscriptions = await db diff --git a/apps/server/src/services/webhook/remove.ts b/apps/server/src/operations/webhook/remove.ts similarity index 81% rename from apps/server/src/services/webhook/remove.ts rename to apps/server/src/operations/webhook/remove.ts index dfcc81a..eb33fa2 100644 --- a/apps/server/src/services/webhook/remove.ts +++ b/apps/server/src/operations/webhook/remove.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { webhook } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: webhook.deleteSubscriptionSchema, execute: async (input) => { const result = await db diff --git a/apps/server/src/services/webhook/rotate-secret.ts b/apps/server/src/operations/webhook/rotate-secret.ts similarity index 86% rename from apps/server/src/services/webhook/rotate-secret.ts rename to apps/server/src/operations/webhook/rotate-secret.ts index 559550c..41a1c59 100644 --- a/apps/server/src/services/webhook/rotate-secret.ts +++ b/apps/server/src/operations/webhook/rotate-secret.ts @@ -1,9 +1,9 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { webhook } from "@floyd-run/schema/inputs"; import { generateSecret } from "./generate-secret"; -export default createService({ +export default createOperation({ input: webhook.rotateSecretSchema, execute: async (input) => { const newSecret = generateSecret(); diff --git a/apps/server/src/services/webhook/update.ts b/apps/server/src/operations/webhook/update.ts similarity index 84% rename from apps/server/src/services/webhook/update.ts rename to apps/server/src/operations/webhook/update.ts index 50b5726..1c48b82 100644 --- a/apps/server/src/services/webhook/update.ts +++ b/apps/server/src/operations/webhook/update.ts @@ -1,8 +1,8 @@ import { db } from "database"; -import { createService } from "lib/service"; +import { createOperation } from "lib/operation"; import { webhook } from "@floyd-run/schema/inputs"; -export default createService({ +export default createOperation({ input: webhook.updateSubscriptionSchema, execute: async (input) => { const subscription = await db diff --git a/apps/server/src/routes/v1/allocations.ts b/apps/server/src/routes/v1/allocations.ts index c3c842f..82efd98 100644 --- a/apps/server/src/routes/v1/allocations.ts +++ b/apps/server/src/routes/v1/allocations.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "../../operations/index.js"; import { NotFoundError } from "lib/errors"; import { idempotent, storeIdempotencyResponse, IdempotencyVariables } from "infra/idempotency"; import { serializeAllocation } from "./serializers"; @@ -10,31 +10,31 @@ const ALLOCATION_SIGNIFICANT_FIELDS = ["resourceId", "startAt", "endAt", "status // 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") }); 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 { allocation, serverTime } = await operations.allocation.create({ ...(body as object), ledgerId: c.req.param("ledgerId")!, - } as Parameters[0]); + } 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({ + const { allocation, serverTime } = await operations.allocation.confirm({ id: c.req.param("id"), }); const responseBody = { data: serializeAllocation(allocation), meta: { serverTime } }; @@ -43,7 +43,7 @@ export const allocations = new Hono<{ Variables: IdempotencyVariables }>() }) .post("/:id/cancel", idempotent(), async (c) => { - const { allocation, serverTime } = await services.allocation.cancel({ + const { allocation, serverTime } = await operations.allocation.cancel({ id: c.req.param("id"), }); const responseBody = { data: serializeAllocation(allocation), meta: { serverTime } }; diff --git a/apps/server/src/routes/v1/availability.ts b/apps/server/src/routes/v1/availability.ts index 8d92bb4..b841fc7 100644 --- a/apps/server/src/routes/v1/availability.ts +++ b/apps/server/src/routes/v1/availability.ts @@ -1,12 +1,12 @@ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "../../operations/index.js"; // 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, diff --git a/apps/server/src/routes/v1/ledgers.ts b/apps/server/src/routes/v1/ledgers.ts index 0fc0d02..eaf8a12 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/index.js"; 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 index 0aa6f82..03093d7 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -1,29 +1,29 @@ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "../../operations/index.js"; 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 services.policy.list({ + 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 services.policy.get({ id: c.req.param("id") }); + const { policy } = await operations.policy.get({ id: c.req.param("id") }); 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 services.policy.create({ + const { policy, warnings } = await operations.policy.create({ ...(body as object), ledgerId: c.req.param("ledgerId")!, - } as Parameters[0]); + } as Parameters[0]); const responseBody: Record = { data: serializePolicy(policy) }; if (warnings.length > 0) { @@ -34,10 +34,10 @@ export const policies = new Hono() .put("/:id", async (c) => { const body = await c.req.json(); - const { policy, warnings } = await services.policy.update({ + const { policy, warnings } = await operations.policy.update({ ...(body as object), id: c.req.param("id")!, - } as Parameters[0]); + } as Parameters[0]); const responseBody: Record = { data: serializePolicy(policy) }; if (warnings.length > 0) { @@ -47,7 +47,7 @@ export const policies = new Hono() }) .delete("/:id", async (c) => { - const { deleted } = await services.policy.remove({ id: c.req.param("id") }); + const { deleted } = await operations.policy.remove({ id: c.req.param("id") }); 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..51038d4 100644 --- a/apps/server/src/routes/v1/resources.ts +++ b/apps/server/src/routes/v1/resources.ts @@ -1,26 +1,26 @@ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "../../operations/index.js"; 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") }); 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"), }); @@ -28,6 +28,6 @@ export const resources = new Hono() }) .delete("/:id", async (c) => { - await services.resource.remove({ id: c.req.param("id") }); + await operations.resource.remove({ id: c.req.param("id") }); return c.body(null, 204); }); diff --git a/apps/server/src/routes/v1/webhooks.ts b/apps/server/src/routes/v1/webhooks.ts index 859bcd8..b62763e 100644 --- a/apps/server/src/routes/v1/webhooks.ts +++ b/apps/server/src/routes/v1/webhooks.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { services } from "../../services/index.js"; +import { operations } from "../../operations/index.js"; import { NotFoundError } from "lib/errors"; import { serializeWebhookSubscription } from "./serializers"; @@ -7,7 +7,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 +16,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")!, }); @@ -36,7 +36,7 @@ export const webhooks = new Hono() // Update subscription .patch("/:subscriptionId", 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")!, }); @@ -50,7 +50,7 @@ export const webhooks = new Hono() // Delete subscription .delete("/:subscriptionId", async (c) => { - const { deleted } = await services.webhook.remove({ + const { deleted } = await operations.webhook.remove({ id: c.req.param("subscriptionId")!, }); @@ -63,7 +63,7 @@ export const webhooks = new Hono() // Rotate secret .post("/:subscriptionId/rotate-secret", async (c) => { - const { subscription, secret } = await services.webhook.rotateSecret({ + const { subscription, secret } = await operations.webhook.rotateSecret({ id: c.req.param("subscriptionId")!, }); diff --git a/apps/server/test/integration/vitest.config.ts b/apps/server/test/integration/vitest.config.ts index 3971c95..264c699 100644 --- a/apps/server/test/integration/vitest.config.ts +++ b/apps/server/test/integration/vitest.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ middleware: path.resolve(__dirname, "../../src/middleware"), migrations: path.resolve(__dirname, "../../src/migrations"), routes: path.resolve(__dirname, "../../src/routes"), - services: path.resolve(__dirname, "../../src/services"), + operations: path.resolve(__dirname, "../../src/operations"), }, }, }); From 3fadd31845085120e95eeec657ddf1d362673d4c Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 07:25:44 +0300 Subject: [PATCH 06/31] Rename some imports --- apps/server/src/routes/v1/allocations.ts | 2 +- apps/server/src/routes/v1/availability.ts | 2 +- apps/server/src/routes/v1/ledgers.ts | 2 +- apps/server/src/routes/v1/policies.ts | 2 +- apps/server/src/routes/v1/resources.ts | 2 +- apps/server/src/routes/v1/webhooks.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/src/routes/v1/allocations.ts b/apps/server/src/routes/v1/allocations.ts index 82efd98..cbc6503 100644 --- a/apps/server/src/routes/v1/allocations.ts +++ b/apps/server/src/routes/v1/allocations.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { operations } from "../../operations/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { idempotent, storeIdempotencyResponse, IdempotencyVariables } from "infra/idempotency"; import { serializeAllocation } from "./serializers"; diff --git a/apps/server/src/routes/v1/availability.ts b/apps/server/src/routes/v1/availability.ts index b841fc7..01039e2 100644 --- a/apps/server/src/routes/v1/availability.ts +++ b/apps/server/src/routes/v1/availability.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { operations } from "../../operations/index.js"; +import { operations } from "operations"; // Nested under /v1/ledgers/:ledgerId/availability export const availability = new Hono().post("/", async (c) => { diff --git a/apps/server/src/routes/v1/ledgers.ts b/apps/server/src/routes/v1/ledgers.ts index eaf8a12..a3d011b 100644 --- a/apps/server/src/routes/v1/ledgers.ts +++ b/apps/server/src/routes/v1/ledgers.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { operations } from "../../operations/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { serializeLedger } from "./serializers"; diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts index 03093d7..c74a25f 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { operations } from "../../operations/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { serializePolicy } from "./serializers"; diff --git a/apps/server/src/routes/v1/resources.ts b/apps/server/src/routes/v1/resources.ts index 51038d4..582fa6d 100644 --- a/apps/server/src/routes/v1/resources.ts +++ b/apps/server/src/routes/v1/resources.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { operations } from "../../operations/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { serializeResource } from "./serializers"; diff --git a/apps/server/src/routes/v1/webhooks.ts b/apps/server/src/routes/v1/webhooks.ts index b62763e..54ec35b 100644 --- a/apps/server/src/routes/v1/webhooks.ts +++ b/apps/server/src/routes/v1/webhooks.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { operations } from "../../operations/index.js"; +import { operations } from "operations"; import { NotFoundError } from "lib/errors"; import { serializeWebhookSubscription } from "./serializers"; From 5bc2218d54d6962e7cb604bd9716473a0511a81c Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 19:59:05 +0300 Subject: [PATCH 07/31] Initial services and bookings --- apps/server/src/database/schema.ts | 47 ++- apps/server/src/infra/webhooks.ts | 23 +- ...2153727_create-bookings-services-tables.ts | 165 +++++++++++ .../src/operations/allocation/cancel.ts | 59 ---- .../src/operations/allocation/confirm.ts | 76 ----- .../src/operations/allocation/create.ts | 39 +-- .../server/src/operations/allocation/index.ts | 6 +- .../src/operations/allocation/remove.ts | 42 +++ .../src/operations/availability/query.ts | 4 +- apps/server/src/operations/booking/cancel.ts | 78 +++++ apps/server/src/operations/booking/confirm.ts | 86 ++++++ apps/server/src/operations/booking/create.ts | 153 ++++++++++ apps/server/src/operations/booking/get.ts | 26 ++ apps/server/src/operations/booking/index.ts | 13 + apps/server/src/operations/booking/list.ts | 49 ++++ apps/server/src/operations/index.ts | 4 + apps/server/src/operations/resource/create.ts | 1 + apps/server/src/operations/service/create.ts | 65 +++++ apps/server/src/operations/service/get.ts | 26 ++ apps/server/src/operations/service/index.ts | 13 + apps/server/src/operations/service/list.ts | 46 +++ apps/server/src/operations/service/remove.ts | 54 ++++ apps/server/src/operations/service/update.ts | 77 +++++ apps/server/src/routes/v1/allocations.ts | 21 +- apps/server/src/routes/v1/bookings.ts | 54 ++++ apps/server/src/routes/v1/index.ts | 6 +- apps/server/src/routes/v1/serializers.ts | 41 ++- apps/server/src/routes/v1/services.ts | 45 +++ apps/server/src/workers/expiration-worker.ts | 91 ++++-- .../setup/factories/allocation.factory.ts | 7 +- .../setup/factories/booking.factory.ts | 91 ++++++ .../test/integration/setup/factories/index.ts | 2 + .../setup/factories/service.factory.ts | 39 +++ apps/server/test/integration/setup/types.ts | 11 +- .../integration/v1/allocations/cancel.spec.ts | 115 -------- .../v1/allocations/confirm.spec.ts | 99 ------- .../integration/v1/allocations/create.spec.ts | 104 ++----- .../integration/v1/allocations/delete.spec.ts | 50 ++++ .../integration/v1/allocations/get.spec.ts | 3 +- .../v1/allocations/idempotency.spec.ts | 84 ------ .../integration/v1/availability/query.spec.ts | 32 +-- .../integration/v1/bookings/cancel.spec.ts | 91 ++++++ .../integration/v1/bookings/confirm.spec.ts | 89 ++++++ .../integration/v1/bookings/create.spec.ts | 270 ++++++++++++++++++ .../test/integration/v1/bookings/get.spec.ts | 40 +++ .../test/integration/v1/bookings/list.spec.ts | 47 +++ .../integration/v1/services/create.spec.ts | 141 +++++++++ .../integration/v1/services/delete.spec.ts | 74 +++++ .../test/integration/v1/services/get.spec.ts | 54 ++++ .../test/integration/v1/services/list.spec.ts | 44 +++ .../integration/v1/services/update.spec.ts | 114 ++++++++ packages/schema/constants/index.ts | 14 +- packages/schema/inputs/allocation.ts | 12 - packages/schema/inputs/booking.ts | 29 ++ packages/schema/inputs/index.ts | 2 + packages/schema/inputs/resource.ts | 1 + packages/schema/inputs/service.ts | 43 +++ packages/schema/outputs/allocation.ts | 4 +- packages/schema/outputs/booking.ts | 40 +++ packages/schema/outputs/index.ts | 2 + packages/schema/outputs/resource.ts | 1 + packages/schema/outputs/service.ts | 20 ++ packages/schema/types/index.ts | 6 +- packages/utils/id.ts | 4 +- 64 files changed, 2535 insertions(+), 654 deletions(-) create mode 100644 apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts delete mode 100644 apps/server/src/operations/allocation/cancel.ts delete mode 100644 apps/server/src/operations/allocation/confirm.ts create mode 100644 apps/server/src/operations/allocation/remove.ts create mode 100644 apps/server/src/operations/booking/cancel.ts create mode 100644 apps/server/src/operations/booking/confirm.ts create mode 100644 apps/server/src/operations/booking/create.ts create mode 100644 apps/server/src/operations/booking/get.ts create mode 100644 apps/server/src/operations/booking/index.ts create mode 100644 apps/server/src/operations/booking/list.ts create mode 100644 apps/server/src/operations/service/create.ts create mode 100644 apps/server/src/operations/service/get.ts create mode 100644 apps/server/src/operations/service/index.ts create mode 100644 apps/server/src/operations/service/list.ts create mode 100644 apps/server/src/operations/service/remove.ts create mode 100644 apps/server/src/operations/service/update.ts create mode 100644 apps/server/src/routes/v1/bookings.ts create mode 100644 apps/server/src/routes/v1/services.ts create mode 100644 apps/server/test/integration/setup/factories/booking.factory.ts create mode 100644 apps/server/test/integration/setup/factories/service.factory.ts delete mode 100644 apps/server/test/integration/v1/allocations/cancel.spec.ts delete mode 100644 apps/server/test/integration/v1/allocations/confirm.spec.ts create mode 100644 apps/server/test/integration/v1/allocations/delete.spec.ts create mode 100644 apps/server/test/integration/v1/bookings/cancel.spec.ts create mode 100644 apps/server/test/integration/v1/bookings/confirm.spec.ts create mode 100644 apps/server/test/integration/v1/bookings/create.spec.ts create mode 100644 apps/server/test/integration/v1/bookings/get.spec.ts create mode 100644 apps/server/test/integration/v1/bookings/list.spec.ts create mode 100644 apps/server/test/integration/v1/services/create.spec.ts create mode 100644 apps/server/test/integration/v1/services/delete.spec.ts create mode 100644 apps/server/test/integration/v1/services/get.spec.ts create mode 100644 apps/server/test/integration/v1/services/list.spec.ts create mode 100644 apps/server/test/integration/v1/services/update.spec.ts create mode 100644 packages/schema/inputs/booking.ts create mode 100644 packages/schema/inputs/service.ts create mode 100644 packages/schema/outputs/booking.ts create mode 100644 packages/schema/outputs/service.ts diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index a67a37b..d6f41ae 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 | null; createdAt: Generated; updatedAt: Generated; } @@ -22,7 +23,8 @@ export interface AllocationsTable { id: string; ledgerId: string; resourceId: string; - status: AllocationStatus; + bookingId: string | null; + active: boolean; startAt: Date; endAt: Date; expiresAt: Date | null; @@ -31,6 +33,32 @@ export interface AllocationsTable { 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; + status: BookingStatus; + expiresAt: Date | null; + metadata: Record | null; + createdAt: Generated; + updatedAt: Generated; +} + export interface IdempotencyKeysTable { ledgerId: string; key: string; @@ -78,6 +106,9 @@ export interface PoliciesTable { export interface Database { allocations: AllocationsTable; + bookings: BookingsTable; + services: ServicesTable; + serviceResources: ServiceResourcesTable; idempotencyKeys: IdempotencyKeysTable; resources: ResourcesTable; ledgers: LedgersTable; @@ -98,6 +129,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; diff --git a/apps/server/src/infra/webhooks.ts b/apps/server/src/infra/webhooks.ts index 0162369..4483cbb 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.cancelled" + | "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, 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..1b50bf4 --- /dev/null +++ b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts @@ -0,0 +1,165 @@ +import type { Database } from "database/schema"; +import { Kysely, sql } from "kysely"; +import { addUpdatedAtTrigger } from "./utils"; + +export async function up(db: Kysely): Promise { + // 1. Add timezone to resources + await db.schema.alterTable("resources").addColumn("timezone", "varchar(64)").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("status", "varchar(50)", (col) => + col.notNull().check(sql`status IN ('hold', 'confirmed', 'cancelled', 'expired')`), + ) + .addColumn("expires_at", "timestamptz") + .addCheckConstraint( + "bookings_expires_at_consistency", + sql`(status = 'hold' AND expires_at IS NOT NULL) OR (status IN ('confirmed', 'cancelled', '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 + // 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(); + + // Drop old indexes + await db.schema.dropIndex("idx_allocations_status").ifExists().execute(); + await db.schema.dropIndex("idx_allocations_time_range").ifExists().execute(); + + // Add new indexes + await sql` + CREATE INDEX idx_allocations_active + ON allocations(resource_id, start_at, end_at) + 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(); + + await sql` + CREATE INDEX idx_allocations_time_range ON allocations + USING GIST (resource_id, tstzrange(start_at, end_at, '[)')) + `.execute(db); + + // Drop new columns + 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(); + + // Remove timezone from resources + await db.schema.alterTable("resources").dropColumn("timezone").execute(); +} diff --git a/apps/server/src/operations/allocation/cancel.ts b/apps/server/src/operations/allocation/cancel.ts deleted file mode 100644 index 8f177d0..0000000 --- a/apps/server/src/operations/allocation/cancel.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { db, getServerTime } from "database"; -import { createOperation } from "lib/operation"; -import { allocation } from "@floyd-run/schema/inputs"; -import { ConflictError, NotFoundError } from "lib/errors"; -import { enqueueWebhookEvent } from "infra/webhooks"; - -export default createOperation({ - 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 serverTime = await getServerTime(trx); - - // 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/operations/allocation/confirm.ts b/apps/server/src/operations/allocation/confirm.ts deleted file mode 100644 index b42369f..0000000 --- a/apps/server/src/operations/allocation/confirm.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { db, getServerTime } from "database"; -import { createOperation } from "lib/operation"; -import { allocation } from "@floyd-run/schema/inputs"; -import { ConflictError, NotFoundError } from "lib/errors"; -import { enqueueWebhookEvent } from "infra/webhooks"; - -export default createOperation({ - 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 serverTime = await getServerTime(trx); - - // 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/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts index 6887602..76ae687 100644 --- a/apps/server/src/operations/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -4,6 +4,7 @@ import { generateId } from "@floyd-run/utils"; import { allocation } 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: allocation.createSchema, @@ -24,18 +25,13 @@ export default createOperation({ // 2. Capture server time immediately after acquiring lock const serverTime = await getServerTime(trx); - // 3. Check for overlapping allocations that would block this request - // Blocking allocations are: confirmed OR (hold AND not expired) + // 3. Check for overlapping active allocations (not expired) const conflicting = await trx .selectFrom("allocations") - .select(["id", "status", "startAt", "endAt"]) + .select(["id", "startAt", "endAt"]) .where("resourceId", "=", input.resourceId) - .where((eb) => - eb.or([ - eb("status", "=", "confirmed"), - eb.and([eb("status", "=", "hold"), eb("expiresAt", ">", serverTime)]), - ]), - ) + .where("active", "=", true) + .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", serverTime)])) // Overlap condition: existing.start < new.end AND existing.end > new.start .where("startAt", "<", input.endAt) .where("endAt", ">", input.startAt) @@ -47,36 +43,27 @@ export default createOperation({ }); } - // 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 + // 4. Insert the allocation const alloc = await trx .insertInto("allocations") .values({ id: generateId("alc"), ledgerId: input.ledgerId, resourceId: input.resourceId, - status: input.status, + bookingId: null, + active: true, startAt: input.startAt, endAt: input.endAt, - expiresAt, + expiresAt: input.expiresAt ?? null, metadata: input.metadata ?? null, }) .returningAll() .executeTakeFirstOrThrow(); - // 6. Enqueue webhook event (in same transaction) - await enqueueWebhookEvent(trx, "allocation.created", input.ledgerId, alloc); + // 5. Enqueue webhook event (in same transaction) + await enqueueWebhookEvent(trx, "allocation.created", input.ledgerId, { + allocation: serializeAllocation(alloc), + }); return { allocation: alloc, serverTime }; }); diff --git a/apps/server/src/operations/allocation/index.ts b/apps/server/src/operations/allocation/index.ts index f8a0c4c..ba8b4df 100644 --- a/apps/server/src/operations/allocation/index.ts +++ b/apps/server/src/operations/allocation/index.ts @@ -1,13 +1,11 @@ -import cancel from "./cancel"; -import confirm from "./confirm"; import create from "./create"; import get from "./get"; import list from "./list"; +import remove from "./remove"; export const allocation = { - cancel, - confirm, create, get, list, + remove, }; diff --git a/apps/server/src/operations/allocation/remove.ts b/apps/server/src/operations/allocation/remove.ts new file mode 100644 index 0000000..e7b7278 --- /dev/null +++ b/apps/server/src/operations/allocation/remove.ts @@ -0,0 +1,42 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { allocation } 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: allocation.removeSchema, + 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) + .forUpdate() + .executeTakeFirst(); + + if (!existing) { + throw new NotFoundError("Allocation not found"); + } + + // 2. Cannot delete booking-owned allocations + if (existing.bookingId !== null) { + throw new ConflictError("booking_owned_allocation", { + 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/query.ts b/apps/server/src/operations/availability/query.ts index 060c70d..dfd80d0 100644 --- a/apps/server/src/operations/availability/query.ts +++ b/apps/server/src/operations/availability/query.ts @@ -22,8 +22,8 @@ export default createOperation({ 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 active = true + AND (expires_at IS NULL OR expires_at > clock_timestamp()) AND start_at < ${endAt} AND end_at > ${startAt} ORDER BY resource_id, start_at diff --git a/apps/server/src/operations/booking/cancel.ts b/apps/server/src/operations/booking/cancel.ts new file mode 100644 index 0000000..fe66472 --- /dev/null +++ b/apps/server/src/operations/booking/cancel.ts @@ -0,0 +1,78 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { booking } 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: booking.cancelSchema, + 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) + .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 === "cancelled") { + // Idempotent — already cancelled + 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("invalid_state_transition", { + currentStatus: existing.status, + requestedStatus: "cancelled", + }); + } + + // 4. Update booking + const bkg = await trx + .updateTable("bookings") + .set({ + status: "cancelled", + 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.cancelled", bkg.ledgerId, { + booking: serializeBooking(bkg, allocations), + }); + + return { booking: bkg, 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..81ba5d6 --- /dev/null +++ b/apps/server/src/operations/booking/confirm.ts @@ -0,0 +1,86 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { booking } 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: booking.confirmSchema, + 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) + .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("invalid_state_transition", { + currentStatus: existing.status, + requestedStatus: "confirmed", + }); + } + + // 4. Check if hold has expired + if (existing.expiresAt && serverTime >= existing.expiresAt) { + throw new ConflictError("hold_expired", { + expiresAt: existing.expiresAt, + serverTime, + }); + } + + // 5. Update booking + const bkg = 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", bkg.ledgerId, { + booking: serializeBooking(bkg, allocations), + }); + + return { booking: bkg, 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..510b0e8 --- /dev/null +++ b/apps/server/src/operations/booking/create.ts @@ -0,0 +1,153 @@ +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { generateId } from "@floyd-run/utils"; +import { booking } 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"; + +const DEFAULT_HOLD_DURATION_MS = 15 * 60 * 1000; // 15 minutes + +export default createOperation({ + input: booking.createSchema, + 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) + .forUpdate() + .executeTakeFirst(); + + if (!resource) { + throw new NotFoundError("Resource not found"); + } + + // 2. Capture server time + const serverTime = await getServerTime(trx); + + // 3. Load service + const svc = await trx + .selectFrom("services") + .selectAll() + .where("id", "=", input.serviceId) + .executeTakeFirst(); + + if (!svc) { + 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("resource_not_in_service", { + serviceId: input.serviceId, + resourceId: input.resourceId, + }); + } + + // 5. Policy evaluation (if service has a policy) + let holdDurationMs = DEFAULT_HOLD_DURATION_MS; + if (svc.policyId) { + const policy = await trx + .selectFrom("policies") + .selectAll() + .where("id", "=", svc.policyId) + .executeTakeFirst(); + + if (policy) { + const timezone = resource.timezone ?? "UTC"; + const result = evaluatePolicy( + policy.config as unknown as PolicyConfig, + { startAt: input.startAt, endAt: input.endAt }, + { decisionTime: serverTime, timezone }, + ); + + if (!result.allowed) { + throw new ConflictError("policy_rejected", { + code: result.code, + message: result.message, + ...("details" in result ? { details: result.details } : {}), + }); + } + + // Use policy hold_duration if configured + const policyConfig = policy.config as Record; + if ( + policyConfig["hold_duration_ms"] && + typeof policyConfig["hold_duration_ms"] === "number" + ) { + holdDurationMs = policyConfig["hold_duration_ms"]; + } + } + } + + // 6. Conflict check: active, non-expired, overlapping allocations + const conflicting = await trx + .selectFrom("allocations") + .select(["id", "startAt", "endAt"]) + .where("resourceId", "=", input.resourceId) + .where("active", "=", true) + .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", serverTime)])) + .where("startAt", "<", input.endAt) + .where("endAt", ">", input.startAt) + .execute(); + + if (conflicting.length > 0) { + throw new ConflictError("overlap_conflict", { + conflictingAllocationIds: conflicting.map((a) => a.id), + }); + } + + // 7. Compute expiresAt + const isHold = input.status === "hold"; + const expiresAt = isHold ? new Date(serverTime.getTime() + holdDurationMs) : null; + + // 8. Insert booking + const bkg = await trx + .insertInto("bookings") + .values({ + id: generateId("bkg"), + ledgerId: input.ledgerId, + serviceId: input.serviceId, + status: input.status, + expiresAt, + metadata: input.metadata ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // 9. Insert allocation + const alloc = await trx + .insertInto("allocations") + .values({ + id: generateId("alc"), + ledgerId: input.ledgerId, + resourceId: input.resourceId, + bookingId: bkg.id, + active: true, + startAt: input.startAt, + endAt: input.endAt, + expiresAt, + metadata: null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // 10. Enqueue webhook + await enqueueWebhookEvent(trx, "booking.created", input.ledgerId, { + booking: serializeBooking(bkg, [alloc]), + }); + + return { booking: bkg, allocations: [alloc], 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..09f0daa --- /dev/null +++ b/apps/server/src/operations/booking/get.ts @@ -0,0 +1,26 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { booking } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: booking.getSchema, + execute: async (input) => { + const bkg = await db + .selectFrom("bookings") + .selectAll() + .where("id", "=", input.id) + .executeTakeFirst(); + + if (!bkg) { + return { booking: null, allocations: [] }; + } + + const allocations = await db + .selectFrom("allocations") + .selectAll() + .where("bookingId", "=", bkg.id) + .execute(); + + return { booking: bkg, allocations }; + }, +}); diff --git a/apps/server/src/operations/booking/index.ts b/apps/server/src/operations/booking/index.ts new file mode 100644 index 0000000..9cfd9c5 --- /dev/null +++ b/apps/server/src/operations/booking/index.ts @@ -0,0 +1,13 @@ +import create from "./create"; +import confirm from "./confirm"; +import cancel from "./cancel"; +import get from "./get"; +import list from "./list"; + +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..bd372e7 --- /dev/null +++ b/apps/server/src/operations/booking/list.ts @@ -0,0 +1,49 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { booking } from "@floyd-run/schema/inputs"; +import type { AllocationRow } from "database/schema"; + +export default createOperation({ + input: booking.listSchema, + 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 bkg of bookings) { + allocationsByBooking.set(bkg.id, []); + } + + if (bookings.length > 0) { + const allocations = await db + .selectFrom("allocations") + .selectAll() + .where( + "bookingId", + "in", + bookings.map((b) => b.id), + ) + .execute(); + + for (const alloc of allocations) { + if (alloc.bookingId) { + const list = allocationsByBooking.get(alloc.bookingId); + if (list) { + list.push(alloc); + } + } + } + } + + return { + bookings: bookings.map((bkg) => ({ + booking: bkg, + allocations: allocationsByBooking.get(bkg.id) || [], + })), + }; + }, +}); diff --git a/apps/server/src/operations/index.ts b/apps/server/src/operations/index.ts index 65289a7..47f0547 100644 --- a/apps/server/src/operations/index.ts +++ b/apps/server/src/operations/index.ts @@ -4,6 +4,8 @@ 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 operations = { allocation, @@ -12,4 +14,6 @@ export const operations = { ledger, webhook, policy, + service, + booking, }; diff --git a/apps/server/src/operations/resource/create.ts b/apps/server/src/operations/resource/create.ts index 92763ce..a5b82ab 100644 --- a/apps/server/src/operations/resource/create.ts +++ b/apps/server/src/operations/resource/create.ts @@ -11,6 +11,7 @@ export default createOperation({ .values({ id: generateId("rsc"), ledgerId: input.ledgerId, + timezone: input.timezone ?? null, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/src/operations/service/create.ts b/apps/server/src/operations/service/create.ts new file mode 100644 index 0000000..6b2588b --- /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 { service } from "@floyd-run/schema/inputs"; +import { NotFoundError } from "lib/errors"; + +export default createOperation({ + input: service.createSchema, + 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 svc = 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: svc.id, resourceId }))) + .execute(); + } + + return { service: svc, 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..730b310 --- /dev/null +++ b/apps/server/src/operations/service/get.ts @@ -0,0 +1,26 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { service } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: service.getSchema, + execute: async (input) => { + const svc = await db + .selectFrom("services") + .selectAll() + .where("id", "=", input.id) + .executeTakeFirst(); + + if (!svc) { + return { service: null, resourceIds: [] }; + } + + const resources = await db + .selectFrom("serviceResources") + .select("resourceId") + .where("serviceId", "=", svc.id) + .execute(); + + return { service: svc, 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..cab2645 --- /dev/null +++ b/apps/server/src/operations/service/list.ts @@ -0,0 +1,46 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { service } from "@floyd-run/schema/inputs"; + +export default createOperation({ + input: service.listSchema, + 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 svc of services) { + resourceIdsByService.set(svc.id, []); + } + + if (services.length > 0) { + const serviceResources = await db + .selectFrom("serviceResources") + .selectAll() + .where( + "serviceId", + "in", + services.map((s) => s.id), + ) + .execute(); + + for (const sr of serviceResources) { + const list = resourceIdsByService.get(sr.serviceId); + if (list) { + list.push(sr.resourceId); + } + } + } + + return { + services: services.map((svc) => ({ + service: svc, + resourceIds: resourceIdsByService.get(svc.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..62c31b5 --- /dev/null +++ b/apps/server/src/operations/service/remove.ts @@ -0,0 +1,54 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { service } from "@floyd-run/schema/inputs"; +import { ConflictError, NotFoundError } from "lib/errors"; + +export default createOperation({ + input: service.removeSchema, + 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) + .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("active_bookings_exist"); + } + + // 3. Clean up non-active bookings (cancelled/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..7ff1b83 --- /dev/null +++ b/apps/server/src/operations/service/update.ts @@ -0,0 +1,77 @@ +import { db } from "database"; +import { createOperation } from "lib/operation"; +import { service } from "@floyd-run/schema/inputs"; +import { NotFoundError } from "lib/errors"; + +export default createOperation({ + input: service.updateSchema, + 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) + .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 svc = 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: svc, resourceIds: input.resourceIds }; + }); + }, +}); diff --git a/apps/server/src/routes/v1/allocations.ts b/apps/server/src/routes/v1/allocations.ts index cbc6503..d3997e6 100644 --- a/apps/server/src/routes/v1/allocations.ts +++ b/apps/server/src/routes/v1/allocations.ts @@ -5,7 +5,7 @@ import { idempotent, storeIdempotencyResponse, IdempotencyVariables } from "infr 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", "startAt", "endAt", "expiresAt"]; // Nested under /v1/ledgers/:ledgerId/allocations export const allocations = new Hono<{ Variables: IdempotencyVariables }>() @@ -33,20 +33,7 @@ export const allocations = new Hono<{ Variables: IdempotencyVariables }>() return c.json(responseBody, 201); }) - .post("/:id/confirm", idempotent(), async (c) => { - const { allocation, serverTime } = await operations.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 operations.allocation.cancel({ - id: c.req.param("id"), - }); - const responseBody = { data: serializeAllocation(allocation), meta: { serverTime } }; - await storeIdempotencyResponse(c, responseBody, 200); - return c.json(responseBody); + .delete("/:id", async (c) => { + await operations.allocation.remove({ id: c.req.param("id") }); + return c.body(null, 204); }); diff --git a/apps/server/src/routes/v1/bookings.ts b/apps/server/src/routes/v1/bookings.ts new file mode 100644 index 0000000..7a346b9 --- /dev/null +++ b/apps/server/src/routes/v1/bookings.ts @@ -0,0 +1,54 @@ +import { Hono } from "hono"; +import { operations } from "operations"; +import { NotFoundError } from "lib/errors"; +import { idempotent, storeIdempotencyResponse, IdempotencyVariables } from "infra/idempotency"; +import { serializeBooking } from "./serializers"; + +// Significant fields for booking create idempotency hash +const BOOKING_SIGNIFICANT_FIELDS = ["serviceId", "resourceId", "startAt", "endAt", "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") }); + 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"), + }); + 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"), + }); + 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 6a981bf..d122765 100644 --- a/apps/server/src/routes/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -5,6 +5,8 @@ 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) @@ -12,4 +14,6 @@ export const v1 = new Hono() .route("/ledgers/:ledgerId/allocations", allocations) .route("/ledgers/:ledgerId/availability", availability) .route("/ledgers/:ledgerId/webhooks", webhooks) - .route("/ledgers/:ledgerId/policies", policies); + .route("/ledgers/:ledgerId/policies", policies) + .route("/ledgers/:ledgerId/services", services) + .route("/ledgers/:ledgerId/bookings", bookings); diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index cebae64..3aa8b88 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -4,13 +4,16 @@ import { LedgerRow, WebhookSubscriptionRow, PolicyRow, + ServiceRow, + BookingRow, } from "database/schema"; -import { Allocation, Resource, Ledger, Policy } from "@floyd-run/schema/types"; +import { 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(), }; @@ -29,7 +32,8 @@ export function serializeAllocation(allocation: AllocationRow): Allocation { id: allocation.id, ledgerId: allocation.ledgerId, resourceId: allocation.resourceId, - status: allocation.status, + bookingId: allocation.bookingId, + active: allocation.active, startAt: allocation.startAt.toISOString(), endAt: allocation.endAt.toISOString(), expiresAt: allocation.expiresAt?.toISOString() ?? null, @@ -86,3 +90,36 @@ export function serializePolicy(policy: PolicyRow): Policy { 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, + status: booking.status, + expiresAt: booking.expiresAt?.toISOString() ?? null, + allocations: allocations.map((a) => ({ + id: a.id, + resourceId: a.resourceId, + startAt: a.startAt.toISOString(), + endAt: a.endAt.toISOString(), + 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..86f015a --- /dev/null +++ b/apps/server/src/routes/v1/services.ts @@ -0,0 +1,45 @@ +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") }); + 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") }); + return c.body(null, 204); + }); diff --git a/apps/server/src/workers/expiration-worker.ts b/apps/server/src/workers/expiration-worker.ts index 93093b8..17aff5b 100644 --- a/apps/server/src/workers/expiration-worker.ts +++ b/apps/server/src/workers/expiration-worker.ts @@ -1,19 +1,20 @@ 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) => { const serverTime = await getServerTime(trx); - // Find expired holds and lock them - const expiredHolds = await trx - .selectFrom("allocations") + // Find expired booking holds and lock them + const expiredBookings = await trx + .selectFrom("bookings") .selectAll() .where("status", "=", "hold") .where("expiresAt", "<=", serverTime) @@ -22,15 +23,15 @@ async function processExpiredHolds(): Promise { .skipLocked() .execute(); - if (expiredHolds.length === 0) { + if (expiredBookings.length === 0) { return 0; } - const ids = expiredHolds.map((h) => h.id); + const ids = expiredBookings.map((b) => b.id); - // Update to expired (clear expiresAt per database constraint) + // Update bookings to expired await trx - .updateTable("allocations") + .updateTable("bookings") .set({ status: "expired", expiresAt: null, @@ -39,17 +40,64 @@ async function processExpiredHolds(): Promise { .where("id", "in", ids) .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", ids) + .execute(); + + // Enqueue webhook events + for (const bkg of expiredBookings) { + const allocations = await trx + .selectFrom("allocations") + .selectAll() + .where("bookingId", "=", bkg.id) + .execute(); + + const expiredBkg = { + ...bkg, + status: "expired" as const, expiresAt: null, updatedAt: serverTime, + }; + await enqueueWebhookEvent(trx, "booking.expired", bkg.ledgerId, { + booking: serializeBooking(expiredBkg, allocations), }); } - 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 expired = 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 (expired.length === 0) { + return 0; + } + + const ids = expired.map((a) => a.id); + await trx.deleteFrom("allocations").where("id", "in", ids).execute(); + + return expired.length; }); } @@ -57,13 +105,18 @@ async function runWorker(): Promise { if (isRunning) return; isRunning = true; - logger.info("[expiration-worker] Starting hold expiration worker..."); + logger.info("[expiration-worker] Starting expiration worker..."); while (isRunning) { try { - const processed = await processExpiredHolds(); - if (processed > 0) { - logger.info(`[expiration-worker] Expired ${processed} holds`); + const expiredBookings = await processExpiredBookings(); + if (expiredBookings > 0) { + logger.info(`[expiration-worker] Expired ${expiredBookings} booking holds`); + } + + const cleanedAllocations = await cleanupExpiredRawAllocations(); + if (cleanedAllocations > 0) { + logger.info(`[expiration-worker] Cleaned up ${cleanedAllocations} expired raw allocations`); } } catch (error) { logger.error(error, "[expiration-worker] Error processing expirations"); @@ -74,7 +127,7 @@ 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; } diff --git a/apps/server/test/integration/setup/factories/allocation.factory.ts b/apps/server/test/integration/setup/factories/allocation.factory.ts index b3429dd..ea883cb 100644 --- a/apps/server/test/integration/setup/factories/allocation.factory.ts +++ b/apps/server/test/integration/setup/factories/allocation.factory.ts @@ -1,13 +1,13 @@ 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; + active?: boolean; + bookingId?: string | null; startAt?: Date; endAt?: Date; expiresAt?: Date | null; @@ -41,7 +41,8 @@ export async function createAllocation(overrides?: { id: generateId("alc"), ledgerId, resourceId, - status: overrides?.status ?? "confirmed", + bookingId: overrides?.bookingId ?? null, + active: overrides?.active ?? true, startAt, endAt, expiresAt: overrides?.expiresAt ?? 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..4139d6f --- /dev/null +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -0,0 +1,91 @@ +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" | "cancelled" | "expired"; + expiresAt?: Date | null; + metadata?: Record | null; + startAt?: Date; + endAt?: 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 svc = await db + .selectFrom("services") + .where("id", "=", serviceId) + .selectAll() + .executeTakeFirstOrThrow(); + ledgerId = svc.ledgerId; + } + + const startAt = overrides?.startAt ?? faker.date.future(); + const endAt = overrides?.endAt ?? new Date(startAt.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", + startAt, + endAt, + expiresAt, + metadata: null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return { booking, allocation, ledgerId, serviceId, resourceId }; +} diff --git a/apps/server/test/integration/setup/factories/index.ts b/apps/server/test/integration/setup/factories/index.ts index e669172..73d9628 100644 --- a/apps/server/test/integration/setup/factories/index.ts +++ b/apps/server/test/integration/setup/factories/index.ts @@ -3,3 +3,5 @@ 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/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/integration/setup/types.ts b/apps/server/test/integration/setup/types.ts index d30a1ca..02edb13 100644 --- a/apps/server/test/integration/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/integration/v1/allocations/cancel.spec.ts b/apps/server/test/integration/v1/allocations/cancel.spec.ts deleted file mode 100644 index a7813e5..0000000 --- a/apps/server/test/integration/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/integration/v1/allocations/confirm.spec.ts b/apps/server/test/integration/v1/allocations/confirm.spec.ts deleted file mode 100644 index cfe7f67..0000000 --- a/apps/server/test/integration/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/test/integration/v1/allocations/create.spec.ts b/apps/server/test/integration/v1/allocations/create.spec.ts index e08c11e..18a79ac 100644 --- a/apps/server/test/integration/v1/allocations/create.spec.ts +++ b/apps/server/test/integration/v1/allocations/create.spec.ts @@ -4,14 +4,13 @@ import { createResource } from "../../setup/factories"; import { 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 response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "confirmed", startAt: startAt.toISOString(), endAt: endAt.toISOString(), }); @@ -21,14 +20,15 @@ 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.active).toBe(true); + expect(data.bookingId).toBeNull(); expect(data.startAt).toBe(startAt.toISOString()); expect(data.endAt).toBe(endAt.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"); @@ -36,7 +36,6 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - status: "hold", startAt: startAt.toISOString(), endAt: endAt.toISOString(), expiresAt: expiresAt.toISOString(), @@ -44,7 +43,6 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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,7 +52,6 @@ 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(), metadata, @@ -65,65 +62,10 @@ 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(); - - 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(), - }); - - expect(response.status).toBe(422); - }); - - it("returns 422 for terminal status (expired)", async () => { - const { resource, ledgerId } = await createResource(); - - 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(), - }); - - expect(response.status).toBe(422); - }); - - it("defaults to hold status when not specified", 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", - }); + const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, {}); expect(response.status).toBe(422); }); @@ -133,7 +75,6 @@ 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(), }); @@ -142,13 +83,12 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { }); 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(), }); @@ -157,7 +97,6 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // 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(), }); @@ -167,14 +106,13 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { expect(body.error.code).toBe("overlap_conflict"); }); - 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(), expiresAt: expiresAt.toISOString(), @@ -184,7 +122,6 @@ 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(), }); @@ -192,24 +129,22 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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(), 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(), }); @@ -223,7 +158,6 @@ 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(), }); @@ -232,7 +166,6 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // 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(), }); @@ -247,7 +180,6 @@ 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(), }); @@ -256,7 +188,6 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // 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(), }); @@ -264,24 +195,22 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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", + active: false, startAt: new Date("2026-02-01T10:00:00Z"), endAt: 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(), }); @@ -294,7 +223,6 @@ 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(), }); 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..3f7c505 --- /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("booking_owned_allocation"); + }); +}); diff --git a/apps/server/test/integration/v1/allocations/get.spec.ts b/apps/server/test/integration/v1/allocations/get.spec.ts index e98a399..6c934c0 100644 --- a/apps/server/test/integration/v1/allocations/get.spec.ts +++ b/apps/server/test/integration/v1/allocations/get.spec.ts @@ -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/integration/v1/allocations/idempotency.spec.ts b/apps/server/test/integration/v1/allocations/idempotency.spec.ts index f2670b1..e6ed230 100644 --- a/apps/server/test/integration/v1/allocations/idempotency.spec.ts +++ b/apps/server/test/integration/v1/allocations/idempotency.spec.ts @@ -11,7 +11,6 @@ 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(), }; @@ -43,7 +42,6 @@ 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(), }, @@ -56,7 +54,6 @@ 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(), }, @@ -76,7 +73,6 @@ 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(), }, @@ -90,7 +86,6 @@ 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(), }, @@ -109,7 +104,6 @@ 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(), }; @@ -139,7 +133,6 @@ 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(), }); @@ -147,81 +140,4 @@ describe("Idempotency", () => { 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/integration/v1/availability/query.spec.ts b/apps/server/test/integration/v1/availability/query.spec.ts index 89d727b..1df82e3 100644 --- a/apps/server/test/integration/v1/availability/query.spec.ts +++ b/apps/server/test/integration/v1/availability/query.spec.ts @@ -25,7 +25,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { expect(data[0]!.timeline).toEqual([{ startAt, endAt, 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,7 +35,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", + active: true, startAt: allocStart, endAt: allocEnd, }); @@ -59,7 +59,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ]); }); - 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,7 +70,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "hold", + active: true, startAt: allocStart, endAt: allocEnd, expiresAt, @@ -92,7 +92,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,7 +103,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "hold", + active: true, startAt: allocStart, endAt: allocEnd, expiresAt, @@ -121,18 +121,18 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { expect(response.status).toBe(200); const { data } = (await response.json()) as AvailabilityResponse; - // Expired hold should not block - entire window is free + // Expired allocation should not block - entire window is free expect(data[0]!.timeline).toEqual([{ startAt, endAt, 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", + active: false, startAt: new Date("2026-01-01T10:30:00.000Z"), endAt: new Date("2026-01-01T11:00:00.000Z"), }); @@ -160,14 +160,14 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", + active: true, startAt: new Date("2026-01-01T10:30:00.000Z"), endAt: new Date("2026-01-01T11:00:00.000Z"), }); await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", + active: true, startAt: new Date("2026-01-01T10:45:00.000Z"), endAt: new Date("2026-01-01T11:15:00.000Z"), }); @@ -200,14 +200,14 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", + active: true, startAt: new Date("2026-01-01T10:30:00.000Z"), endAt: new Date("2026-01-01T11:00:00.000Z"), }); await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", + active: true, startAt: new Date("2026-01-01T11:00:00.000Z"), endAt: new Date("2026-01-01T11:30:00.000Z"), }); @@ -240,7 +240,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", + active: true, startAt: new Date("2026-01-01T09:00:00.000Z"), endAt: new Date("2026-01-01T13:00:00.000Z"), }); @@ -270,7 +270,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource1.id, - status: "confirmed", + active: true, startAt: new Date("2026-01-01T10:30:00.000Z"), endAt: new Date("2026-01-01T11:00:00.000Z"), }); @@ -306,7 +306,7 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { await createAllocation({ ledgerId: ledger.id, resourceId: resource.id, - status: "confirmed", + active: true, startAt: new Date("2026-01-01T08:00:00.000Z"), endAt: new Date("2026-01-01T09:00:00.000Z"), }); 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..b8a4c49 --- /dev/null +++ b/apps/server/test/integration/v1/bookings/cancel.spec.ts @@ -0,0 +1,91 @@ +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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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 cancelling 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("cancelled"); + expect(data.expiresAt).toBeNull(); + expect(data.allocations).toHaveLength(1); + expect(data.allocations[0]!.active).toBe(false); + expect(meta.serverTime).toBeDefined(); + }); + + it("returns 200 when cancelling 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("cancelled"); + expect(data.allocations[0]!.active).toBe(false); + }); + + it("is idempotent — cancelling an already cancelled 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("cancelled"); + }); + + 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..a5257f0 --- /dev/null +++ b/apps/server/test/integration/v1/bookings/confirm.spec.ts @@ -0,0 +1,89 @@ +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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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("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 cancelled 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("invalid_state_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..4430148 --- /dev/null +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { + createLedger, + createResource, + createService, + createAllocation, +} 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 startAt = "2026-06-01T10:00:00.000Z"; + const endAt = "2026-06-01T11:00:00.000Z"; + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startAt, + endAt, + }); + + 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]!.startAt).toBe(startAt); + expect(data.allocations[0]!.endAt).toBe(endAt); + 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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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 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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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", + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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 + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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("resource_not_in_service"); + }); + + 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, + startAt: new Date("2026-06-01T10:00:00.000Z"), + endAt: 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, + startAt: "2026-06-01T10:30:00.000Z", + endAt: "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("overlap_conflict"); + }); + + 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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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, + startAt: "2026-06-01T10:30:00.000Z", + endAt: "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("overlap_conflict"); + }); + + 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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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, + startAt: "2026-06-01T11:00:00.000Z", + endAt: "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, + startAt: new Date("2026-06-01T10:00:00.000Z"), + endAt: 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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "2026-06-01T11:00:00.000Z", + }); + + 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..c062cd3 --- /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, createResource, createService, 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/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..5ddabf9 --- /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("active_bookings_exist"); + }); + + 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("active_bookings_exist"); + }); + + it("allows deletion when all bookings are cancelled", async () => { + const { ledger } = await createLedger(); + const { serviceId } = await createBooking({ ledgerId: ledger.id, status: "cancelled" }); + + 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/packages/schema/constants/index.ts b/packages/schema/constants/index.ts index 670e648..cfcb1c2 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", @@ -18,6 +11,13 @@ export const WebhookDeliveryStatus = { EXHAUSTED: "exhausted", } as const; +export const BookingStatus = { + HOLD: "hold", + CONFIRMED: "confirmed", + CANCELLED: "cancelled", + EXPIRED: "expired", +} as const; + export const PolicyDefault = { OPEN: "open", CLOSED: "closed", diff --git a/packages/schema/inputs/allocation.ts b/packages/schema/inputs/allocation.ts index e08a91b..a17c740 100644 --- a/packages/schema/inputs/allocation.ts +++ b/packages/schema/inputs/allocation.ts @@ -1,13 +1,9 @@ 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(), @@ -25,11 +21,3 @@ export const listSchema = z.object({ 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 cancelSchema = z.object({ - id: z.string().refine((id) => isValidId(id, "alc"), { message: "Invalid allocation ID" }), -}); diff --git a/packages/schema/inputs/booking.ts b/packages/schema/inputs/booking.ts new file mode 100644 index 0000000..65e3858 --- /dev/null +++ b/packages/schema/inputs/booking.ts @@ -0,0 +1,29 @@ +import z from "zod"; +import { isValidId } from "@floyd-run/utils"; +import { BookingStatus } from "../constants"; + +export const createSchema = 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" }), + startAt: z.coerce.date(), + endAt: z.coerce.date(), + status: z.enum([BookingStatus.HOLD, BookingStatus.CONFIRMED]).default(BookingStatus.HOLD), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), +}); + +export const getSchema = z.object({ + id: z.string().refine((id) => isValidId(id, "bkg"), { message: "Invalid booking ID" }), +}); + +export const listSchema = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), +}); + +export const confirmSchema = z.object({ + id: z.string().refine((id) => isValidId(id, "bkg"), { message: "Invalid booking ID" }), +}); + +export const cancelSchema = z.object({ + id: z.string().refine((id) => isValidId(id, "bkg"), { message: "Invalid booking ID" }), +}); diff --git a/packages/schema/inputs/index.ts b/packages/schema/inputs/index.ts index dccff01..83f4efa 100644 --- a/packages/schema/inputs/index.ts +++ b/packages/schema/inputs/index.ts @@ -4,3 +4,5 @@ export * as resource from "./resource"; export * as ledger from "./ledger"; export * as webhook from "./webhook"; export * as policy from "./policy"; +export * as service from "./service"; +export * as booking from "./booking"; diff --git a/packages/schema/inputs/resource.ts b/packages/schema/inputs/resource.ts index 864de5d..f8814a8 100644 --- a/packages/schema/inputs/resource.ts +++ b/packages/schema/inputs/resource.ts @@ -3,6 +3,7 @@ import { isValidId } from "@floyd-run/utils"; export const createSchema = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + timezone: z.string().max(64).nullable().optional(), }); export const getSchema = z.object({ diff --git a/packages/schema/inputs/service.ts b/packages/schema/inputs/service.ts new file mode 100644 index 0000000..1986ba1 --- /dev/null +++ b/packages/schema/inputs/service.ts @@ -0,0 +1,43 @@ +import z from "zod"; +import { isValidId } from "@floyd-run/utils"; + +export const createSchema = 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 updateSchema = 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 getSchema = z.object({ + id: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service 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, "svc"), { message: "Invalid service ID" }), +}); diff --git a/packages/schema/outputs/allocation.ts b/packages/schema/outputs/allocation.ts index 36b6325..71dae76 100644 --- a/packages/schema/outputs/allocation.ts +++ b/packages/schema/outputs/allocation.ts @@ -1,11 +1,11 @@ import { z } from "./zod"; -import { AllocationStatus } from "../constants"; export const schema = z.object({ id: z.string(), ledgerId: z.string(), resourceId: z.string(), - status: z.enum(AllocationStatus), + bookingId: z.string().nullable(), + active: z.boolean(), startAt: z.string(), endAt: z.string(), expiresAt: z.string().nullable(), diff --git a/packages/schema/outputs/booking.ts b/packages/schema/outputs/booking.ts new file mode 100644 index 0000000..2ef8f13 --- /dev/null +++ b/packages/schema/outputs/booking.ts @@ -0,0 +1,40 @@ +import { z } from "./zod"; +import { BookingStatus } from "../constants"; + +const bookingAllocationSchema = z.object({ + id: z.string(), + resourceId: z.string(), + startAt: z.string(), + endAt: z.string(), + active: z.boolean(), +}); + +export const schema = z.object({ + id: z.string(), + ledgerId: z.string(), + serviceId: z.string(), + status: z.enum([ + BookingStatus.HOLD, + BookingStatus.CONFIRMED, + BookingStatus.CANCELLED, + 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 getSchema = z.object({ + data: schema, + meta: z + .object({ + serverTime: z.string(), + }) + .optional(), +}); + +export const listSchema = z.object({ + data: z.array(schema), +}); diff --git a/packages/schema/outputs/index.ts b/packages/schema/outputs/index.ts index f69713c..d13ddbb 100644 --- a/packages/schema/outputs/index.ts +++ b/packages/schema/outputs/index.ts @@ -5,3 +5,5 @@ 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/resource.ts b/packages/schema/outputs/resource.ts index 746a2c0..afdf2ea 100644 --- a/packages/schema/outputs/resource.ts +++ b/packages/schema/outputs/resource.ts @@ -3,6 +3,7 @@ import { z } from "./zod"; export const schema = z.object({ id: z.string(), ledgerId: z.string(), + timezone: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/packages/schema/outputs/service.ts b/packages/schema/outputs/service.ts new file mode 100644 index 0000000..19aa8a1 --- /dev/null +++ b/packages/schema/outputs/service.ts @@ -0,0 +1,20 @@ +import { z } from "./zod"; + +export const schema = 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 getSchema = z.object({ + data: schema, +}); + +export const listSchema = z.object({ + data: z.array(schema), +}); diff --git a/packages/schema/types/index.ts b/packages/schema/types/index.ts index 9db0406..1a0f9fa 100644 --- a/packages/schema/types/index.ts +++ b/packages/schema/types/index.ts @@ -1,14 +1,14 @@ import type { z } from "zod"; import type * as outputs from "../outputs"; import { - AllocationStatus, + BookingStatus, IdempotencyStatus, WebhookDeliveryStatus, PolicyDefault, } from "../constants"; import { ConstantType } from "./utils"; -export type AllocationStatus = ConstantType; +export type BookingStatus = ConstantType; export type IdempotencyStatus = ConstantType; export type WebhookDeliveryStatus = ConstantType; export type PolicyDefault = ConstantType; @@ -19,3 +19,5 @@ 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 f9024a4..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" | "pol"; +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|pol)_([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]! }; } From 3cfbb913602533dbf0dc2c0876bf295acccfb529 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 20:16:22 +0300 Subject: [PATCH 08/31] Update docs --- README.md | 31 +- apps/server/openapi.json | 1594 +++++++++++++++++-- apps/server/src/scripts/generate-openapi.ts | 302 +++- docs/allocations.md | 155 +- docs/availability.md | 17 +- docs/bookings.md | 167 ++ docs/errors.md | 86 +- docs/idempotency.md | 28 +- docs/introduction.md | 22 +- docs/quickstart.md | 122 +- docs/services.md | 140 ++ docs/webhooks.md | 75 +- scalar.config.json | 2 + 13 files changed, 2401 insertions(+), 340 deletions(-) create mode 100644 docs/bookings.md create mode 100644 docs/services.md 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/openapi.json b/apps/server/openapi.json index 9a26f9d..c979342 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,9 +67,11 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] + }, + "active": { + "type": "boolean" }, "startAt": { "type": "string" @@ -92,7 +97,8 @@ "id", "ledgerId", "resourceId", - "status", + "bookingId", + "active", "startAt", "endAt", "expiresAt", @@ -191,6 +197,115 @@ }, "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" + }, + "status": { + "type": "string", + "enum": ["hold", "confirmed", "cancelled", "expired"] + }, + "expiresAt": { + "type": ["string", "null"] + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["id", "resourceId", "startAt", "endAt", "active"] + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "status", + "expiresAt", + "allocations", + "metadata", + "createdAt", + "updatedAt" + ] + }, "Error": { "type": "object", "properties": { @@ -424,6 +539,9 @@ "ledgerId": { "type": "string" }, + "timezone": { + "type": ["string", "null"] + }, "createdAt": { "type": "string" }, @@ -431,7 +549,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] } } }, @@ -460,7 +578,13 @@ "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "timezone": { + "type": ["string", "null"], + "description": "IANA timezone for the resource (e.g. America/New_York)", + "example": "America/New_York" + } + } } } } @@ -482,6 +606,9 @@ "ledgerId": { "type": "string" }, + "timezone": { + "type": ["string", "null"] + }, "createdAt": { "type": "string" }, @@ -489,7 +616,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] } }, "required": ["data"] @@ -539,6 +666,9 @@ "ledgerId": { "type": "string" }, + "timezone": { + "type": ["string", "null"] + }, "createdAt": { "type": "string" }, @@ -546,7 +676,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] } }, "required": ["data"] @@ -787,9 +917,11 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] + }, + "active": { + "type": "boolean" }, "startAt": { "type": "string" @@ -815,7 +947,8 @@ "id", "ledgerId", "resourceId", - "status", + "bookingId", + "active", "startAt", "endAt", "expiresAt", @@ -836,7 +969,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": { @@ -855,12 +988,7 @@ "properties": { "resourceId": { "type": "string", - "example": "res_01abc123def456ghi789jkl012" - }, - "status": { - "type": "string", - "enum": ["hold", "confirmed"], - "default": "hold" + "example": "rsc_01abc123def456ghi789jkl012" }, "startAt": { "type": "string", @@ -872,7 +1000,8 @@ }, "expiresAt": { "type": ["string", "null"], - "format": "date-time" + "format": "date-time", + "description": "If set, the allocation auto-expires after this time" }, "metadata": { "type": ["object", "null"], @@ -904,9 +1033,11 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] + }, + "active": { + "type": "boolean" }, "startAt": { "type": "string" @@ -932,7 +1063,8 @@ "id", "ledgerId", "resourceId", - "status", + "bookingId", + "active", "startAt", "endAt", "expiresAt", @@ -1037,9 +1169,11 @@ "resourceId": { "type": "string" }, - "status": { - "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "bookingId": { + "type": ["string", "null"] + }, + "active": { + "type": "boolean" }, "startAt": { "type": "string" @@ -1065,7 +1199,8 @@ "id", "ledgerId", "resourceId", - "status", + "bookingId", + "active", "startAt", "endAt", "expiresAt", @@ -1126,13 +1261,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": { @@ -1152,76 +1285,8 @@ } ], "responses": { - "200": { - "description": "Allocation confirmed", - "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" - }, - "endAt": { - "type": "string" - }, - "expiresAt": { - "type": ["string", "null"] - }, - "metadata": { - "type": ["object", "null"], - "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" - } - }, - "required": ["serverTime"] - } - }, - "required": ["data"] - } - } - } + "204": { + "description": "Allocation deleted" }, "404": { "description": "Allocation not found", @@ -1260,7 +1325,7 @@ } }, "409": { - "description": "Allocation cannot be confirmed", + "description": "Allocation belongs to a booking", "content": { "application/json": { "schema": { @@ -1298,11 +1363,10 @@ } } }, - "/v1/ledgers/{ledgerId}/allocations/{id}/cancel": { - "post": { - "tags": ["Allocations"], - "summary": "Cancel an allocation", - "description": "Cancels an allocation that is in HOLD or CONFIRMED status.", + "/v1/ledgers/{ledgerId}/services": { + "get": { + "tags": ["Services"], + "summary": "List all services in a ledger", "parameters": [ { "schema": { @@ -1311,49 +1375,1310 @@ "required": true, "name": "ledgerId", "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "id", - "in": "path" } ], "responses": { "200": { - "description": "Allocation cancelled", + "description": "List of services", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { - "type": "object", - "properties": { - "id": { - "type": "string" + "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" + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } }, - "ledgerId": { - "type": "string" + "required": [ + "id", + "ledgerId", + "name", + "policyId", + "resourceIds", + "metadata", + "createdAt", + "updatedAt" + ] + } + } + }, + "required": ["data"] + } + } + } + } + } + }, + "post": { + "tags": ["Services"], + "summary": "Create a new service", + "description": "Creates a service that groups resources with an optional scheduling policy.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "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": { + "201": { + "description": "Service created", + "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": "Policy or resource 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": ["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"] + } + } + } + }, + "404": { + "description": "Service 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": ["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" + }, + { + "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": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["code"] + } + ] + } + }, + "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": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["code"] + } + ] + } + }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "Service has active bookings", + "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": ["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" + }, + "status": { + "type": "string", + "enum": ["hold", "confirmed", "cancelled", "expired"] + }, + "expiresAt": { + "type": ["string", "null"] + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["id", "resourceId", "startAt", "endAt", "active"] + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "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. 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" + }, + "startAt": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T10:00:00Z" + }, + "endAt": { + "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", "startAt", "endAt"] + } + } + } + }, + "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" + }, + "status": { + "type": "string", + "enum": ["hold", "confirmed", "cancelled", "expired"] + }, + "expiresAt": { + "type": ["string", "null"] + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["id", "resourceId", "startAt", "endAt", "active"] + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "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": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["code"] + } + ] + } + }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "Conflict (overlap, policy rejected, or resource not in service)", + "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": ["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" + }, + "status": { + "type": "string", + "enum": ["hold", "confirmed", "cancelled", "expired"] + }, + "expiresAt": { + "type": ["string", "null"] + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["id", "resourceId", "startAt", "endAt", "active"] + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "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": { + "anyOf": [ + { + "type": "string" }, - "resourceId": { + { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["code"] + } + ] + } + }, + "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" }, "status": { "type": "string", "enum": ["hold", "confirmed", "cancelled", "expired"] }, - "startAt": { + "expiresAt": { + "type": ["string", "null"] + }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["id", "resourceId", "startAt", "endAt", "active"] + } + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": {} + }, + "createdAt": { "type": "string" }, - "endAt": { + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "serviceId", + "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": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["code"] + } + ] + } + }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "Booking cannot be confirmed (expired or invalid state)", + "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": ["error"] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/bookings/{id}/cancel": { + "post": { + "tags": ["Bookings"], + "summary": "Cancel a booking", + "description": "Cancels a booking in hold or confirmed status. Idempotent — cancelling an already cancelled 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 cancelled", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "serviceId": { "type": "string" }, + "status": { + "type": "string", + "enum": ["hold", "confirmed", "cancelled", "expired"] + }, "expiresAt": { "type": ["string", "null"] }, + "allocations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["id", "resourceId", "startAt", "endAt", "active"] + } + }, "metadata": { "type": ["object", "null"], "additionalProperties": {} @@ -1368,11 +2693,10 @@ "required": [ "id", "ledgerId", - "resourceId", + "serviceId", "status", - "startAt", - "endAt", "expiresAt", + "allocations", "metadata", "createdAt", "updatedAt" @@ -1394,7 +2718,7 @@ } }, "404": { - "description": "Allocation not found", + "description": "Booking not found", "content": { "application/json": { "schema": { @@ -1430,7 +2754,7 @@ } }, "409": { - "description": "Allocation cannot be cancelled", + "description": "Booking cannot be cancelled (invalid state)", "content": { "application/json": { "schema": { diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 55007d7..e744cc3 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -10,6 +10,8 @@ import { policy, error, availability, + service, + booking, } from "@floyd-run/schema/outputs"; const registry = new OpenAPIRegistry(); @@ -22,6 +24,8 @@ registry.register("WebhookSubscription", webhook.subscriptionSchema); registry.register("AvailabilityItem", availability.itemSchema); registry.register("TimelineBlock", availability.timelineBlockSchema); registry.register("Policy", policy.schema); +registry.register("Service", service.schema); +registry.register("Booking", booking.schema); registry.register("Error", error.schema); // Ledger routes @@ -123,7 +127,12 @@ registry.registerPath({ body: { content: { "application/json": { - schema: z.object({}), + schema: z.object({ + timezone: z.string().nullable().optional().openapi({ + description: "IANA timezone for the resource (e.g. America/New_York)", + example: "America/New_York", + }), + }), }, }, }, @@ -234,18 +243,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"), + resourceId: z.string().openapi({ example: "rsc_01abc123def456ghi789jkl012" }), startAt: z.string().datetime(), endAt: z.string().datetime(), - expiresAt: z.string().datetime().nullable().optional(), + expiresAt: z.string().datetime().nullable().optional().openapi({ + description: "If set, the allocation auto-expires after this time", + }), metadata: z.record(z.string(), z.unknown()).nullable().optional(), }), }, @@ -265,25 +275,203 @@ 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.listSchema } }, + }, + }, +}); + +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.getSchema } }, }, 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.getSchema } }, + }, + 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.getSchema } }, + }, + 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 } }, + }, + }, +}); + +// 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.listSchema } }, + }, + }, +}); + +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.getSchema } }, + }, + 404: { + description: "Booking not found", content: { "application/json": { schema: error.schema } }, }, }, @@ -291,24 +479,94 @@ registry.registerPath({ 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}/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. 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" }), + startAt: z.string().datetime().openapi({ example: "2026-01-15T10:00:00Z" }), + endAt: z.string().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.getSchema } }, + }, + 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: "Allocation cancelled", - content: { "application/json": { schema: allocation.getSchema } }, + description: "Booking confirmed", + content: { "application/json": { schema: booking.getSchema } }, }, 404: { - description: "Allocation not found", + 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 — cancelling an already cancelled booking returns success. Supports idempotency via the Idempotency-Key header.", + request: { + params: z.object({ ledgerId: z.string(), id: z.string() }), + }, + responses: { + 200: { + description: "Booking cancelled", + content: { "application/json": { schema: booking.getSchema } }, + }, + 404: { + description: "Booking not found", content: { "application/json": { schema: error.schema } }, }, 409: { - description: "Allocation cannot be cancelled", + description: "Booking cannot be cancelled (invalid state)", content: { "application/json": { schema: error.schema } }, }, }, @@ -557,7 +815,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/docs/allocations.md b/docs/allocations.md index 76a8002..5e8feaf 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -1,100 +1,133 @@ # 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...", + "startAt": "2026-01-04T10:00:00Z", + "endAt": "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, + "startAt": "2026-01-04T10:00:00.000Z", + "endAt": "2026-01-04T11:00:00.000Z", + "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_...", + "startAt": "2026-01-04T10:00:00Z", + "endAt": "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 (cancelled/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: - -- 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 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 | +| `startAt` | string | Start of the time block (ISO 8601) | +| `endAt` | string | End of the time block (ISO 8601) | +| `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..24d2128 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -1,6 +1,6 @@ # Availability -Query free/busy timelines for resources before creating allocations. +Query free/busy timelines for resources before creating bookings or allocations. ## Query availability @@ -61,12 +61,17 @@ Each resource gets its own timeline in the response. ## What counts as "busy" -A time slot is marked `busy` if it overlaps with: +A time slot is marked `busy` if it overlaps with an active, non-expired allocation: -- A `hold` allocation that hasn't expired -- A `confirmed` allocation +- `active = true` and `expiresAt` is null (permanent block) +- `active = true` and `expiresAt > now()` (temporary block, not yet expired) -Expired holds and cancelled allocations do **not** block time. +These do **not** block time: + +- `active = false` (cancelled/expired booking allocations) +- Expired allocations (`expiresAt <= now()`) + +Both booking-owned and raw allocations are considered. ## Timeline behavior @@ -78,7 +83,7 @@ Expired holds and cancelled allocations do **not** block time. 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 +3. Create a booking on the chosen slot ```javascript const { data } = await fetch(`${baseUrl}/v1/ledgers/${ledgerId}/availability`, { diff --git a/docs/bookings.md b/docs/bookings.md new file mode 100644 index 0000000..e48fa6b --- /dev/null +++ b/docs/bookings.md @@ -0,0 +1,167 @@ +# 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. +- `cancelled` — 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` | `cancelled` | `POST /bookings/:id/cancel` | +| `hold` | `expired` | `expiresAt` elapsed (automatic) | +| `confirmed` | `cancelled` | `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_...", + "startAt": "2026-03-01T10:00:00Z", + "endAt": "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, etc.) +3. Checks for time conflicts on the resource +4. Creates a booking with `status=hold` and `expiresAt` (default: 15 minutes from now) +5. 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_...", + "startAt": "2026-03-01T10:00:00Z", + "endAt": "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 = cancelled` +- Deactivates allocations (`active = false`) + +Cancel works on both `hold` and `confirmed` bookings. It's safe to retry — cancelling an already cancelled 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_...", + "startAt": "2026-03-01T10:00:00.000Z", + "endAt": "2026-03-01T11:00:00.000Z", + "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`. + +## History + +Cancelled 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 cancelled 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**: **[startAt, endAt)** + +- 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..e5f59ec 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,52 @@ 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": "INVALID_STATUS_TRANSITION", - "message": "Cannot confirm allocation with status: cancelled" + "code": "active_bookings_exist", + "message": "Service has active bookings" + } +} +``` + +### 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 +138,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..8ba4325 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`, `startAt`, `endAt`, `expiresAt` | +| `POST /bookings` | `serviceId`, `resourceId`, `startAt`, `endAt`, `status` | +| `POST /bookings/:id/confirm` | (none — scoped to booking ID) | +| `POST /bookings/:id/cancel` | (none — scoped to booking ID) | ## Behavior @@ -29,16 +38,17 @@ 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", + "serviceId": "svc_01abc123...", + "resourceId": "rsc_01abc123...", "startAt": "2026-01-04T10:00:00Z", "endAt": "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 +- Cancelling an already cancelled booking returns the cancelled 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 f3f44eb..b0c58d4 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,19 +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 +- **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 create requests using `Idempotency-Key` header -- **409 Conflict**: means "the slot is not available" or "idempotency mismatch" +- **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 +- [Services](./services) - Grouping resources with policies +- [Bookings](./bookings) - The reservation lifecycle +- [Allocations](./allocations) - Raw time blocks - [Policies](./policies) - Scheduling rules - [Availability](./availability) - Query 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/quickstart.md b/docs/quickstart.md index fa3d014..cf567f2 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,7 +105,7 @@ 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" \ @@ -120,23 +120,54 @@ Response: "data": { "id": "rsc_01abc123def456ghi789jkl012", "ledgerId": "ldg_01abc123def456ghi789jkl012", + "timezone": null, "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", "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", + "startAt": "2026-01-04T10:00:00.000Z", + "endAt": "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 `cancelled` and allocations deactivated (`active: false`) +- Safe to retry (cancelling an already cancelled 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 +- [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) - Query 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..a1035e6 --- /dev/null +++ b/docs/services.md @@ -0,0 +1,140 @@ +# 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 `active_bookings_exist` if the service has bookings in `hold` or `confirmed` status +- Cancelled 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 (or UTC if no timezone is set) + +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. + +## 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/webhooks.md b/docs/webhooks.md index 5a957ab..d3ad143 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -1,36 +1,80 @@ # 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.cancelled` | A booking was cancelled | +| `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...", + "startAt": "2026-01-15T14:00:00Z", + "endAt": "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, + "startAt": "2026-01-15T14:00:00Z", + "endAt": "2026-01-15T15:00:00Z", + "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 +108,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/scalar.config.json b/scalar.config.json index 93b04eb..0b46012 100644 --- a/scalar.config.json +++ b/scalar.config.json @@ -24,6 +24,8 @@ "type": "folder", "icon": "phosphor/regular/puzzle-piece", "children": [ + { "type": "page", "name": "Services", "path": "docs/services.md" }, + { "type": "page", "name": "Bookings", "path": "docs/bookings.md" }, { "type": "page", "name": "Allocations", "path": "docs/allocations.md" }, { "type": "page", "name": "Policies", "path": "docs/policies.md" }, { "type": "page", "name": "Availability", "path": "docs/availability.md" }, From a14b579ce85a5fe1799092d8acd6efe881970f41 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 20:38:47 +0300 Subject: [PATCH 09/31] Fix some small issues --- apps/server/src/app.ts | 8 +++- apps/server/src/infra/idempotency.ts | 7 ++-- apps/server/src/infra/webhooks.ts | 41 +++++++++++-------- apps/server/src/operations/allocation/get.ts | 1 + .../src/operations/allocation/remove.ts | 1 + .../src/operations/availability/query.ts | 2 +- apps/server/src/operations/booking/cancel.ts | 1 + apps/server/src/operations/booking/confirm.ts | 1 + apps/server/src/operations/booking/get.ts | 1 + apps/server/src/operations/policy/get.ts | 1 + apps/server/src/operations/policy/remove.ts | 6 ++- apps/server/src/operations/policy/update.ts | 2 + apps/server/src/operations/resource/get.ts | 1 + apps/server/src/operations/resource/remove.ts | 11 ++++- apps/server/src/operations/service/get.ts | 1 + apps/server/src/operations/service/remove.ts | 1 + apps/server/src/operations/service/update.ts | 1 + apps/server/src/operations/webhook/remove.ts | 1 + .../src/operations/webhook/rotate-secret.ts | 1 + apps/server/src/operations/webhook/update.ts | 1 + apps/server/src/routes/v1/allocations.ts | 10 ++++- apps/server/src/routes/v1/bookings.ts | 7 +++- apps/server/src/routes/v1/policies.ts | 11 ++++- apps/server/src/routes/v1/resources.ts | 7 +++- apps/server/src/routes/v1/services.ts | 7 +++- apps/server/src/routes/v1/webhooks.ts | 3 ++ packages/schema/inputs/allocation.ts | 2 + packages/schema/inputs/booking.ts | 3 ++ packages/schema/inputs/policy.ts | 3 ++ packages/schema/inputs/resource.ts | 2 + packages/schema/inputs/service.ts | 2 + packages/schema/inputs/webhook.ts | 3 ++ 32 files changed, 115 insertions(+), 35 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 965aff3..3b65997 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -18,7 +18,10 @@ app.route("/", routes); app.onError((err, c) => { if (err instanceof InputError) { - return c.json({ error: err.message }, 422); + return c.json( + { error: { code: "VALIDATION_ERROR", message: err.message, details: err.issues } }, + 422, + ); } if (err instanceof NotFoundError) { @@ -41,10 +44,11 @@ app.onError((err, c) => { return c.json({ error: "Invalid JSON" }, 400); } + logger.error(err); + if (config.NODE_ENV === "production") { return c.json({ message: "Internal server error" }, 500); } else { - logger.error(err); return c.json([err, err.stack], 500); } }); diff --git a/apps/server/src/infra/idempotency.ts b/apps/server/src/infra/idempotency.ts index 07636d1..9378734 100644 --- a/apps/server/src/infra/idempotency.ts +++ b/apps/server/src/infra/idempotency.ts @@ -151,12 +151,11 @@ export function idempotent(options: IdempotencyOptions = {}) { 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; diff --git a/apps/server/src/infra/webhooks.ts b/apps/server/src/infra/webhooks.ts index 4483cbb..35b8522 100644 --- a/apps/server/src/infra/webhooks.ts +++ b/apps/server/src/infra/webhooks.ts @@ -90,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/operations/allocation/get.ts b/apps/server/src/operations/allocation/get.ts index bb3c6ca..47fea72 100644 --- a/apps/server/src/operations/allocation/get.ts +++ b/apps/server/src/operations/allocation/get.ts @@ -8,6 +8,7 @@ export default createOperation({ const allocation = await db .selectFrom("allocations") .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .selectAll() .executeTakeFirst(); diff --git a/apps/server/src/operations/allocation/remove.ts b/apps/server/src/operations/allocation/remove.ts index e7b7278..f5caf11 100644 --- a/apps/server/src/operations/allocation/remove.ts +++ b/apps/server/src/operations/allocation/remove.ts @@ -14,6 +14,7 @@ export default createOperation({ .selectFrom("allocations") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .forUpdate() .executeTakeFirst(); diff --git a/apps/server/src/operations/availability/query.ts b/apps/server/src/operations/availability/query.ts index dfd80d0..1f734a7 100644 --- a/apps/server/src/operations/availability/query.ts +++ b/apps/server/src/operations/availability/query.ts @@ -21,7 +21,7 @@ export default createOperation({ SELECT resource_id, start_at, end_at FROM allocations WHERE ledger_id = ${ledgerId} - AND resource_id = ANY(${sql.raw(`ARRAY[${resourceIds.map((id) => `'${id}'`).join(",")}]::text[]`)}) + 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_at < ${endAt} diff --git a/apps/server/src/operations/booking/cancel.ts b/apps/server/src/operations/booking/cancel.ts index fe66472..2403db2 100644 --- a/apps/server/src/operations/booking/cancel.ts +++ b/apps/server/src/operations/booking/cancel.ts @@ -14,6 +14,7 @@ export default createOperation({ .selectFrom("bookings") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .forUpdate() .executeTakeFirst(); diff --git a/apps/server/src/operations/booking/confirm.ts b/apps/server/src/operations/booking/confirm.ts index 81ba5d6..690ca8e 100644 --- a/apps/server/src/operations/booking/confirm.ts +++ b/apps/server/src/operations/booking/confirm.ts @@ -14,6 +14,7 @@ export default createOperation({ .selectFrom("bookings") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .forUpdate() .executeTakeFirst(); diff --git a/apps/server/src/operations/booking/get.ts b/apps/server/src/operations/booking/get.ts index 09f0daa..78e7d2a 100644 --- a/apps/server/src/operations/booking/get.ts +++ b/apps/server/src/operations/booking/get.ts @@ -9,6 +9,7 @@ export default createOperation({ .selectFrom("bookings") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); if (!bkg) { diff --git a/apps/server/src/operations/policy/get.ts b/apps/server/src/operations/policy/get.ts index 556add3..80e03e6 100644 --- a/apps/server/src/operations/policy/get.ts +++ b/apps/server/src/operations/policy/get.ts @@ -9,6 +9,7 @@ export default createOperation({ .selectFrom("policies") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); return { policy: row ?? null }; diff --git a/apps/server/src/operations/policy/remove.ts b/apps/server/src/operations/policy/remove.ts index 8994ac7..8c00731 100644 --- a/apps/server/src/operations/policy/remove.ts +++ b/apps/server/src/operations/policy/remove.ts @@ -5,7 +5,11 @@ import { policy } from "@floyd-run/schema/inputs"; export default createOperation({ input: policy.removeSchema, execute: async (input) => { - const result = await db.deleteFrom("policies").where("id", "=", input.id).executeTakeFirst(); + const result = await db + .deleteFrom("policies") + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); return { deleted: result.numDeletedRows > 0n }; }, diff --git a/apps/server/src/operations/policy/update.ts b/apps/server/src/operations/policy/update.ts index 56db225..bd9c8b9 100644 --- a/apps/server/src/operations/policy/update.ts +++ b/apps/server/src/operations/policy/update.ts @@ -12,6 +12,7 @@ export default createOperation({ .selectFrom("policies") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); if (!existing) { @@ -31,6 +32,7 @@ export default createOperation({ configHash, }) .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/src/operations/resource/get.ts b/apps/server/src/operations/resource/get.ts index e8670d0..226a0f9 100644 --- a/apps/server/src/operations/resource/get.ts +++ b/apps/server/src/operations/resource/get.ts @@ -8,6 +8,7 @@ export default createOperation({ const resource = await db .selectFrom("resources") .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .selectAll() .executeTakeFirst(); diff --git a/apps/server/src/operations/resource/remove.ts b/apps/server/src/operations/resource/remove.ts index d13c0bb..501f0aa 100644 --- a/apps/server/src/operations/resource/remove.ts +++ b/apps/server/src/operations/resource/remove.ts @@ -1,10 +1,19 @@ import { resource } from "@floyd-run/schema/inputs"; import { db } from "database"; import { createOperation } from "lib/operation"; +import { NotFoundError } from "lib/errors"; export default createOperation({ input: resource.removeSchema, execute: async (input) => { - await db.deleteFrom("resources").where("id", "=", input.id).executeTakeFirstOrThrow(); + 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"); + } }, }); diff --git a/apps/server/src/operations/service/get.ts b/apps/server/src/operations/service/get.ts index 730b310..525dfb7 100644 --- a/apps/server/src/operations/service/get.ts +++ b/apps/server/src/operations/service/get.ts @@ -9,6 +9,7 @@ export default createOperation({ .selectFrom("services") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); if (!svc) { diff --git a/apps/server/src/operations/service/remove.ts b/apps/server/src/operations/service/remove.ts index 62c31b5..305913d 100644 --- a/apps/server/src/operations/service/remove.ts +++ b/apps/server/src/operations/service/remove.ts @@ -12,6 +12,7 @@ export default createOperation({ .selectFrom("services") .select("id") .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .forUpdate() .executeTakeFirst(); diff --git a/apps/server/src/operations/service/update.ts b/apps/server/src/operations/service/update.ts index 7ff1b83..448efad 100644 --- a/apps/server/src/operations/service/update.ts +++ b/apps/server/src/operations/service/update.ts @@ -12,6 +12,7 @@ export default createOperation({ .selectFrom("services") .selectAll() .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .forUpdate() .executeTakeFirst(); diff --git a/apps/server/src/operations/webhook/remove.ts b/apps/server/src/operations/webhook/remove.ts index eb33fa2..a7438ba 100644 --- a/apps/server/src/operations/webhook/remove.ts +++ b/apps/server/src/operations/webhook/remove.ts @@ -8,6 +8,7 @@ export default createOperation({ 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/operations/webhook/rotate-secret.ts b/apps/server/src/operations/webhook/rotate-secret.ts index 41a1c59..15996dc 100644 --- a/apps/server/src/operations/webhook/rotate-secret.ts +++ b/apps/server/src/operations/webhook/rotate-secret.ts @@ -12,6 +12,7 @@ export default createOperation({ .updateTable("webhookSubscriptions") .set({ secret: newSecret }) .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) .returningAll() .executeTakeFirst(); diff --git a/apps/server/src/operations/webhook/update.ts b/apps/server/src/operations/webhook/update.ts index 1c48b82..ea6148c 100644 --- a/apps/server/src/operations/webhook/update.ts +++ b/apps/server/src/operations/webhook/update.ts @@ -11,6 +11,7 @@ export default createOperation({ ...(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 d3997e6..ee38fc9 100644 --- a/apps/server/src/routes/v1/allocations.ts +++ b/apps/server/src/routes/v1/allocations.ts @@ -17,7 +17,10 @@ export const allocations = new Hono<{ Variables: IdempotencyVariables }>() }) .get("/:id", async (c) => { - const { allocation } = await operations.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) }); }) @@ -34,6 +37,9 @@ export const allocations = new Hono<{ Variables: IdempotencyVariables }>() }) .delete("/:id", async (c) => { - await operations.allocation.remove({ id: c.req.param("id") }); + await operations.allocation.remove({ + id: c.req.param("id"), + ledgerId: c.req.param("ledgerId")!, + }); return c.body(null, 204); }); diff --git a/apps/server/src/routes/v1/bookings.ts b/apps/server/src/routes/v1/bookings.ts index 7a346b9..e017204 100644 --- a/apps/server/src/routes/v1/bookings.ts +++ b/apps/server/src/routes/v1/bookings.ts @@ -19,7 +19,10 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>() }) .get("/:id", async (c) => { - const { booking, allocations } = await operations.booking.get({ id: c.req.param("id") }); + 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) }); }) @@ -38,6 +41,7 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>() .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); @@ -47,6 +51,7 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>() .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); diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts index c74a25f..fc58774 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -13,7 +13,10 @@ export const policies = new Hono() }) .get("/:id", async (c) => { - const { policy } = await operations.policy.get({ id: c.req.param("id") }); + 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) }); }) @@ -37,6 +40,7 @@ export const policies = new Hono() 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) }; @@ -47,7 +51,10 @@ export const policies = new Hono() }) .delete("/:id", async (c) => { - const { deleted } = await operations.policy.remove({ id: c.req.param("id") }); + 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 582fa6d..d661746 100644 --- a/apps/server/src/routes/v1/resources.ts +++ b/apps/server/src/routes/v1/resources.ts @@ -13,7 +13,10 @@ export const resources = new Hono() }) .get("/:id", async (c) => { - const { resource } = await operations.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) }); }) @@ -28,6 +31,6 @@ export const resources = new Hono() }) .delete("/:id", async (c) => { - await operations.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/services.ts b/apps/server/src/routes/v1/services.ts index 86f015a..de6c308 100644 --- a/apps/server/src/routes/v1/services.ts +++ b/apps/server/src/routes/v1/services.ts @@ -15,7 +15,10 @@ export const services = new Hono() }) .get("/:id", async (c) => { - const { service, resourceIds } = await operations.service.get({ id: c.req.param("id") }); + 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) }); }) @@ -40,6 +43,6 @@ export const services = new Hono() }) .delete("/:id", async (c) => { - await operations.service.remove({ id: c.req.param("id") }); + await operations.service.remove({ id: c.req.param("id"), ledgerId: c.req.param("ledgerId")! }); return c.body(null, 204); }); diff --git a/apps/server/src/routes/v1/webhooks.ts b/apps/server/src/routes/v1/webhooks.ts index 54ec35b..487576e 100644 --- a/apps/server/src/routes/v1/webhooks.ts +++ b/apps/server/src/routes/v1/webhooks.ts @@ -39,6 +39,7 @@ export const webhooks = new Hono() const { subscription } = await operations.webhook.update({ ...body, id: c.req.param("subscriptionId")!, + ledgerId: c.req.param("ledgerId")!, }); if (!subscription) { @@ -52,6 +53,7 @@ export const webhooks = new Hono() .delete("/:subscriptionId", async (c) => { const { deleted } = await operations.webhook.remove({ id: c.req.param("subscriptionId")!, + ledgerId: c.req.param("ledgerId")!, }); if (!deleted) { @@ -65,6 +67,7 @@ export const webhooks = new Hono() .post("/:subscriptionId/rotate-secret", async (c) => { const { subscription, secret } = await operations.webhook.rotateSecret({ id: c.req.param("subscriptionId")!, + ledgerId: c.req.param("ledgerId")!, }); if (!subscription) { diff --git a/packages/schema/inputs/allocation.ts b/packages/schema/inputs/allocation.ts index a17c740..c810b10 100644 --- a/packages/schema/inputs/allocation.ts +++ b/packages/schema/inputs/allocation.ts @@ -12,6 +12,7 @@ export const createSchema = z.object({ export const getSchema = 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" }), }); export const listSchema = z.object({ @@ -20,4 +21,5 @@ export const listSchema = z.object({ export const removeSchema = 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/booking.ts b/packages/schema/inputs/booking.ts index 65e3858..bde97a6 100644 --- a/packages/schema/inputs/booking.ts +++ b/packages/schema/inputs/booking.ts @@ -14,6 +14,7 @@ export const createSchema = z.object({ export const getSchema = 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 listSchema = z.object({ @@ -22,8 +23,10 @@ export const listSchema = z.object({ export const confirmSchema = 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 cancelSchema = 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/policy.ts b/packages/schema/inputs/policy.ts index 5e5302a..4e20528 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -160,11 +160,13 @@ export const createSchema = z.object({ export const updateSchema = 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 getSchema = 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 listSchema = z.object({ @@ -173,4 +175,5 @@ export const listSchema = z.object({ export const removeSchema = 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 f8814a8..d3f07b1 100644 --- a/packages/schema/inputs/resource.ts +++ b/packages/schema/inputs/resource.ts @@ -8,6 +8,7 @@ export const createSchema = z.object({ export const getSchema = 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({ @@ -16,4 +17,5 @@ export const listSchema = z.object({ export const removeSchema = 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 index 1986ba1..e688981 100644 --- a/packages/schema/inputs/service.ts +++ b/packages/schema/inputs/service.ts @@ -32,6 +32,7 @@ export const updateSchema = z.object({ export const getSchema = 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 listSchema = z.object({ @@ -40,4 +41,5 @@ export const listSchema = z.object({ export const removeSchema = 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..bf0f0ae 100644 --- a/packages/schema/inputs/webhook.ts +++ b/packages/schema/inputs/webhook.ts @@ -14,6 +14,7 @@ export const updateSubscriptionSchema = 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(), }); @@ -21,10 +22,12 @@ export const deleteSubscriptionSchema = 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({ 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" }), }); From 51a11c2cf023351b5b8888ee994e82985f65d261 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 21:38:15 +0300 Subject: [PATCH 10/31] Add buffers to allocations --- apps/server/src/database/client.ts | 5 +- apps/server/src/database/schema.ts | 2 + ...2153727_create-bookings-services-tables.ts | 13 ++ .../src/operations/allocation/create.ts | 5 +- apps/server/src/operations/booking/create.ts | 32 ++-- apps/server/src/routes/v1/serializers.ts | 25 +-- .../setup/factories/allocation.factory.ts | 2 + .../setup/factories/booking.factory.ts | 2 + .../integration/v1/bookings/create.spec.ts | 159 ++++++++++++++++++ packages/schema/outputs/allocation.ts | 2 + packages/schema/outputs/booking.ts | 2 + 11 files changed, 216 insertions(+), 33 deletions(-) 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/schema.ts b/apps/server/src/database/schema.ts index d6f41ae..bdc85b2 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -27,6 +27,8 @@ export interface AllocationsTable { active: boolean; startAt: Date; endAt: Date; + bufferBeforeMs: number; + bufferAfterMs: number; expiresAt: Date | null; metadata: Record | null; createdAt: Generated; diff --git a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts index 1b50bf4..c0e0958 100644 --- a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts +++ b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts @@ -94,6 +94,17 @@ export async function up(db: Kysely): Promise { 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(); await db.schema.dropIndex("idx_allocations_time_range").ifExists().execute(); @@ -152,6 +163,8 @@ export async function down(db: Kysely): Promise { `.execute(db); // 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(); diff --git a/apps/server/src/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts index 76ae687..32494d3 100644 --- a/apps/server/src/operations/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -28,11 +28,10 @@ export default createOperation({ // 3. Check for overlapping active allocations (not expired) const conflicting = await trx .selectFrom("allocations") - .select(["id", "startAt", "endAt"]) + .select("id") .where("resourceId", "=", input.resourceId) .where("active", "=", true) .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", serverTime)])) - // Overlap condition: existing.start < new.end AND existing.end > new.start .where("startAt", "<", input.endAt) .where("endAt", ">", input.startAt) .execute(); @@ -54,6 +53,8 @@ export default createOperation({ active: true, startAt: input.startAt, endAt: input.endAt, + bufferBeforeMs: 0, + bufferAfterMs: 0, expiresAt: input.expiresAt ?? null, metadata: input.metadata ?? null, }) diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 510b0e8..5ef87b3 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -56,6 +56,10 @@ export default createOperation({ // 5. Policy evaluation (if service has a policy) let holdDurationMs = DEFAULT_HOLD_DURATION_MS; + let startAt = input.startAt; + let endAt = input.endAt; + let bufferBeforeMs = 0; + let bufferAfterMs = 0; if (svc.policyId) { const policy = await trx .selectFrom("policies") @@ -79,13 +83,19 @@ export default createOperation({ }); } + // Use buffer-expanded times as the allocation's blocked window + startAt = result.effectiveStartAt; + endAt = result.effectiveEndAt; + bufferBeforeMs = result.bufferBeforeMs; + bufferAfterMs = result.bufferAfterMs; + // Use policy hold_duration if configured - const policyConfig = policy.config as Record; + const configRecord = policy.config as Record; if ( - policyConfig["hold_duration_ms"] && - typeof policyConfig["hold_duration_ms"] === "number" + configRecord["hold_duration_ms"] && + typeof configRecord["hold_duration_ms"] === "number" ) { - holdDurationMs = policyConfig["hold_duration_ms"]; + holdDurationMs = configRecord["hold_duration_ms"]; } } } @@ -93,12 +103,12 @@ export default createOperation({ // 6. Conflict check: active, non-expired, overlapping allocations const conflicting = await trx .selectFrom("allocations") - .select(["id", "startAt", "endAt"]) + .select("id") .where("resourceId", "=", input.resourceId) .where("active", "=", true) .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", serverTime)])) - .where("startAt", "<", input.endAt) - .where("endAt", ">", input.startAt) + .where("startAt", "<", endAt) + .where("endAt", ">", startAt) .execute(); if (conflicting.length > 0) { @@ -125,7 +135,7 @@ export default createOperation({ .returningAll() .executeTakeFirstOrThrow(); - // 9. Insert allocation + // 9. Insert allocation (startAt/endAt = blocked window including buffers) const alloc = await trx .insertInto("allocations") .values({ @@ -134,8 +144,10 @@ export default createOperation({ resourceId: input.resourceId, bookingId: bkg.id, active: true, - startAt: input.startAt, - endAt: input.endAt, + startAt, + endAt, + bufferBeforeMs, + bufferAfterMs, expiresAt, metadata: null, }) diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index 3aa8b88..4dad78e 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -36,6 +36,8 @@ export function serializeAllocation(allocation: AllocationRow): Allocation { active: allocation.active, startAt: allocation.startAt.toISOString(), endAt: allocation.endAt.toISOString(), + bufferBeforeMs: allocation.bufferBeforeMs, + bufferAfterMs: allocation.bufferAfterMs, expiresAt: allocation.expiresAt?.toISOString() ?? null, metadata: allocation.metadata, createdAt: allocation.createdAt.toISOString(), @@ -61,30 +63,11 @@ export function serializeWebhookSubscription(sub: WebhookSubscriptionRow): Webho }; } -function camelToSnake(str: string): string { - return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); -} - -/** Recursively convert camelCase keys back to snake_case (undoes CamelCasePlugin on JSONB) */ -function snakeCaseKeys(obj: unknown): unknown { - if (Array.isArray(obj)) { - return obj.map(snakeCaseKeys); - } - if (obj !== null && typeof obj === "object" && !(obj instanceof Date)) { - const result: Record = {}; - for (const [key, value] of Object.entries(obj as Record)) { - result[camelToSnake(key)] = snakeCaseKeys(value); - } - return result; - } - return obj; -} - export function serializePolicy(policy: PolicyRow): Policy { return { id: policy.id, ledgerId: policy.ledgerId, - config: snakeCaseKeys(policy.config) as Record, + config: policy.config as Record, configHash: policy.configHash, createdAt: policy.createdAt.toISOString(), updatedAt: policy.updatedAt.toISOString(), @@ -116,6 +99,8 @@ export function serializeBooking(booking: BookingRow, allocations: AllocationRow resourceId: a.resourceId, startAt: a.startAt.toISOString(), endAt: a.endAt.toISOString(), + bufferBeforeMs: a.bufferBeforeMs, + bufferAfterMs: a.bufferAfterMs, active: a.active, })), metadata: booking.metadata, diff --git a/apps/server/test/integration/setup/factories/allocation.factory.ts b/apps/server/test/integration/setup/factories/allocation.factory.ts index ea883cb..adedfff 100644 --- a/apps/server/test/integration/setup/factories/allocation.factory.ts +++ b/apps/server/test/integration/setup/factories/allocation.factory.ts @@ -45,6 +45,8 @@ export async function createAllocation(overrides?: { active: overrides?.active ?? true, startAt, endAt, + 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 index 4139d6f..d7ca05f 100644 --- a/apps/server/test/integration/setup/factories/booking.factory.ts +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -81,6 +81,8 @@ export async function createBooking(overrides?: { active: status === "hold" || status === "confirmed", startAt, endAt, + bufferBeforeMs: 0, + bufferAfterMs: 0, expiresAt, metadata: null, }) diff --git a/apps/server/test/integration/v1/bookings/create.spec.ts b/apps/server/test/integration/v1/bookings/create.spec.ts index 4430148..23d4797 100644 --- a/apps/server/test/integration/v1/bookings/create.spec.ts +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -5,6 +5,7 @@ import { createResource, createService, createAllocation, + createPolicy, } from "../../setup/factories"; import type { Booking } from "@floyd-run/schema/types"; @@ -267,4 +268,162 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(response.status).toBe(201); }); }); + + describe("buffers", () => { + it("stores buffer-expanded times as allocation startAt/endAt", 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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "2026-06-01T11:00:00.000Z", + status: "confirmed", + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + const alloc = data.allocations[0]!; + + // Allocation startAt/endAt = buffer-expanded blocked window + expect(alloc.startAt).toBe("2026-06-01T09:45:00.000Z"); // 10:00 - 15min + expect(alloc.endAt).toBe("2026-06-01T11:10:00.000Z"); // 11:00 + 10min + + // Buffer amounts stored for deriving original customer time + expect(alloc.bufferBeforeMs).toBe(900_000); + expect(alloc.bufferAfterMs).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 startAt = "2026-06-01T10:00:00.000Z"; + const endAt = "2026-06-01T11:00:00.000Z"; + + const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startAt, + endAt, + status: "confirmed", + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Booking }; + const alloc = data.allocations[0]!; + + // Without policy, allocation times = input times (no buffers) + expect(alloc.startAt).toBe(startAt); + expect(alloc.endAt).toBe(endAt); + expect(alloc.bufferBeforeMs).toBe(0); + expect(alloc.bufferAfterMs).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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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, + startAt: "2026-06-01T11:00:00.000Z", + endAt: "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("overlap_conflict"); + }); + + 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, + startAt: "2026-06-01T10:00:00.000Z", + endAt: "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, + startAt: "2026-06-01T11:30:00.000Z", + endAt: "2026-06-01T12:30:00.000Z", + status: "confirmed", + }); + + expect(response.status).toBe(201); + }); + }); }); diff --git a/packages/schema/outputs/allocation.ts b/packages/schema/outputs/allocation.ts index 71dae76..a090c8e 100644 --- a/packages/schema/outputs/allocation.ts +++ b/packages/schema/outputs/allocation.ts @@ -8,6 +8,8 @@ export const schema = z.object({ active: z.boolean(), startAt: z.string(), endAt: z.string(), + bufferBeforeMs: z.number(), + bufferAfterMs: z.number(), expiresAt: z.string().nullable(), metadata: z.record(z.string(), z.unknown()).nullable(), createdAt: z.string(), diff --git a/packages/schema/outputs/booking.ts b/packages/schema/outputs/booking.ts index 2ef8f13..7dcc049 100644 --- a/packages/schema/outputs/booking.ts +++ b/packages/schema/outputs/booking.ts @@ -6,6 +6,8 @@ const bookingAllocationSchema = z.object({ resourceId: z.string(), startAt: z.string(), endAt: z.string(), + bufferBeforeMs: z.number(), + bufferAfterMs: z.number(), active: z.boolean(), }); From 1923ca2c9d77a0ec38f9c44692e9410659833998 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 21:57:46 +0300 Subject: [PATCH 11/31] Refactor insert allocation --- .../src/operations/allocation/create.ts | 54 +++++------------ .../operations/allocation/internal/insert.ts | 60 +++++++++++++++++++ apps/server/src/operations/booking/create.ts | 53 +++++----------- 3 files changed, 92 insertions(+), 75 deletions(-) create mode 100644 apps/server/src/operations/allocation/internal/insert.ts diff --git a/apps/server/src/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts index 32494d3..0d9f033 100644 --- a/apps/server/src/operations/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -1,10 +1,10 @@ import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; -import { generateId } from "@floyd-run/utils"; import { allocation } from "@floyd-run/schema/inputs"; -import { ConflictError, NotFoundError } from "lib/errors"; +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: allocation.createSchema, @@ -25,43 +25,21 @@ export default createOperation({ // 2. Capture server time immediately after acquiring lock const serverTime = await getServerTime(trx); - // 3. Check for overlapping active allocations (not expired) - const conflicting = await trx - .selectFrom("allocations") - .select("id") - .where("resourceId", "=", input.resourceId) - .where("active", "=", true) - .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", serverTime)])) - .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. Insert the allocation - const alloc = await trx - .insertInto("allocations") - .values({ - id: generateId("alc"), - ledgerId: input.ledgerId, - resourceId: input.resourceId, - bookingId: null, - active: true, - startAt: input.startAt, - endAt: input.endAt, - bufferBeforeMs: 0, - bufferAfterMs: 0, - expiresAt: input.expiresAt ?? null, - metadata: input.metadata ?? null, - }) - .returningAll() - .executeTakeFirstOrThrow(); + // 3. Check conflicts + insert allocation + const alloc = await insertAllocation(trx, { + ledgerId: input.ledgerId, + resourceId: input.resourceId, + bookingId: null, + startAt: input.startAt, + endAt: input.endAt, + bufferBeforeMs: 0, + bufferAfterMs: 0, + expiresAt: input.expiresAt ?? null, + metadata: input.metadata ?? null, + serverTime, + }); - // 5. Enqueue webhook event (in same transaction) + // 4. Enqueue webhook event (in same transaction) await enqueueWebhookEvent(trx, "allocation.created", input.ledgerId, { allocation: serializeAllocation(alloc), }); 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..03a1d3d --- /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; + startAt: Date; + endAt: 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("startAt", "<", params.endAt) + .where("endAt", ">", params.startAt) + .execute(); + + if (conflicting.length > 0) { + throw new ConflictError("overlap_conflict", { + conflictingAllocationIds: conflicting.map((a) => a.id), + }); + } + + // 2. Insert the allocation + const alloc = await trx + .insertInto("allocations") + .values({ + id: generateId("alc"), + ledgerId: params.ledgerId, + resourceId: params.resourceId, + bookingId: params.bookingId, + active: true, + startAt: params.startAt, + endAt: params.endAt, + bufferBeforeMs: params.bufferBeforeMs, + bufferAfterMs: params.bufferAfterMs, + expiresAt: params.expiresAt, + metadata: params.metadata, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return alloc; +} diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 5ef87b3..e78ce8a 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -6,6 +6,7 @@ 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 @@ -100,28 +101,11 @@ export default createOperation({ } } - // 6. Conflict check: active, non-expired, overlapping allocations - const conflicting = await trx - .selectFrom("allocations") - .select("id") - .where("resourceId", "=", input.resourceId) - .where("active", "=", true) - .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", serverTime)])) - .where("startAt", "<", endAt) - .where("endAt", ">", startAt) - .execute(); - - if (conflicting.length > 0) { - throw new ConflictError("overlap_conflict", { - conflictingAllocationIds: conflicting.map((a) => a.id), - }); - } - - // 7. Compute expiresAt + // 6. Compute expiresAt const isHold = input.status === "hold"; const expiresAt = isHold ? new Date(serverTime.getTime() + holdDurationMs) : null; - // 8. Insert booking + // 7. Insert booking const bkg = await trx .insertInto("bookings") .values({ @@ -135,24 +119,19 @@ export default createOperation({ .returningAll() .executeTakeFirstOrThrow(); - // 9. Insert allocation (startAt/endAt = blocked window including buffers) - const alloc = await trx - .insertInto("allocations") - .values({ - id: generateId("alc"), - ledgerId: input.ledgerId, - resourceId: input.resourceId, - bookingId: bkg.id, - active: true, - startAt, - endAt, - bufferBeforeMs, - bufferAfterMs, - expiresAt, - metadata: null, - }) - .returningAll() - .executeTakeFirstOrThrow(); + // 8. Conflict check + insert allocation (startAt/endAt = blocked window including buffers) + const alloc = await insertAllocation(trx, { + ledgerId: input.ledgerId, + resourceId: input.resourceId, + bookingId: bkg.id, + startAt, + endAt, + bufferBeforeMs, + bufferAfterMs, + expiresAt, + metadata: null, + serverTime, + }); // 10. Enqueue webhook await enqueueWebhookEvent(trx, "booking.created", input.ledgerId, { From 2fe3b1e3fcd3481ba5f4a1bd492838619a7fb8da Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Thu, 12 Feb 2026 22:02:09 +0300 Subject: [PATCH 12/31] Update docs --- apps/server/openapi.json | 130 ++++++++++++++++++-- apps/server/src/scripts/generate-openapi.ts | 5 +- docs/allocations.md | 30 +++-- docs/bookings.md | 30 ++++- 4 files changed, 168 insertions(+), 27 deletions(-) diff --git a/apps/server/openapi.json b/apps/server/openapi.json index c979342..2e76153 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -79,6 +79,12 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "expiresAt": { "type": ["string", "null"] }, @@ -101,6 +107,8 @@ "active", "startAt", "endAt", + "bufferBeforeMs", + "bufferAfterMs", "expiresAt", "metadata", "createdAt", @@ -276,11 +284,25 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "active": { "type": "boolean" } }, - "required": ["id", "resourceId", "startAt", "endAt", "active"] + "required": [ + "id", + "resourceId", + "startAt", + "endAt", + "bufferBeforeMs", + "bufferAfterMs", + "active" + ] } }, "metadata": { @@ -929,6 +951,12 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "expiresAt": { "type": ["string", "null"] }, @@ -951,6 +979,8 @@ "active", "startAt", "endAt", + "bufferBeforeMs", + "bufferAfterMs", "expiresAt", "metadata", "createdAt", @@ -1045,6 +1075,12 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "expiresAt": { "type": ["string", "null"] }, @@ -1067,6 +1103,8 @@ "active", "startAt", "endAt", + "bufferBeforeMs", + "bufferAfterMs", "expiresAt", "metadata", "createdAt", @@ -1181,6 +1219,12 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "expiresAt": { "type": ["string", "null"] }, @@ -1203,6 +1247,8 @@ "active", "startAt", "endAt", + "bufferBeforeMs", + "bufferAfterMs", "expiresAt", "metadata", "createdAt", @@ -2010,11 +2056,25 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "active": { "type": "boolean" } }, - "required": ["id", "resourceId", "startAt", "endAt", "active"] + "required": [ + "id", + "resourceId", + "startAt", + "endAt", + "bufferBeforeMs", + "bufferAfterMs", + "active" + ] } }, "metadata": { @@ -2052,7 +2112,7 @@ "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. Supports idempotency via the Idempotency-Key header.", + "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 startAt/endAt represent the buffer-expanded blocked window. The original customer time can be derived using bufferBeforeMs and bufferAfterMs on the allocation. Supports idempotency via the Idempotency-Key header.", "parameters": [ { "schema": { @@ -2147,11 +2207,25 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "active": { "type": "boolean" } }, - "required": ["id", "resourceId", "startAt", "endAt", "active"] + "required": [ + "id", + "resourceId", + "startAt", + "endAt", + "bufferBeforeMs", + "bufferAfterMs", + "active" + ] } }, "metadata": { @@ -2334,11 +2408,25 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "active": { "type": "boolean" } }, - "required": ["id", "resourceId", "startAt", "endAt", "active"] + "required": [ + "id", + "resourceId", + "startAt", + "endAt", + "bufferBeforeMs", + "bufferAfterMs", + "active" + ] } }, "metadata": { @@ -2485,11 +2573,25 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "active": { "type": "boolean" } }, - "required": ["id", "resourceId", "startAt", "endAt", "active"] + "required": [ + "id", + "resourceId", + "startAt", + "endAt", + "bufferBeforeMs", + "bufferAfterMs", + "active" + ] } }, "metadata": { @@ -2672,11 +2774,25 @@ "endAt": { "type": "string" }, + "bufferBeforeMs": { + "type": "number" + }, + "bufferAfterMs": { + "type": "number" + }, "active": { "type": "boolean" } }, - "required": ["id", "resourceId", "startAt", "endAt", "active"] + "required": [ + "id", + "resourceId", + "startAt", + "endAt", + "bufferBeforeMs", + "bufferAfterMs", + "active" + ] } }, "metadata": { diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index e744cc3..51e897f 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -483,7 +483,10 @@ registry.registerPath({ 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. Supports idempotency via the Idempotency-Key header.", + "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 startAt/endAt represent the buffer-expanded blocked window. " + + "The original customer time can be derived using bufferBeforeMs and bufferAfterMs on the allocation. " + + "Supports idempotency via the Idempotency-Key header.", request: { params: z.object({ ledgerId: z.string() }), body: { diff --git a/docs/allocations.md b/docs/allocations.md index 5e8feaf..4966746 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -41,6 +41,8 @@ Response: "active": true, "startAt": "2026-01-04T10:00:00.000Z", "endAt": "2026-01-04T11:00:00.000Z", + "bufferBeforeMs": 0, + "bufferAfterMs": 0, "expiresAt": null, "metadata": { "reason": "maintenance" }, "createdAt": "2026-01-04T10:00:00.000Z", @@ -118,16 +120,18 @@ Overlap exists iff: ## 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 | -| `startAt` | string | Start of the time block (ISO 8601) | -| `endAt` | string | End of the time block (ISO 8601) | -| `expiresAt` | string | Expiration time, or `null` for permanent blocks | -| `metadata` | object | Arbitrary key-value data | -| `createdAt` | string | Creation timestamp | -| `updatedAt` | string | Last update timestamp | +| 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 | +| `startAt` | string | Start of the blocked time window (ISO 8601). Includes buffer if from a booking with a buffer policy. | +| `endAt` | string | End of the blocked time window (ISO 8601). Includes buffer if from a booking with a buffer policy. | +| `bufferBeforeMs` | number | Buffer time before the customer appointment (ms). `0` for raw allocations. | +| `bufferAfterMs` | number | Buffer time after the customer appointment (ms). `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/bookings.md b/docs/bookings.md index e48fa6b..086cb65 100644 --- a/docs/bookings.md +++ b/docs/bookings.md @@ -50,10 +50,11 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ ### 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, etc.) -3. Checks for time conflicts on the resource -4. Creates a booking with `status=hold` and `expiresAt` (default: 15 minutes from now) -5. Creates an allocation linked to the booking +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 @@ -130,8 +131,10 @@ After expiration, the time slot is available for new bookings. { "id": "alc_...", "resourceId": "rsc_...", - "startAt": "2026-03-01T10:00:00.000Z", - "endAt": "2026-03-01T11:00:00.000Z", + "startAt": "2026-03-01T09:45:00.000Z", + "endAt": "2026-03-01T11:10:00.000Z", + "bufferBeforeMs": 900000, + "bufferAfterMs": 600000, "active": true } ], @@ -147,6 +150,21 @@ After expiration, the time slot is available for new bookings. Mutating endpoints (`create`, `confirm`, `cancel`) return `meta.serverTime`. +## Buffers + +When a service's policy defines buffers (`before_ms` / `after_ms`), the allocation's `startAt` and `endAt` 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 `bufferBeforeMs` and `bufferAfterMs` so the original customer time is derivable: + +- Customer start = `allocation.startAt` + `bufferBeforeMs` +- Customer end = `allocation.endAt` - `bufferAfterMs` + +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 Cancelled 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. From f392d9dac9637257b70c51fc8e881abfd27fcf8e Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 01:12:27 +0300 Subject: [PATCH 13/31] Add service availability --- apps/server/openapi.json | 480 +++++++++++ apps/server/src/domain/policy/evaluate.ts | 4 +- apps/server/src/domain/policy/index.ts | 2 + .../src/domain/scheduling/availability.ts | 605 ++++++++++++++ apps/server/src/domain/scheduling/index.ts | 10 + .../src/operations/availability/index.ts | 4 + .../src/operations/availability/slots.ts | 146 ++++ .../src/operations/availability/windows.ts | 145 ++++ apps/server/src/routes/v1/services.ts | 26 + apps/server/src/scripts/generate-openapi.ts | 111 +++ .../setup/factories/resource.factory.ts | 3 +- .../integration/v1/availability/slots.spec.ts | 514 ++++++++++++ .../v1/availability/windows.spec.ts | 447 ++++++++++ .../domain/scheduling/availability.spec.ts | 774 ++++++++++++++++++ packages/schema/inputs/availability.ts | 22 + packages/schema/outputs/availability.ts | 36 + 16 files changed, 3326 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/domain/scheduling/availability.ts create mode 100644 apps/server/src/operations/availability/slots.ts create mode 100644 apps/server/src/operations/availability/windows.ts create mode 100644 apps/server/test/integration/v1/availability/slots.spec.ts create mode 100644 apps/server/test/integration/v1/availability/windows.spec.ts create mode 100644 apps/server/test/unit/domain/scheduling/availability.spec.ts diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 2e76153..f770427 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -180,6 +180,100 @@ }, "required": ["startAt", "endAt", "status"] }, + "Slot": { + "type": "object", + "properties": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startAt", "endAt"] + }, + "ResourceSlots": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "slots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startAt", "endAt"] + } + } + }, + "required": ["resourceId", "timezone", "slots"] + }, + "Window": { + "type": "object", + "properties": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startAt", "endAt"] + }, + "ResourceWindows": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startAt", "endAt"] + } + } + }, + "required": ["resourceId", "timezone", "windows"] + }, "Policy": { "type": "object", "properties": { @@ -1996,6 +2090,392 @@ } } }, + "/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": { + "startAt": { + "type": "string", + "format": "date-time", + "description": "Start of the query window (ISO 8601)", + "example": "2026-03-02T00:00:00Z" + }, + "endAt": { + "type": "string", + "format": "date-time", + "description": "End of the query window (ISO 8601). Max 7 days from startAt.", + "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": ["startAt", "endAt", "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": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startAt", "endAt"] + } + } + }, + "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": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["code"] + } + ] + } + }, + "required": ["error"] + } + } + } + }, + "422": { + "description": "Invalid input (range too large, invalid resourceIds, etc.)", + "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": ["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": { + "startAt": { + "type": "string", + "format": "date-time", + "description": "Start of the query window (ISO 8601)", + "example": "2026-03-02T00:00:00Z" + }, + "endAt": { + "type": "string", + "format": "date-time", + "description": "End of the query window (ISO 8601). Max 31 days from startAt.", + "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": ["startAt", "endAt"] + } + } + } + }, + "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": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["available", "unavailable"] + } + }, + "required": ["startAt", "endAt"] + } + } + }, + "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": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["code"] + } + ] + } + }, + "required": ["error"] + } + } + } + }, + "422": { + "description": "Invalid input (range too large, invalid resourceIds, etc.)", + "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": ["error"] + } + } + } + } + } + } + }, "/v1/ledgers/{ledgerId}/bookings": { "get": { "tags": ["Bookings"], diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index 3354bc5..da988dc 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -191,7 +191,7 @@ export function dateRange(from: string, to: string): string[] { /** * Convert time string (HH:MM) to milliseconds since midnight. */ -function timeToMs(time: string): number { +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; @@ -199,7 +199,7 @@ function timeToMs(time: string): number { // ─── Match Logic ───────────────────────────────────────────────────────────── -function matchesCondition(match: RuleMatch, dateStr: string, dayOfWeek: string): boolean { +export function matchesCondition(match: RuleMatch, dateStr: string, dayOfWeek: string): boolean { switch (match.type) { case "weekly": return match.days?.includes(dayOfWeek) ?? false; diff --git a/apps/server/src/domain/policy/index.ts b/apps/server/src/domain/policy/index.ts index 41c5583..adb09c3 100644 --- a/apps/server/src/domain/policy/index.ts +++ b/apps/server/src/domain/policy/index.ts @@ -8,6 +8,8 @@ export { msSinceLocalMidnight, getDayOfWeek, dateRange, + timeToMs, + matchesCondition, REASON_CODES, type PolicyConfig, type EvaluationInput, diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts new file mode 100644 index 0000000..c4e9826 --- /dev/null +++ b/apps/server/src/domain/scheduling/availability.ts @@ -0,0 +1,605 @@ +/** + * 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, + matchesCondition, + timeToMs, + toLocalDate, + getDayOfWeek, + dateRange, +} from "domain/policy/evaluate"; +import { mergeIntervals, type Interval } from "./timeline"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface DurationConfig { + min_ms?: number; + max_ms?: number; + allowed_ms?: number[]; +} + +interface GridConfig { + interval_ms: number; +} + +interface BookingWindowConfig { + min_lead_time_ms?: number; + max_lead_time_ms?: number; +} + +interface BuffersConfig { + before_ms?: number; + after_ms?: number; +} + +export interface ResolvedDay { + date: string; + windows: { startMs: number; endMs: number }[]; + config: ResolvedConfig; +} + +export interface BlockingAllocation { + resourceId: string; + startAt: Date; + endAt: Date; +} + +export interface SlotResult { + startAt: string; + endAt: string; + status?: "available" | "unavailable"; +} + +export interface WindowResult { + startAt: string; + endAt: 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 ?? {}) as Record; + const ruleConfig = (matchedRule.config ?? {}) as Record; + const resolvedRaw = { ...baseConfig, ...ruleConfig }; + const config: ResolvedConfig = { + duration: resolvedRaw["duration"] as DurationConfig | undefined, + grid: resolvedRaw["grid"] as GridConfig | undefined, + booking_window: resolvedRaw["booking_window"] as BookingWindowConfig | undefined, + buffers: resolvedRaw["buffers"] as BuffersConfig | 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 ?? {}) as Record; + const ruleConfig = (matchedRule?.config ?? {}) as Record; + const resolvedRaw = { ...baseConfig, ...ruleConfig }; + const config: ResolvedConfig = { + duration: resolvedRaw["duration"] as DurationConfig | undefined, + grid: resolvedRaw["grid"] as GridConfig | undefined, + booking_window: resolvedRaw["booking_window"] as BookingWindowConfig | undefined, + buffers: resolvedRaw["buffers"] as BuffersConfig | 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, + startAt: Date, + endAt: Date, + timezone: string, +): ResolvedDay[] { + const startDate = toLocalDate(startAt, timezone); + const endInstant = new Date(endAt.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 + const localStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(ms).padStart(3, "0")}`; + + // Create a rough UTC estimate, then adjust for timezone offset + const roughUtc = new Date(`${localStr}Z`); + + // 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); + + // 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(roughUtc.getUTCFullYear(), 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 as DurationConfig | undefined; + 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 alloc of allocations) { + if (start < alloc.end && end > alloc.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.startAt.getTime(), + end: a.endAt.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 as GridConfig | undefined)?.interval_ms ?? durationMs; + const beforeMs = (day.config.buffers as BuffersConfig | undefined)?.before_ms ?? 0; + const afterMs = (day.config.buffers as BuffersConfig | undefined)?.after_ms ?? 0; + const minLeadMs = (day.config.booking_window as BookingWindowConfig | undefined) + ?.min_lead_time_ms; + const maxLeadMs = (day.config.booking_window as BookingWindowConfig | undefined) + ?.max_lead_time_ms; + + for (const window of day.windows) { + // Generate candidates at grid steps within this window + for ( + let startMs = window.startMs; + 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 = { + startAt: candidateStart.toISOString(), + endAt: candidateEnd.toISOString(), + }; + if (includeUnavailable) slot.status = "available"; + slots.push(slot); + } else if (includeUnavailable) { + slots.push({ + startAt: candidateStart.toISOString(), + endAt: 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.startAt, + end: a.endAt, + })); + + // 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 and merge schedule intervals (handles contiguous cross-day windows) + const sortedSchedule = [...scheduleIntervals].sort( + (a, b) => a.start.getTime() - b.start.getTime(), + ); + const mergedSchedule = mergeIntervals(sortedSchedule); + + // 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 as BuffersConfig | undefined)?.before_ms ?? 0; + const afterMs = (config.buffers as BuffersConfig | undefined)?.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 as DurationConfig | undefined)?.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 bwConfig = config.booking_window as BookingWindowConfig | undefined; + + // For windows, we check if the window START is within the booking window + const leadTime = w.start.getTime() - serverTimeMs; + + if (bwConfig?.min_lead_time_ms !== undefined && leadTime < bwConfig.min_lead_time_ms) { + // Trim the window start to meet lead time, rather than discarding entirely + const minStart = serverTimeMs + bwConfig.min_lead_time_ms; + if (minStart < w.end.getTime()) { + w.start = new Date(minStart); + } else { + return false; + } + } + + if (bwConfig?.max_lead_time_ms !== undefined) { + const maxStart = serverTimeMs + bwConfig.max_lead_time_ms; + if (w.start.getTime() > maxStart) return false; + // Trim end if needed + if (w.end.getTime() > maxStart) { + w.end = new Date(maxStart); + } + } + + return true; + }); + + // Step 7: Merge contiguous windows + const sortedFiltered = [...leadFiltered].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({ + startAt: entry.interval.start.toISOString(), + endAt: entry.interval.end.toISOString(), + status: entry.status, + }); + } + } else { + for (const w of finalWindows) { + results.push({ + startAt: w.start.toISOString(), + endAt: 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 index e959b4d..e9bb79d 100644 --- a/apps/server/src/domain/scheduling/index.ts +++ b/apps/server/src/domain/scheduling/index.ts @@ -1 +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/operations/availability/index.ts b/apps/server/src/operations/availability/index.ts index 51a6c20..63b70f3 100644 --- a/apps/server/src/operations/availability/index.ts +++ b/apps/server/src/operations/availability/index.ts @@ -1,5 +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/operations/availability/slots.ts b/apps/server/src/operations/availability/slots.ts new file mode 100644 index 0000000..77599d6 --- /dev/null +++ b/apps/server/src/operations/availability/slots.ts @@ -0,0 +1,146 @@ +import { sql } from "kysely"; +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { availability } 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: availability.slotsSchema, + execute: async (input) => { + const { ledgerId, serviceId, startAt, endAt, durationMs, includeUnavailable } = input; + + // Validate query window + if (endAt.getTime() - startAt.getTime() > MAX_SLOTS_RANGE_MS) { + throw new InputError([ + { code: "custom", message: "Query range exceeds maximum of 7 days", path: ["endAt"] }, + ]); + } + + if (endAt <= startAt) { + throw new InputError([ + { code: "custom", message: "endAt must be after startAt", path: ["endAt"] }, + ]); + } + + // 1. Load service + const svc = await db + .selectFrom("services") + .selectAll() + .where("id", "=", serviceId) + .where("ledgerId", "=", ledgerId) + .executeTakeFirst(); + + if (!svc) 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) { + return { data: [], serverTime: new Date() }; + } + + // 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 (svc.policyId) { + const policyRow = await db + .selectFrom("policies") + .selectAll() + .where("id", "=", svc.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_at, end_at + 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_at < ${endAt} + AND end_at > ${startAt} + ORDER BY resource_id, start_at + `.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 ?? "UTC"; + const allocs = allocByResource.get(resourceId) ?? []; + + const resolvedDays = resolveServiceDays(policy, startAt, endAt, timezone); + const slots = generateSlots( + resolvedDays, + allocs, + durationMs, + serverTime, + timezone, + includeUnavailable, + startAt, + endAt, + ); + + 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..e4968ce --- /dev/null +++ b/apps/server/src/operations/availability/windows.ts @@ -0,0 +1,145 @@ +import { sql } from "kysely"; +import { db, getServerTime } from "database"; +import { createOperation } from "lib/operation"; +import { availability } 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: availability.windowsSchema, + execute: async (input) => { + const { ledgerId, serviceId, startAt, endAt, includeUnavailable } = input; + + // Validate query window + if (endAt.getTime() - startAt.getTime() > MAX_WINDOWS_RANGE_MS) { + throw new InputError([ + { code: "custom", message: "Query range exceeds maximum of 31 days", path: ["endAt"] }, + ]); + } + + if (endAt <= startAt) { + throw new InputError([ + { code: "custom", message: "endAt must be after startAt", path: ["endAt"] }, + ]); + } + + // 1. Load service + const svc = await db + .selectFrom("services") + .selectAll() + .where("id", "=", serviceId) + .where("ledgerId", "=", ledgerId) + .executeTakeFirst(); + + if (!svc) 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) { + return { data: [], serverTime: new Date() }; + } + + // 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 (svc.policyId) { + const policyRow = await db + .selectFrom("policies") + .selectAll() + .where("id", "=", svc.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_at, end_at + 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_at < ${endAt} + AND end_at > ${startAt} + ORDER BY resource_id, start_at + `.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 ?? "UTC"; + const allocs = allocByResource.get(resourceId) ?? []; + + const resolvedDays = resolveServiceDays(policy, startAt, endAt, timezone); + const windows = computeWindows( + resolvedDays, + allocs, + serverTime, + timezone, + includeUnavailable, + startAt, + endAt, + ); + + return { resourceId, timezone, windows }; + }); + + return { data, serverTime }; + }, +}); diff --git a/apps/server/src/routes/v1/services.ts b/apps/server/src/routes/v1/services.ts index de6c308..442d621 100644 --- a/apps/server/src/routes/v1/services.ts +++ b/apps/server/src/routes/v1/services.ts @@ -45,4 +45,30 @@ export const services = new Hono() .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/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 51e897f..df770a1 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -23,6 +23,10 @@ registry.register("Allocation", allocation.schema); registry.register("WebhookSubscription", webhook.subscriptionSchema); registry.register("AvailabilityItem", availability.itemSchema); registry.register("TimelineBlock", availability.timelineBlockSchema); +registry.register("Slot", availability.slotSchema); +registry.register("ResourceSlots", availability.resourceSlotsSchema); +registry.register("Window", availability.windowSchema); +registry.register("ResourceWindows", availability.resourceWindowsSchema); registry.register("Policy", policy.schema); registry.register("Service", service.schema); registry.register("Booking", booking.schema); @@ -441,6 +445,113 @@ registry.registerPath({ }, }); +// Service Availability routes +registry.registerPath({ + method: "post", + 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({ + startAt: z.string().datetime().openapi({ + description: "Start of the query window (ISO 8601)", + example: "2026-03-02T00:00:00Z", + }), + endAt: z.string().datetime().openapi({ + description: "End of the query window (ISO 8601). Max 7 days from startAt.", + 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: "Available slots per resource", + content: { "application/json": { schema: availability.slotsResponseSchema } }, + }, + 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 } }, + }, + }, +}); + +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({ + startAt: z.string().datetime().openapi({ + description: "Start of the query window (ISO 8601)", + example: "2026-03-02T00:00:00Z", + }), + endAt: z.string().datetime().openapi({ + description: "End of the query window (ISO 8601). Max 31 days from startAt.", + 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.windowsResponseSchema } }, + }, + 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", diff --git a/apps/server/test/integration/setup/factories/resource.factory.ts b/apps/server/test/integration/setup/factories/resource.factory.ts index 38bbd69..bb5fe9c 100644 --- a/apps/server/test/integration/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 | null }) { 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 ?? null, }) .returningAll() .executeTakeFirst(); 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..1bbbfce --- /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: { startAt: string; endAt: 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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T09:00:00.000Z"); + expect(slots[0]!.endAt).toBe("2026-03-02T10:00:00.000Z"); + expect(slots[slots.length - 1]!.startAt).toBe("2026-03-02T16:00:00.000Z"); + expect(slots[slots.length - 1]!.endAt).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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T09:00:00.000Z"); + expect(slots[0]!.endAt).toBe("2026-03-02T10:30:00.000Z"); + expect(slots[1]!.startAt).toBe("2026-03-02T09:30:00.000Z"); + expect(slots[1]!.endAt).toBe("2026-03-02T11:00:00.000Z"); + + // Last slot at 15:30 (15:30 + 90min = 17:00) + expect(slots[slots.length - 1]!.startAt).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`, + { + startAt: "2026-03-02T00:00:00Z", // Monday + endAt: "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]!.startAt).toBe("2026-03-02T09:00:00.000Z"); + expect(slots[1]!.startAt).toBe("2026-03-02T10:00:00.000Z"); + expect(slots[2]!.startAt).toBe("2026-03-02T11:00:00.000Z"); + expect(slots[3]!.startAt).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`, + { + startAt: "2026-03-06T00:00:00Z", + endAt: "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.startAt.startsWith("2026-03-06")); + const saturdaySlots = slots.filter((s) => s.startAt.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, + startAt: new Date("2026-03-02T10:00:00Z"), + endAt: new Date("2026-03-02T11:10:00Z"), // buffer-expanded + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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.startAt); + 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, + startAt: new Date("2026-03-02T10:00:00Z"), + endAt: new Date("2026-03-02T11:10:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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.startAt === "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.startAt === "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, + startAt: new Date("2026-03-02T10:00:00Z"), + endAt: new Date("2026-03-02T11:10:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T10:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T10:00:00.000Z"); + expect(slots[3]!.startAt).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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-01T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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..f1bc37b --- /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: { startAt: string; endAt: 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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T09:00:00.000Z"); + expect(windows[0]!.endAt).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, + startAt: new Date("2026-03-02T10:00:00Z"), + endAt: new Date("2026-03-02T13:30:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T13:30:00.000Z"); + expect(windows[0]!.endAt).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, + startAt: new Date("2026-03-02T09:45:00Z"), + endAt: new Date("2026-03-02T11:10:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T09:00:00.000Z"); + expect(windows[0]!.endAt).toBe("2026-03-02T09:35:00.000Z"); + expect(windows[1]!.startAt).toBe("2026-03-02T11:25:00.000Z"); + expect(windows[1]!.endAt).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, + startAt: new Date("2026-03-02T09:00:00Z"), + endAt: new Date("2026-03-02T12:00:00Z"), + }); + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + active: true, + startAt: new Date("2026-03-02T13:00:00Z"), + endAt: new Date("2026-03-02T17:00:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T00:00:00.000Z"); + expect(windows[0]!.endAt).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, + startAt: new Date("2026-03-02T10:00:00Z"), + endAt: new Date("2026-03-02T13:30:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T10:00:00.000Z"); + expect(unavailable[0]!.endAt).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, + startAt: new Date("2026-03-02T10:00:00Z"), + endAt: new Date("2026-03-02T15:00:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T09:00:00.000Z"); + expect(r2Data.windows[0]!.endAt).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, + startAt: new Date("2026-03-02T10:00:00Z"), + endAt: new Date("2026-03-02T12:00:00Z"), + }); + + const response = await client.post( + `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, + { + startAt: "2026-03-02T08:00:00Z", + endAt: "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]!.startAt).toBe("2026-03-02T08:00:00.000Z"); + expect(windows[0]!.endAt).toBe("2026-03-02T10:00:00.000Z"); + expect(windows[1]!.startAt).toBe("2026-03-02T12:00:00.000Z"); + expect(windows[1]!.endAt).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`, + { + startAt: "2026-03-01T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "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`, + { + startAt: "2026-03-02T00:00:00Z", + endAt: "2026-03-02T23:59:59Z", + }, + ); + + expect(response.status).toBe(404); + }); +}); 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..7ac8ac1 --- /dev/null +++ b/apps/server/test/unit/domain/scheduling/availability.spec.ts @@ -0,0 +1,774 @@ +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("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", + ); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(slots[4]!.startAt).toBe("2026-03-16T11:00:00.000Z"); + expect(slots[4]!.endAt).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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(slots[3]!.startAt).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", + startAt: new Date("2026-03-16T10:00:00Z"), + endAt: 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.startAt); + + 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", + startAt: new Date("2026-03-16T10:00:00Z"), + endAt: 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.startAt); + + // 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", + startAt: new Date("2026-03-16T09:50:00Z"), + endAt: 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.startAt); + + // 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]], { + booking_window: { min_lead_time_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]], { + booking_window: { max_lead_time_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.startAt); + + 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", + startAt: new Date("2026-03-16T10:00:00Z"), + endAt: 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", + startAt: new Date("2026-03-16T10:00:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T10:00:00.000Z"); + expect(slots[1]!.startAt).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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(slots[2]!.startAt).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.startAt.startsWith("2026-03-16")); + const day2 = slots.filter((s) => s.startAt.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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endAt).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", + startAt: new Date("2026-03-16T12:00:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endAt).toBe("2026-03-16T12:00:00.000Z"); + expect(windows[1]!.startAt).toBe("2026-03-16T13:00:00.000Z"); + expect(windows[1]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + }); + + 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", + startAt: new Date("2026-03-16T09:45:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endAt).toBe("2026-03-16T09:35:00.000Z"); + expect(windows[1]!.startAt).toBe("2026-03-16T11:25:00.000Z"); + expect(windows[1]!.endAt).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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endAt).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", + startAt: new Date("2026-03-16T09:00:00Z"), + endAt: new Date("2026-03-16T11:00:00Z"), + }, + { + resourceId: "r", + startAt: new Date("2026-03-16T14:00:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T11:15:00.000Z"); + expect(windows[0]!.endAt).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", + startAt: new Date("2026-03-16T09:00:00Z"), + endAt: new Date("2026-03-16T12:00:00Z"), + }, + { + resourceId: "r", + startAt: new Date("2026-03-16T12:30:00Z"), + endAt: 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", + startAt: new Date("2026-03-16T09:00:00Z"), + endAt: new Date("2026-03-16T12:00:00Z"), + }, + { + resourceId: "r", + startAt: new Date("2026-03-16T13:00:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T00:00:00.000Z"); + expect(windows[0]!.endAt).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", + startAt: new Date("2026-03-16T12:00:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T12:00:00.000Z"); + expect(unavailable[0]!.endAt).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]!.startAt).toBe("2026-03-16T10:00:00.000Z"); + expect(windows[0]!.endAt).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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endAt).toBe("2026-03-16T12:00:00.000Z"); + expect(windows[1]!.startAt).toBe("2026-03-16T14:00:00.000Z"); + expect(windows[1]!.endAt).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", + startAt: new Date("2026-03-16T07:00:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); + expect(windows[0]!.endAt).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", + startAt: new Date("2026-03-16T08:00:00Z"), + endAt: 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]!.startAt).toBe("2026-03-16T10:00:00.000Z"); + expect(windows[0]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + }); +}); diff --git a/packages/schema/inputs/availability.ts b/packages/schema/inputs/availability.ts index 0cb0b34..de688f6 100644 --- a/packages/schema/inputs/availability.ts +++ b/packages/schema/inputs/availability.ts @@ -9,3 +9,25 @@ export const querySchema = z.object({ startAt: z.coerce.date(), endAt: z.coerce.date(), }); + +// ─── Service Availability ──────────────────────────────────────────────────── + +const serviceAvailabilityBase = { + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + serviceId: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service ID" }), + startAt: z.coerce.date(), + endAt: 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 slotsSchema = z.object({ + ...serviceAvailabilityBase, + durationMs: z.number().int().positive(), +}); + +export const windowsSchema = z.object({ + ...serviceAvailabilityBase, +}); diff --git a/packages/schema/outputs/availability.ts b/packages/schema/outputs/availability.ts index 6d48228..adc4611 100644 --- a/packages/schema/outputs/availability.ts +++ b/packages/schema/outputs/availability.ts @@ -14,3 +14,39 @@ export const itemSchema = z.object({ export const querySchema = z.object({ data: z.array(itemSchema), }); + +// ─── Service Availability ──────────────────────────────────────────────────── + +export const slotSchema = z.object({ + startAt: z.string(), + endAt: z.string(), + status: z.enum(["available", "unavailable"]).optional(), +}); + +export const resourceSlotsSchema = z.object({ + resourceId: z.string(), + timezone: z.string(), + slots: z.array(slotSchema), +}); + +export const slotsResponseSchema = z.object({ + data: z.array(resourceSlotsSchema), + meta: z.object({ serverTime: z.string() }), +}); + +export const windowSchema = z.object({ + startAt: z.string(), + endAt: z.string(), + status: z.enum(["available", "unavailable"]).optional(), +}); + +export const resourceWindowsSchema = z.object({ + resourceId: z.string(), + timezone: z.string(), + windows: z.array(windowSchema), +}); + +export const windowsResponseSchema = z.object({ + data: z.array(resourceWindowsSchema), + meta: z.object({ serverTime: z.string() }), +}); From 499202cc7e489704e81e6458af6e0073da1b834b Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 01:16:03 +0300 Subject: [PATCH 14/31] Update docs --- docs/availability.md | 294 ++++++++++++++++++++++++++++++++++++++----- docs/introduction.md | 2 +- docs/quickstart.md | 2 +- docs/services.md | 9 ++ 4 files changed, 271 insertions(+), 36 deletions(-) diff --git a/docs/availability.md b/docs/availability.md index 24d2128..9ce23a8 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -1,8 +1,220 @@ # Availability -Query free/busy timelines for resources before creating bookings or 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 '{ + "startAt": "2026-03-16T00:00:00Z", + "endAt": "2026-03-16T23:59:59Z", + "durationMs": 3600000 + }' +``` + +Response: + +```json +{ + "data": [ + { + "resourceId": "rsc_01abc123def456ghi789jkl012", + "timezone": "America/New_York", + "slots": [ + { "startAt": "2026-03-16T13:00:00.000Z", "endAt": "2026-03-16T14:00:00.000Z" }, + { "startAt": "2026-03-16T13:30:00.000Z", "endAt": "2026-03-16T14:30:00.000Z" }, + { "startAt": "2026-03-16T14:00:00.000Z", "endAt": "2026-03-16T15:00:00.000Z" } + ] + } + ], + "meta": { + "serverTime": "2026-03-16T10:00:00.000Z" + } +} +``` + +### Request fields + +| Field | Type | Required | Description | +| -------------------- | -------- | -------- | ----------------------------------------------------------------------------- | +| `startAt` | string | Yes | Start of query window (ISO 8601) | +| `endAt` | string | Yes | End of query window (ISO 8601). Max 7 days from `startAt`. | +| `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_lead_time_ms`) or too far out (beyond `max_lead_time_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": [ + { "startAt": "...", "endAt": "...", "status": "available" }, + { "startAt": "...", "endAt": "...", "status": "unavailable" }, + { "startAt": "...", "endAt": "...", "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 '{ + "startAt": "2026-03-16T00:00:00Z", + "endAt": "2026-03-20T00:00:00Z" + }' +``` + +Response: + +```json +{ + "data": [ + { + "resourceId": "rsc_01abc123def456ghi789jkl012", + "timezone": "America/New_York", + "windows": [ + { "startAt": "2026-03-16T13:00:00.000Z", "endAt": "2026-03-16T22:00:00.000Z" }, + { "startAt": "2026-03-17T13:00:00.000Z", "endAt": "2026-03-17T16:00:00.000Z" }, + { "startAt": "2026-03-17T18:00:00.000Z", "endAt": "2026-03-17T22:00:00.000Z" } + ] + } + ], + "meta": { + "serverTime": "2026-03-16T10:00:00.000Z" + } +} +``` + +### Request fields + +| Field | Type | Required | Description | +| -------------------- | -------- | -------- | --------------------------------------------------------------------------------------- | +| `startAt` | string | Yes | Start of query window (ISO 8601) | +| `endAt` | string | Yes | End of query window (ISO 8601). Max 31 days from `startAt`. | +| `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 booking window 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": [ + { "startAt": "...", "endAt": "...", "status": "available" }, + { "startAt": "...", "endAt": "...", "status": "unavailable" }, + { "startAt": "...", "endAt": "...", "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 '{ + "startAt": "2026-03-16T00:00:00Z", + "endAt": "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 + +Each resource has an optional `timezone` field. When set, schedule windows (e.g., "09:00–17:00") are interpreted in that timezone, including DST transitions. The resolved `timezone` is included in each resource's response entry. + +If a resource has no timezone set, UTC is used. + +## 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" \ @@ -43,23 +255,9 @@ Response: } ``` -## Multiple resources - -Query multiple resources in a single request: - -```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. +This endpoint doesn't know about services or policies — it only reports raw allocation conflicts. -## What counts as "busy" +### What counts as "busy" A time slot is marked `busy` if it overlaps with an active, non-expired allocation: @@ -73,28 +271,56 @@ These 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 booking 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({ startAt, endAt, 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.startAt}`); + 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({ startAt, endAt }), + }, +).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.endAt) - new Date(w.startAt); + return duration >= minDuration; + }); + if (suitable) { + console.log(`Rent ${resource.resourceId}: ${suitable.startAt} to ${suitable.endAt}`); + break; + } +} ``` diff --git a/docs/introduction.md b/docs/introduction.md index b0c58d4..e45472c 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -43,7 +43,7 @@ See the [Quickstart](./quickstart) for full setup instructions. - [Bookings](./bookings) - The reservation lifecycle - [Allocations](./allocations) - Raw time blocks - [Policies](./policies) - Scheduling rules -- [Availability](./availability) - Query free/busy timelines +- [Availability](./availability) - Slots, windows, and free/busy timelines - [Webhooks](./webhooks) - Real-time notifications - [Idempotency](./idempotency) - Safe retries - [Errors](./errors) - Error handling diff --git a/docs/quickstart.md b/docs/quickstart.md index cf567f2..f5f9d82 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -251,7 +251,7 @@ curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" - [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) - Query free/busy timelines +- [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 diff --git a/docs/services.md b/docs/services.md index a1035e6..b078c9e 100644 --- a/docs/services.md +++ b/docs/services.md @@ -92,6 +92,15 @@ When a booking is created against a service: 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 From 1c39b68c072b033c2039f51cff284cedf4be8159 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 01:26:44 +0300 Subject: [PATCH 15/31] Fix some small issues --- .../src/operations/allocation/create.ts | 1 + apps/server/src/operations/booking/create.ts | 2 ++ apps/server/src/operations/policy/remove.ts | 22 ++++++++++++++----- .../integration/v1/policies/create.spec.ts | 8 +++---- .../test/unit/domain/policy/evaluate.spec.ts | 14 ++++++------ 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/apps/server/src/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts index 0d9f033..48b3171 100644 --- a/apps/server/src/operations/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -15,6 +15,7 @@ export default createOperation({ .selectFrom("resources") .selectAll() .where("id", "=", input.resourceId) + .where("ledgerId", "=", input.ledgerId) .forUpdate() .executeTakeFirst(); diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index e78ce8a..746ebdd 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -19,6 +19,7 @@ export default createOperation({ .selectFrom("resources") .selectAll() .where("id", "=", input.resourceId) + .where("ledgerId", "=", input.ledgerId) .forUpdate() .executeTakeFirst(); @@ -34,6 +35,7 @@ export default createOperation({ .selectFrom("services") .selectAll() .where("id", "=", input.serviceId) + .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); if (!svc) { diff --git a/apps/server/src/operations/policy/remove.ts b/apps/server/src/operations/policy/remove.ts index 8c00731..355f5af 100644 --- a/apps/server/src/operations/policy/remove.ts +++ b/apps/server/src/operations/policy/remove.ts @@ -1,16 +1,26 @@ import { db } from "database"; import { createOperation } from "lib/operation"; import { policy } from "@floyd-run/schema/inputs"; +import { ConflictError } from "lib/errors"; export default createOperation({ input: policy.removeSchema, execute: async (input) => { - const result = await db - .deleteFrom("policies") - .where("id", "=", input.id) - .where("ledgerId", "=", input.ledgerId) - .executeTakeFirst(); + try { + const result = await db + .deleteFrom("policies") + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); - return { deleted: result.numDeletedRows > 0n }; + 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/test/integration/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts index 31c1208..1aa35ec 100644 --- a/apps/server/test/integration/v1/policies/create.spec.ts +++ b/apps/server/test/integration/v1/policies/create.spec.ts @@ -47,12 +47,12 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { 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(); + 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(); + expect(duration["grid"]!["interval_ms"]).toBe(900000); + expect(duration["grid"]!["interval_minutes"]).toBeUndefined(); // weekdays -> expanded day names const rules = config["rules"] as Array<{ diff --git a/apps/server/test/unit/domain/policy/evaluate.spec.ts b/apps/server/test/unit/domain/policy/evaluate.spec.ts index 385378e..d6cda83 100644 --- a/apps/server/test/unit/domain/policy/evaluate.spec.ts +++ b/apps/server/test/unit/domain/policy/evaluate.spec.ts @@ -695,7 +695,7 @@ describe("Step 5: Duration", () => { const result = evaluatePolicy(policy, request, context); expectDenied(result, REASON_CODES.INVALID_DURATION); - expect(result.details?.allowed_ms).toEqual([30 * MINUTE, 60 * MINUTE]); + expect(result.details?.["allowed_ms"]).toEqual([30 * MINUTE, 60 * MINUTE]); }); it("duration below min_ms is rejected", () => { @@ -710,7 +710,7 @@ describe("Step 5: Duration", () => { const result = evaluatePolicy(policy, request, context); expectDenied(result, REASON_CODES.INVALID_DURATION); - expect(result.details?.min_ms).toBe(60 * MINUTE); + expect(result.details?.["min_ms"]).toBe(60 * MINUTE); }); it("duration above max_ms is rejected", () => { @@ -725,7 +725,7 @@ describe("Step 5: Duration", () => { const result = evaluatePolicy(policy, request, context); expectDenied(result, REASON_CODES.INVALID_DURATION); - expect(result.details?.max_ms).toBe(60 * MINUTE); + expect(result.details?.["max_ms"]).toBe(60 * MINUTE); }); it("duration within min/max is allowed", () => { @@ -827,8 +827,8 @@ describe("Step 6: Grid alignment", () => { 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); + expect(result.details?.["interval_ms"]).toBe(30 * MINUTE); + expect(result.details?.["remainder"]).toBe(15 * MINUTE); }); it("15-minute grid: start at :45 is aligned", () => { @@ -886,8 +886,8 @@ describe("Steps 7-8: Lead time + horizon", () => { const result = evaluatePolicy(policy, request, context); expectDenied(result, REASON_CODES.LEAD_TIME_VIOLATION); - expect(result.details?.leadTimeMs).toBe(1 * HOUR); - expect(result.details?.min_lead_time_ms).toBe(2 * HOUR); + expect(result.details?.["leadTimeMs"]).toBe(1 * HOUR); + expect(result.details?.["min_lead_time_ms"]).toBe(2 * HOUR); }); it("booking beyond horizon is rejected", () => { From 82bf9f59d8464721f2e1ebd4e04881b0e34f6888 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 01:36:24 +0300 Subject: [PATCH 16/31] Add policy to bookings --- apps/server/src/database/schema.ts | 1 + ...0260212153727_create-bookings-services-tables.ts | 1 + apps/server/src/operations/booking/create.ts | 1 + apps/server/src/routes/v1/serializers.ts | 1 + docs/errors.md | 13 +++++++++++++ packages/schema/outputs/booking.ts | 1 + 6 files changed, 18 insertions(+) diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index bdc85b2..b28f80f 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -54,6 +54,7 @@ export interface BookingsTable { id: string; ledgerId: string; serviceId: string; + policyId: string | null; status: BookingStatus; expiresAt: Date | null; metadata: Record | null; diff --git a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts index c0e0958..cf49e5e 100644 --- a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts +++ b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts @@ -44,6 +44,7 @@ export async function up(db: Kysely): Promise { .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', 'cancelled', 'expired')`), ) diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 746ebdd..6a3b630 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -114,6 +114,7 @@ export default createOperation({ id: generateId("bkg"), ledgerId: input.ledgerId, serviceId: input.serviceId, + policyId: svc.policyId, status: input.status, expiresAt, metadata: input.metadata ?? null, diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index 4dad78e..1529a09 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -92,6 +92,7 @@ export function serializeBooking(booking: BookingRow, allocations: AllocationRow id: booking.id, ledgerId: booking.ledgerId, serviceId: booking.serviceId, + policyId: booking.policyId, status: booking.status, expiresAt: booking.expiresAt?.toISOString() ?? null, allocations: allocations.map((a) => ({ diff --git a/docs/errors.md b/docs/errors.md index e5f59ec..6623cf1 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -106,6 +106,19 @@ Trying to delete a service that has bookings in `hold` or `confirmed` status. } ``` +### 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. diff --git a/packages/schema/outputs/booking.ts b/packages/schema/outputs/booking.ts index 7dcc049..2b4544d 100644 --- a/packages/schema/outputs/booking.ts +++ b/packages/schema/outputs/booking.ts @@ -15,6 +15,7 @@ export const schema = z.object({ id: z.string(), ledgerId: z.string(), serviceId: z.string(), + policyId: z.string().nullable(), status: z.enum([ BookingStatus.HOLD, BookingStatus.CONFIRMED, From d911634443c84b9103abc3a92676e4dcae9b7a0b Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 01:41:28 +0300 Subject: [PATCH 17/31] Update some variable name --- apps/server/src/operations/availability/slots.ts | 8 ++++---- apps/server/src/operations/availability/windows.ts | 8 ++++---- apps/server/src/operations/booking/create.ts | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/server/src/operations/availability/slots.ts b/apps/server/src/operations/availability/slots.ts index 77599d6..3e0775c 100644 --- a/apps/server/src/operations/availability/slots.ts +++ b/apps/server/src/operations/availability/slots.ts @@ -28,14 +28,14 @@ export default createOperation({ } // 1. Load service - const svc = await db + const service = await db .selectFrom("services") .selectAll() .where("id", "=", serviceId) .where("ledgerId", "=", ledgerId) .executeTakeFirst(); - if (!svc) throw new NotFoundError("Service not found"); + if (!service) throw new NotFoundError("Service not found"); // 2. Load service's resources const serviceResourceRows = await db @@ -82,11 +82,11 @@ export default createOperation({ // 5. Load policy let policy: PolicyConfig | null = null; - if (svc.policyId) { + if (service.policyId) { const policyRow = await db .selectFrom("policies") .selectAll() - .where("id", "=", svc.policyId) + .where("id", "=", service.policyId) .executeTakeFirst(); if (policyRow) { diff --git a/apps/server/src/operations/availability/windows.ts b/apps/server/src/operations/availability/windows.ts index e4968ce..d293d28 100644 --- a/apps/server/src/operations/availability/windows.ts +++ b/apps/server/src/operations/availability/windows.ts @@ -28,14 +28,14 @@ export default createOperation({ } // 1. Load service - const svc = await db + const service = await db .selectFrom("services") .selectAll() .where("id", "=", serviceId) .where("ledgerId", "=", ledgerId) .executeTakeFirst(); - if (!svc) throw new NotFoundError("Service not found"); + if (!service) throw new NotFoundError("Service not found"); // 2. Load service's resources const serviceResourceRows = await db @@ -82,11 +82,11 @@ export default createOperation({ // 5. Load policy let policy: PolicyConfig | null = null; - if (svc.policyId) { + if (service.policyId) { const policyRow = await db .selectFrom("policies") .selectAll() - .where("id", "=", svc.policyId) + .where("id", "=", service.policyId) .executeTakeFirst(); if (policyRow) { diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 6a3b630..9348caf 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -31,14 +31,14 @@ export default createOperation({ const serverTime = await getServerTime(trx); // 3. Load service - const svc = await trx + const service = await trx .selectFrom("services") .selectAll() .where("id", "=", input.serviceId) .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); - if (!svc) { + if (!service) { throw new NotFoundError("Service not found"); } @@ -63,11 +63,11 @@ export default createOperation({ let endAt = input.endAt; let bufferBeforeMs = 0; let bufferAfterMs = 0; - if (svc.policyId) { + if (service.policyId) { const policy = await trx .selectFrom("policies") .selectAll() - .where("id", "=", svc.policyId) + .where("id", "=", service.policyId) .executeTakeFirst(); if (policy) { @@ -114,7 +114,7 @@ export default createOperation({ id: generateId("bkg"), ledgerId: input.ledgerId, serviceId: input.serviceId, - policyId: svc.policyId, + policyId: service.policyId, status: input.status, expiresAt, metadata: input.metadata ?? null, From 03f80407d1e518e2a1766288b177b8c4d6fb9b3c Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 02:03:39 +0300 Subject: [PATCH 18/31] Fix some small bugs --- apps/server/src/domain/policy/canonicalize.ts | 2 +- apps/server/src/domain/policy/evaluate.ts | 2 + .../src/operations/availability/slots.ts | 3 +- .../src/operations/availability/windows.ts | 3 +- apps/server/src/operations/booking/create.ts | 12 ++-- apps/server/src/operations/resource/remove.ts | 25 +++++--- .../integration/v1/allocations/create.spec.ts | 25 ++++++++ .../integration/v1/bookings/create.spec.ts | 37 ++++++++++++ .../integration/v1/resources/create.spec.ts | 20 +++++++ .../integration/v1/resources/delete.spec.ts | 60 +++++++++++++++++++ .../test/unit/domain/policy/evaluate.spec.ts | 50 ++++++++++++++++ docs/errors.md | 13 ++++ packages/schema/inputs/allocation.ts | 21 ++++--- packages/schema/inputs/booking.ts | 23 ++++--- packages/schema/inputs/resource.ts | 17 +++++- 15 files changed, 276 insertions(+), 37 deletions(-) create mode 100644 apps/server/test/integration/v1/resources/delete.spec.ts diff --git a/apps/server/src/domain/policy/canonicalize.ts b/apps/server/src/domain/policy/canonicalize.ts index 44c564c..b6967a6 100644 --- a/apps/server/src/domain/policy/canonicalize.ts +++ b/apps/server/src/domain/policy/canonicalize.ts @@ -38,7 +38,7 @@ function sortAllowedMs(arr: number[]): number[] { return [...new Set(arr)].sort((a, b) => a - b); } -function canonicalizeValue(key: string, value: unknown, parentKey?: string): unknown { +function canonicalizeValue(key: string, value: unknown): unknown { if (value === null || value === undefined) return value; if (Array.isArray(value)) { diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index da988dc..ecc7275 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -59,6 +59,7 @@ export interface ResolvedConfig { grid?: GridConfig | undefined; booking_window?: BookingWindowConfig | undefined; buffers?: BuffersConfig | undefined; + hold_duration_ms?: number | undefined; } interface TimeWindow { @@ -361,6 +362,7 @@ export function evaluatePolicy( grid: resolvedRaw["grid"] as GridConfig | undefined, booking_window: resolvedRaw["booking_window"] as BookingWindowConfig | undefined, buffers: resolvedRaw["buffers"] as BuffersConfig | undefined, + hold_duration_ms: resolvedRaw["hold_duration_ms"] as number | undefined, }; // ─── Step 5: Duration check ──────────────────────────────────────────── diff --git a/apps/server/src/operations/availability/slots.ts b/apps/server/src/operations/availability/slots.ts index 3e0775c..7729752 100644 --- a/apps/server/src/operations/availability/slots.ts +++ b/apps/server/src/operations/availability/slots.ts @@ -65,7 +65,8 @@ export default createOperation({ } if (targetResourceIds.length === 0) { - return { data: [], serverTime: new Date() }; + const serverTime = await getServerTime(db); + return { data: [], serverTime }; } // 3. Load resources (for timezone) diff --git a/apps/server/src/operations/availability/windows.ts b/apps/server/src/operations/availability/windows.ts index d293d28..5b2ed48 100644 --- a/apps/server/src/operations/availability/windows.ts +++ b/apps/server/src/operations/availability/windows.ts @@ -65,7 +65,8 @@ export default createOperation({ } if (targetResourceIds.length === 0) { - return { data: [], serverTime: new Date() }; + const serverTime = await getServerTime(db); + return { data: [], serverTime }; } // 3. Load resources (for timezone) diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 9348caf..18a18b7 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -92,13 +92,9 @@ export default createOperation({ bufferBeforeMs = result.bufferBeforeMs; bufferAfterMs = result.bufferAfterMs; - // Use policy hold_duration if configured - const configRecord = policy.config as Record; - if ( - configRecord["hold_duration_ms"] && - typeof configRecord["hold_duration_ms"] === "number" - ) { - holdDurationMs = configRecord["hold_duration_ms"]; + // Use resolved hold_duration (respects per-rule overrides) + if (result.resolvedConfig.hold_duration_ms !== undefined) { + holdDurationMs = result.resolvedConfig.hold_duration_ms; } } } @@ -136,7 +132,7 @@ export default createOperation({ serverTime, }); - // 10. Enqueue webhook + // 9. Enqueue webhook await enqueueWebhookEvent(trx, "booking.created", input.ledgerId, { booking: serializeBooking(bkg, [alloc]), }); diff --git a/apps/server/src/operations/resource/remove.ts b/apps/server/src/operations/resource/remove.ts index 501f0aa..c1fefc9 100644 --- a/apps/server/src/operations/resource/remove.ts +++ b/apps/server/src/operations/resource/remove.ts @@ -1,19 +1,28 @@ import { resource } from "@floyd-run/schema/inputs"; import { db } from "database"; import { createOperation } from "lib/operation"; -import { NotFoundError } from "lib/errors"; +import { ConflictError, NotFoundError } from "lib/errors"; export default createOperation({ input: resource.removeSchema, execute: async (input) => { - const result = await db - .deleteFrom("resources") - .where("id", "=", input.id) - .where("ledgerId", "=", input.ledgerId) - .executeTakeFirst(); + 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"); + 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/test/integration/v1/allocations/create.spec.ts b/apps/server/test/integration/v1/allocations/create.spec.ts index 18a79ac..31d1339 100644 --- a/apps/server/test/integration/v1/allocations/create.spec.ts +++ b/apps/server/test/integration/v1/allocations/create.spec.ts @@ -70,6 +70,31 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { expect(response.status).toBe(422); }); + it("returns 422 when endAt equals startAt", 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, + startAt: time, + endAt: time, + }); + + expect(response.status).toBe(422); + }); + + it("returns 422 when endAt is before startAt", async () => { + const { resource, ledgerId } = await createResource(); + + const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { + resourceId: resource.id, + startAt: new Date("2026-02-01T11:00:00Z").toISOString(), + endAt: new Date("2026-02-01T10:00:00Z").toISOString(), + }); + + expect(response.status).toBe(422); + }); + it("returns 404 for non-existent resource", async () => { const { ledgerId } = await createResource(); diff --git a/apps/server/test/integration/v1/bookings/create.spec.ts b/apps/server/test/integration/v1/bookings/create.spec.ts index 23d4797..e02f711 100644 --- a/apps/server/test/integration/v1/bookings/create.spec.ts +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -101,6 +101,43 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(response.status).toBe(422); }); + it("returns 422 when endAt equals startAt", 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, + startAt: time, + endAt: time, + }); + + expect(response.status).toBe(422); + }); + + it("returns 422 when endAt is before startAt", 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, + startAt: "2026-06-01T11:00:00.000Z", + endAt: "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 }); diff --git a/apps/server/test/integration/v1/resources/create.spec.ts b/apps/server/test/integration/v1/resources/create.spec.ts index 00f1091..4d31882 100644 --- a/apps/server/test/integration/v1/resources/create.spec.ts +++ b/apps/server/test/integration/v1/resources/create.spec.ts @@ -15,4 +15,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..2369876 --- /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, + startAt: new Date("2026-06-01T10:00:00Z"), + endAt: 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/unit/domain/policy/evaluate.spec.ts b/apps/server/test/unit/domain/policy/evaluate.spec.ts index d6cda83..e16b9bf 100644 --- a/apps/server/test/unit/domain/policy/evaluate.spec.ts +++ b/apps/server/test/unit/domain/policy/evaluate.spec.ts @@ -1670,4 +1670,54 @@ describe("Edge cases", () => { const result = evaluatePolicy(policy, request, context); expectAllowed(result); }); + + // ─── hold_duration_ms resolution ───────────────────────────────────────── + + it("resolvedConfig includes hold_duration_ms 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_duration_ms).toBe(5 * MINUTE); + }); + + it("resolvedConfig hold_duration_ms 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_duration_ms).toBe(10 * MINUTE); + }); + + it("resolvedConfig hold_duration_ms 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_duration_ms).toBeUndefined(); + }); }); diff --git a/docs/errors.md b/docs/errors.md index 6623cf1..f3380ed 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -106,6 +106,19 @@ Trying to delete a service that has bookings in `hold` or `confirmed` status. } ``` +### Resource in use + +Trying to delete a resource that has allocations or service associations. + +```json +{ + "error": { + "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. diff --git a/packages/schema/inputs/allocation.ts b/packages/schema/inputs/allocation.ts index c810b10..e152620 100644 --- a/packages/schema/inputs/allocation.ts +++ b/packages/schema/inputs/allocation.ts @@ -1,14 +1,19 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -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" }), - 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 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" }), + startAt: z.coerce.date(), + endAt: z.coerce.date(), + expiresAt: z.coerce.date().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .refine((data) => data.endAt > data.startAt, { + message: "endAt must be after startAt", + path: ["endAt"], + }); export const getSchema = z.object({ id: z.string().refine((id) => isValidId(id, "alc"), { message: "Invalid allocation ID" }), diff --git a/packages/schema/inputs/booking.ts b/packages/schema/inputs/booking.ts index bde97a6..d7660ea 100644 --- a/packages/schema/inputs/booking.ts +++ b/packages/schema/inputs/booking.ts @@ -2,15 +2,20 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; import { BookingStatus } from "../constants"; -export const createSchema = 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" }), - startAt: z.coerce.date(), - endAt: z.coerce.date(), - status: z.enum([BookingStatus.HOLD, BookingStatus.CONFIRMED]).default(BookingStatus.HOLD), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), -}); +export const createSchema = 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" }), + startAt: z.coerce.date(), + endAt: 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.endAt > data.startAt, { + message: "endAt must be after startAt", + path: ["endAt"], + }); export const getSchema = z.object({ id: z.string().refine((id) => isValidId(id, "bkg"), { message: "Invalid booking ID" }), diff --git a/packages/schema/inputs/resource.ts b/packages/schema/inputs/resource.ts index d3f07b1..ff69b02 100644 --- a/packages/schema/inputs/resource.ts +++ b/packages/schema/inputs/resource.ts @@ -3,7 +3,22 @@ import { isValidId } from "@floyd-run/utils"; export const createSchema = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - timezone: z.string().max(64).nullable().optional(), + timezone: z + .string() + .max(64) + .refine( + (tz) => { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } + }, + { message: "Invalid IANA timezone" }, + ) + .nullable() + .optional(), }); export const getSchema = z.object({ From c655a4fddc14dd3f71119bc4b7e207d5d35296fb Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 02:25:56 +0300 Subject: [PATCH 19/31] Rename some variables --- .../src/domain/scheduling/availability.ts | 4 ++-- .../src/operations/allocation/create.ts | 10 ++++----- apps/server/src/operations/allocation/get.ts | 4 ++-- .../operations/allocation/internal/insert.ts | 4 ++-- apps/server/src/operations/allocation/list.ts | 4 ++-- .../src/operations/allocation/remove.ts | 4 ++-- .../src/operations/availability/query.ts | 4 ++-- .../src/operations/availability/slots.ts | 4 ++-- .../src/operations/availability/windows.ts | 4 ++-- apps/server/src/operations/booking/cancel.ts | 12 +++++----- apps/server/src/operations/booking/confirm.ts | 12 +++++----- apps/server/src/operations/booking/create.ts | 14 ++++++------ apps/server/src/operations/booking/get.ts | 12 +++++----- apps/server/src/operations/booking/list.ts | 22 +++++++++---------- apps/server/src/operations/ledger/get.ts | 4 ++-- apps/server/src/operations/policy/create.ts | 4 ++-- apps/server/src/operations/policy/get.ts | 4 ++-- apps/server/src/operations/policy/list.ts | 4 ++-- apps/server/src/operations/policy/remove.ts | 4 ++-- apps/server/src/operations/policy/update.ts | 4 ++-- apps/server/src/operations/resource/create.ts | 4 ++-- apps/server/src/operations/resource/get.ts | 4 ++-- apps/server/src/operations/resource/list.ts | 4 ++-- apps/server/src/operations/resource/remove.ts | 4 ++-- apps/server/src/operations/service/create.ts | 10 ++++----- apps/server/src/operations/service/get.ts | 12 +++++----- apps/server/src/operations/service/list.ts | 20 ++++++++--------- apps/server/src/operations/service/remove.ts | 4 ++-- apps/server/src/operations/service/update.ts | 8 +++---- apps/server/src/operations/webhook/create.ts | 4 ++-- apps/server/src/operations/webhook/list.ts | 4 ++-- apps/server/src/operations/webhook/remove.ts | 4 ++-- .../src/operations/webhook/rotate-secret.ts | 4 ++-- apps/server/src/operations/webhook/update.ts | 4 ++-- apps/server/src/workers/expiration-worker.ts | 12 +++++----- .../setup/factories/booking.factory.ts | 4 ++-- .../integration/v1/bookings/create.spec.ts | 20 ++++++++--------- packages/schema/inputs/allocation.ts | 8 +++---- packages/schema/inputs/availability.ts | 6 ++--- packages/schema/inputs/booking.ts | 10 ++++----- packages/schema/inputs/index.ts | 16 +++++++------- packages/schema/inputs/ledger.ts | 2 +- packages/schema/inputs/policy.ts | 10 ++++----- packages/schema/inputs/resource.ts | 8 +++---- packages/schema/inputs/service.ts | 10 ++++----- packages/schema/inputs/webhook.ts | 10 ++++----- 46 files changed, 172 insertions(+), 172 deletions(-) diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts index c4e9826..72f7323 100644 --- a/apps/server/src/domain/scheduling/availability.ts +++ b/apps/server/src/domain/scheduling/availability.ts @@ -265,8 +265,8 @@ function overlapsAny( end: number, allocations: { start: number; end: number }[], ): boolean { - for (const alloc of allocations) { - if (start < alloc.end && end > alloc.start) return true; + for (const allocation of allocations) { + if (start < allocation.end && end > allocation.start) return true; } return false; } diff --git a/apps/server/src/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts index 48b3171..519dc25 100644 --- a/apps/server/src/operations/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -1,13 +1,13 @@ import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; -import { allocation } from "@floyd-run/schema/inputs"; +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: allocation.createSchema, + input: allocationInput.create, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Lock the resource row (FOR UPDATE) - serializes concurrent writes @@ -27,7 +27,7 @@ export default createOperation({ const serverTime = await getServerTime(trx); // 3. Check conflicts + insert allocation - const alloc = await insertAllocation(trx, { + const allocation = await insertAllocation(trx, { ledgerId: input.ledgerId, resourceId: input.resourceId, bookingId: null, @@ -42,10 +42,10 @@ export default createOperation({ // 4. Enqueue webhook event (in same transaction) await enqueueWebhookEvent(trx, "allocation.created", input.ledgerId, { - allocation: serializeAllocation(alloc), + allocation: serializeAllocation(allocation), }); - return { allocation: alloc, serverTime }; + return { allocation, serverTime }; }); }, }); diff --git a/apps/server/src/operations/allocation/get.ts b/apps/server/src/operations/allocation/get.ts index 47fea72..34e7d4f 100644 --- a/apps/server/src/operations/allocation/get.ts +++ b/apps/server/src/operations/allocation/get.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { allocation } from "@floyd-run/schema/inputs"; +import { allocationInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: allocation.getSchema, + input: allocationInput.get, execute: async (input) => { const allocation = await db .selectFrom("allocations") diff --git a/apps/server/src/operations/allocation/internal/insert.ts b/apps/server/src/operations/allocation/internal/insert.ts index 03a1d3d..7ea25e7 100644 --- a/apps/server/src/operations/allocation/internal/insert.ts +++ b/apps/server/src/operations/allocation/internal/insert.ts @@ -38,7 +38,7 @@ export async function insertAllocation( } // 2. Insert the allocation - const alloc = await trx + const allocation = await trx .insertInto("allocations") .values({ id: generateId("alc"), @@ -56,5 +56,5 @@ export async function insertAllocation( .returningAll() .executeTakeFirstOrThrow(); - return alloc; + return allocation; } diff --git a/apps/server/src/operations/allocation/list.ts b/apps/server/src/operations/allocation/list.ts index 0f49528..b7920d7 100644 --- a/apps/server/src/operations/allocation/list.ts +++ b/apps/server/src/operations/allocation/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { allocation } from "@floyd-run/schema/inputs"; +import { allocationInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: allocation.listSchema, + 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 index f5caf11..a59a166 100644 --- a/apps/server/src/operations/allocation/remove.ts +++ b/apps/server/src/operations/allocation/remove.ts @@ -1,12 +1,12 @@ import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; -import { allocation } from "@floyd-run/schema/inputs"; +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: allocation.removeSchema, + input: allocationInput.remove, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Lock the allocation row diff --git a/apps/server/src/operations/availability/query.ts b/apps/server/src/operations/availability/query.ts index 1f734a7..cb1943e 100644 --- a/apps/server/src/operations/availability/query.ts +++ b/apps/server/src/operations/availability/query.ts @@ -1,7 +1,7 @@ import { sql } from "kysely"; import { db } from "database"; import { createOperation } from "lib/operation"; -import { availability } from "@floyd-run/schema/inputs"; +import { availabilityInput } from "@floyd-run/schema/inputs"; import type { AvailabilityItem } from "@floyd-run/schema/types"; import { clampInterval, mergeIntervals, buildTimeline } from "domain/scheduling/timeline"; @@ -12,7 +12,7 @@ interface BlockingAllocation { } export default createOperation({ - input: availability.querySchema, + input: availabilityInput.query, execute: async (input): Promise<{ items: AvailabilityItem[] }> => { const { ledgerId, resourceIds, startAt, endAt } = input; diff --git a/apps/server/src/operations/availability/slots.ts b/apps/server/src/operations/availability/slots.ts index 7729752..064a8af 100644 --- a/apps/server/src/operations/availability/slots.ts +++ b/apps/server/src/operations/availability/slots.ts @@ -1,7 +1,7 @@ import { sql } from "kysely"; import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; -import { availability } from "@floyd-run/schema/inputs"; +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"; @@ -10,7 +10,7 @@ import type { BlockingAllocation } from "domain/scheduling/availability"; const MAX_SLOTS_RANGE_MS = 7 * 24 * 60 * 60_000; // 7 days export default createOperation({ - input: availability.slotsSchema, + input: availabilityInput.slots, execute: async (input) => { const { ledgerId, serviceId, startAt, endAt, durationMs, includeUnavailable } = input; diff --git a/apps/server/src/operations/availability/windows.ts b/apps/server/src/operations/availability/windows.ts index 5b2ed48..d50d5c3 100644 --- a/apps/server/src/operations/availability/windows.ts +++ b/apps/server/src/operations/availability/windows.ts @@ -1,7 +1,7 @@ import { sql } from "kysely"; import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; -import { availability } from "@floyd-run/schema/inputs"; +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"; @@ -10,7 +10,7 @@ import type { BlockingAllocation } from "domain/scheduling/availability"; const MAX_WINDOWS_RANGE_MS = 31 * 24 * 60 * 60_000; // 31 days export default createOperation({ - input: availability.windowsSchema, + input: availabilityInput.windows, execute: async (input) => { const { ledgerId, serviceId, startAt, endAt, includeUnavailable } = input; diff --git a/apps/server/src/operations/booking/cancel.ts b/apps/server/src/operations/booking/cancel.ts index 2403db2..4b8d798 100644 --- a/apps/server/src/operations/booking/cancel.ts +++ b/apps/server/src/operations/booking/cancel.ts @@ -1,12 +1,12 @@ import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; -import { booking } from "@floyd-run/schema/inputs"; +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: booking.cancelSchema, + input: bookingInput.cancel, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Lock booking row @@ -44,7 +44,7 @@ export default createOperation({ } // 4. Update booking - const bkg = await trx + const booking = await trx .updateTable("bookings") .set({ status: "cancelled", @@ -69,11 +69,11 @@ export default createOperation({ .execute(); // 6. Enqueue webhook - await enqueueWebhookEvent(trx, "booking.cancelled", bkg.ledgerId, { - booking: serializeBooking(bkg, allocations), + await enqueueWebhookEvent(trx, "booking.cancelled", booking.ledgerId, { + booking: serializeBooking(booking, allocations), }); - return { booking: bkg, allocations, serverTime }; + return { booking, allocations, serverTime }; }); }, }); diff --git a/apps/server/src/operations/booking/confirm.ts b/apps/server/src/operations/booking/confirm.ts index 690ca8e..c75f511 100644 --- a/apps/server/src/operations/booking/confirm.ts +++ b/apps/server/src/operations/booking/confirm.ts @@ -1,12 +1,12 @@ import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; -import { booking } from "@floyd-run/schema/inputs"; +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: booking.confirmSchema, + input: bookingInput.confirm, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Lock booking row @@ -52,7 +52,7 @@ export default createOperation({ } // 5. Update booking - const bkg = await trx + const booking = await trx .updateTable("bookings") .set({ status: "confirmed", @@ -77,11 +77,11 @@ export default createOperation({ .execute(); // 7. Enqueue webhook - await enqueueWebhookEvent(trx, "booking.confirmed", bkg.ledgerId, { - booking: serializeBooking(bkg, allocations), + await enqueueWebhookEvent(trx, "booking.confirmed", booking.ledgerId, { + booking: serializeBooking(booking, allocations), }); - return { booking: bkg, allocations, serverTime }; + return { booking, allocations, serverTime }; }); }, }); diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 18a18b7..de400b4 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -1,7 +1,7 @@ import { db, getServerTime } from "database"; import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; -import { booking } from "@floyd-run/schema/inputs"; +import { bookingInput } from "@floyd-run/schema/inputs"; import { ConflictError, NotFoundError } from "lib/errors"; import { enqueueWebhookEvent } from "infra/webhooks"; import { serializeBooking } from "routes/v1/serializers"; @@ -11,7 +11,7 @@ import { insertAllocation } from "../allocation/internal/insert"; const DEFAULT_HOLD_DURATION_MS = 15 * 60 * 1000; // 15 minutes export default createOperation({ - input: booking.createSchema, + input: bookingInput.create, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Lock resource row (serializes concurrent writes) @@ -104,7 +104,7 @@ export default createOperation({ const expiresAt = isHold ? new Date(serverTime.getTime() + holdDurationMs) : null; // 7. Insert booking - const bkg = await trx + const booking = await trx .insertInto("bookings") .values({ id: generateId("bkg"), @@ -119,10 +119,10 @@ export default createOperation({ .executeTakeFirstOrThrow(); // 8. Conflict check + insert allocation (startAt/endAt = blocked window including buffers) - const alloc = await insertAllocation(trx, { + const allocation = await insertAllocation(trx, { ledgerId: input.ledgerId, resourceId: input.resourceId, - bookingId: bkg.id, + bookingId: booking.id, startAt, endAt, bufferBeforeMs, @@ -134,10 +134,10 @@ export default createOperation({ // 9. Enqueue webhook await enqueueWebhookEvent(trx, "booking.created", input.ledgerId, { - booking: serializeBooking(bkg, [alloc]), + booking: serializeBooking(booking, [allocation]), }); - return { booking: bkg, allocations: [alloc], serverTime }; + return { booking, allocations: [allocation], serverTime }; }); }, }); diff --git a/apps/server/src/operations/booking/get.ts b/apps/server/src/operations/booking/get.ts index 78e7d2a..f703c0a 100644 --- a/apps/server/src/operations/booking/get.ts +++ b/apps/server/src/operations/booking/get.ts @@ -1,27 +1,27 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { booking } from "@floyd-run/schema/inputs"; +import { bookingInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: booking.getSchema, + input: bookingInput.get, execute: async (input) => { - const bkg = await db + const booking = await db .selectFrom("bookings") .selectAll() .where("id", "=", input.id) .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); - if (!bkg) { + if (!booking) { return { booking: null, allocations: [] }; } const allocations = await db .selectFrom("allocations") .selectAll() - .where("bookingId", "=", bkg.id) + .where("bookingId", "=", booking.id) .execute(); - return { booking: bkg, allocations }; + return { booking, allocations }; }, }); diff --git a/apps/server/src/operations/booking/list.ts b/apps/server/src/operations/booking/list.ts index bd372e7..1b0ee96 100644 --- a/apps/server/src/operations/booking/list.ts +++ b/apps/server/src/operations/booking/list.ts @@ -1,10 +1,10 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { booking } from "@floyd-run/schema/inputs"; +import { bookingInput } from "@floyd-run/schema/inputs"; import type { AllocationRow } from "database/schema"; export default createOperation({ - input: booking.listSchema, + input: bookingInput.list, execute: async (input) => { const bookings = await db .selectFrom("bookings") @@ -14,8 +14,8 @@ export default createOperation({ // Batch load allocations for all bookings const allocationsByBooking = new Map(); - for (const bkg of bookings) { - allocationsByBooking.set(bkg.id, []); + for (const booking of bookings) { + allocationsByBooking.set(booking.id, []); } if (bookings.length > 0) { @@ -29,20 +29,20 @@ export default createOperation({ ) .execute(); - for (const alloc of allocations) { - if (alloc.bookingId) { - const list = allocationsByBooking.get(alloc.bookingId); + for (const allocation of allocations) { + if (allocation.bookingId) { + const list = allocationsByBooking.get(allocation.bookingId); if (list) { - list.push(alloc); + list.push(allocation); } } } } return { - bookings: bookings.map((bkg) => ({ - booking: bkg, - allocations: allocationsByBooking.get(bkg.id) || [], + bookings: bookings.map((booking) => ({ + booking, + allocations: allocationsByBooking.get(booking.id) || [], })), }; }, diff --git a/apps/server/src/operations/ledger/get.ts b/apps/server/src/operations/ledger/get.ts index 9f826b7..ce5443c 100644 --- a/apps/server/src/operations/ledger/get.ts +++ b/apps/server/src/operations/ledger/get.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { ledger } from "@floyd-run/schema/inputs"; +import { ledgerInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: ledger.getSchema, + input: ledgerInput.get, execute: async (input) => { const ledger = await db .selectFrom("ledgers") diff --git a/apps/server/src/operations/policy/create.ts b/apps/server/src/operations/policy/create.ts index 2e24a81..cc92428 100644 --- a/apps/server/src/operations/policy/create.ts +++ b/apps/server/src/operations/policy/create.ts @@ -1,11 +1,11 @@ import { db } from "database"; import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; -import { policy } from "@floyd-run/schema/inputs"; +import { policyInput } from "@floyd-run/schema/inputs"; import { preparePolicyConfig } from "domain/policy"; export default createOperation({ - input: policy.createSchema, + input: policyInput.create, execute: async (input) => { const { normalized, configHash, warnings } = preparePolicyConfig( input.config as unknown as Record, diff --git a/apps/server/src/operations/policy/get.ts b/apps/server/src/operations/policy/get.ts index 80e03e6..7c6563f 100644 --- a/apps/server/src/operations/policy/get.ts +++ b/apps/server/src/operations/policy/get.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { policy } from "@floyd-run/schema/inputs"; +import { policyInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: policy.getSchema, + input: policyInput.get, execute: async (input) => { const row = await db .selectFrom("policies") diff --git a/apps/server/src/operations/policy/list.ts b/apps/server/src/operations/policy/list.ts index 0c67a28..b2524fb 100644 --- a/apps/server/src/operations/policy/list.ts +++ b/apps/server/src/operations/policy/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { policy } from "@floyd-run/schema/inputs"; +import { policyInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: policy.listSchema, + input: policyInput.list, execute: async (input) => { const policies = await db .selectFrom("policies") diff --git a/apps/server/src/operations/policy/remove.ts b/apps/server/src/operations/policy/remove.ts index 355f5af..74af62a 100644 --- a/apps/server/src/operations/policy/remove.ts +++ b/apps/server/src/operations/policy/remove.ts @@ -1,10 +1,10 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { policy } from "@floyd-run/schema/inputs"; +import { policyInput } from "@floyd-run/schema/inputs"; import { ConflictError } from "lib/errors"; export default createOperation({ - input: policy.removeSchema, + input: policyInput.remove, execute: async (input) => { try { const result = await db diff --git a/apps/server/src/operations/policy/update.ts b/apps/server/src/operations/policy/update.ts index bd9c8b9..bb08f56 100644 --- a/apps/server/src/operations/policy/update.ts +++ b/apps/server/src/operations/policy/update.ts @@ -1,11 +1,11 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { policy } from "@floyd-run/schema/inputs"; +import { policyInput } from "@floyd-run/schema/inputs"; import { NotFoundError } from "lib/errors"; import { preparePolicyConfig } from "domain/policy"; export default createOperation({ - input: policy.updateSchema, + input: policyInput.update, execute: async (input) => { // 1. Verify policy exists const existing = await db diff --git a/apps/server/src/operations/resource/create.ts b/apps/server/src/operations/resource/create.ts index a5b82ab..82ccf03 100644 --- a/apps/server/src/operations/resource/create.ts +++ b/apps/server/src/operations/resource/create.ts @@ -1,10 +1,10 @@ import { db } from "database"; 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 createOperation({ - input: resource.createSchema, + input: resourceInput.create, execute: async (input) => { const resource = await db .insertInto("resources") diff --git a/apps/server/src/operations/resource/get.ts b/apps/server/src/operations/resource/get.ts index 226a0f9..459c359 100644 --- a/apps/server/src/operations/resource/get.ts +++ b/apps/server/src/operations/resource/get.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { resource } from "@floyd-run/schema/inputs"; +import { resourceInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: resource.getSchema, + input: resourceInput.get, execute: async (input) => { const resource = await db .selectFrom("resources") diff --git a/apps/server/src/operations/resource/list.ts b/apps/server/src/operations/resource/list.ts index 7fff786..2ac1571 100644 --- a/apps/server/src/operations/resource/list.ts +++ b/apps/server/src/operations/resource/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { resource } from "@floyd-run/schema/inputs"; +import { resourceInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: resource.listSchema, + 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 index c1fefc9..2557191 100644 --- a/apps/server/src/operations/resource/remove.ts +++ b/apps/server/src/operations/resource/remove.ts @@ -1,10 +1,10 @@ -import { resource } from "@floyd-run/schema/inputs"; +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: resource.removeSchema, + input: resourceInput.remove, execute: async (input) => { try { const result = await db diff --git a/apps/server/src/operations/service/create.ts b/apps/server/src/operations/service/create.ts index 6b2588b..61e53db 100644 --- a/apps/server/src/operations/service/create.ts +++ b/apps/server/src/operations/service/create.ts @@ -1,11 +1,11 @@ import { db } from "database"; import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; -import { service } from "@floyd-run/schema/inputs"; +import { serviceInput } from "@floyd-run/schema/inputs"; import { NotFoundError } from "lib/errors"; export default createOperation({ - input: service.createSchema, + input: serviceInput.create, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Validate policyId exists and belongs to ledger (if provided) @@ -39,7 +39,7 @@ export default createOperation({ } // 3. Insert service - const svc = await trx + const service = await trx .insertInto("services") .values({ id: generateId("svc"), @@ -55,11 +55,11 @@ export default createOperation({ if (input.resourceIds.length > 0) { await trx .insertInto("serviceResources") - .values(input.resourceIds.map((resourceId) => ({ serviceId: svc.id, resourceId }))) + .values(input.resourceIds.map((resourceId) => ({ serviceId: service.id, resourceId }))) .execute(); } - return { service: svc, resourceIds: input.resourceIds }; + return { service, resourceIds: input.resourceIds }; }); }, }); diff --git a/apps/server/src/operations/service/get.ts b/apps/server/src/operations/service/get.ts index 525dfb7..7e6a7a8 100644 --- a/apps/server/src/operations/service/get.ts +++ b/apps/server/src/operations/service/get.ts @@ -1,27 +1,27 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { service } from "@floyd-run/schema/inputs"; +import { serviceInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: service.getSchema, + input: serviceInput.get, execute: async (input) => { - const svc = await db + const service = await db .selectFrom("services") .selectAll() .where("id", "=", input.id) .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); - if (!svc) { + if (!service) { return { service: null, resourceIds: [] }; } const resources = await db .selectFrom("serviceResources") .select("resourceId") - .where("serviceId", "=", svc.id) + .where("serviceId", "=", service.id) .execute(); - return { service: svc, resourceIds: resources.map((r) => r.resourceId) }; + return { service, resourceIds: resources.map((r) => r.resourceId) }; }, }); diff --git a/apps/server/src/operations/service/list.ts b/apps/server/src/operations/service/list.ts index cab2645..8edcf76 100644 --- a/apps/server/src/operations/service/list.ts +++ b/apps/server/src/operations/service/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { service } from "@floyd-run/schema/inputs"; +import { serviceInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: service.listSchema, + input: serviceInput.list, execute: async (input) => { const services = await db .selectFrom("services") @@ -13,8 +13,8 @@ export default createOperation({ // Batch load resourceIds for all services const resourceIdsByService = new Map(); - for (const svc of services) { - resourceIdsByService.set(svc.id, []); + for (const s of services) { + resourceIdsByService.set(s.id, []); } if (services.length > 0) { @@ -28,18 +28,18 @@ export default createOperation({ ) .execute(); - for (const sr of serviceResources) { - const list = resourceIdsByService.get(sr.serviceId); + for (const serviceResource of serviceResources) { + const list = resourceIdsByService.get(serviceResource.serviceId); if (list) { - list.push(sr.resourceId); + list.push(serviceResource.resourceId); } } } return { - services: services.map((svc) => ({ - service: svc, - resourceIds: resourceIdsByService.get(svc.id) || [], + 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 index 305913d..5233aad 100644 --- a/apps/server/src/operations/service/remove.ts +++ b/apps/server/src/operations/service/remove.ts @@ -1,10 +1,10 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { service } from "@floyd-run/schema/inputs"; +import { serviceInput } from "@floyd-run/schema/inputs"; import { ConflictError, NotFoundError } from "lib/errors"; export default createOperation({ - input: service.removeSchema, + input: serviceInput.remove, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Verify service exists diff --git a/apps/server/src/operations/service/update.ts b/apps/server/src/operations/service/update.ts index 448efad..789fb90 100644 --- a/apps/server/src/operations/service/update.ts +++ b/apps/server/src/operations/service/update.ts @@ -1,10 +1,10 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { service } from "@floyd-run/schema/inputs"; +import { serviceInput } from "@floyd-run/schema/inputs"; import { NotFoundError } from "lib/errors"; export default createOperation({ - input: service.updateSchema, + input: serviceInput.update, execute: async (input) => { return await db.transaction().execute(async (trx) => { // 1. Lock service row @@ -51,7 +51,7 @@ export default createOperation({ } // 4. Update service row - const svc = await trx + const service = await trx .updateTable("services") .set({ name: input.name, @@ -72,7 +72,7 @@ export default createOperation({ .execute(); } - return { service: svc, resourceIds: input.resourceIds }; + return { service, resourceIds: input.resourceIds }; }); }, }); diff --git a/apps/server/src/operations/webhook/create.ts b/apps/server/src/operations/webhook/create.ts index 6643e97..8dd9c6b 100644 --- a/apps/server/src/operations/webhook/create.ts +++ b/apps/server/src/operations/webhook/create.ts @@ -1,11 +1,11 @@ import { db } from "database"; import { createOperation } from "lib/operation"; import { generateId } from "@floyd-run/utils"; -import { webhook } from "@floyd-run/schema/inputs"; +import { webhookInput } from "@floyd-run/schema/inputs"; import { generateSecret } from "./generate-secret"; export default createOperation({ - input: webhook.createSubscriptionSchema, + input: webhookInput.createSubscription, execute: async (input) => { const subscription = await db .insertInto("webhookSubscriptions") diff --git a/apps/server/src/operations/webhook/list.ts b/apps/server/src/operations/webhook/list.ts index 91faa16..233ed2d 100644 --- a/apps/server/src/operations/webhook/list.ts +++ b/apps/server/src/operations/webhook/list.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { webhook } from "@floyd-run/schema/inputs"; +import { webhookInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: webhook.listSubscriptionsSchema, + input: webhookInput.listSubscriptions, execute: async (input) => { const subscriptions = await db .selectFrom("webhookSubscriptions") diff --git a/apps/server/src/operations/webhook/remove.ts b/apps/server/src/operations/webhook/remove.ts index a7438ba..491f054 100644 --- a/apps/server/src/operations/webhook/remove.ts +++ b/apps/server/src/operations/webhook/remove.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { webhook } from "@floyd-run/schema/inputs"; +import { webhookInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: webhook.deleteSubscriptionSchema, + input: webhookInput.deleteSubscription, execute: async (input) => { const result = await db .deleteFrom("webhookSubscriptions") diff --git a/apps/server/src/operations/webhook/rotate-secret.ts b/apps/server/src/operations/webhook/rotate-secret.ts index 15996dc..28b2300 100644 --- a/apps/server/src/operations/webhook/rotate-secret.ts +++ b/apps/server/src/operations/webhook/rotate-secret.ts @@ -1,10 +1,10 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { webhook } from "@floyd-run/schema/inputs"; +import { webhookInput } from "@floyd-run/schema/inputs"; import { generateSecret } from "./generate-secret"; export default createOperation({ - input: webhook.rotateSecretSchema, + input: webhookInput.rotateSecret, execute: async (input) => { const newSecret = generateSecret(); diff --git a/apps/server/src/operations/webhook/update.ts b/apps/server/src/operations/webhook/update.ts index ea6148c..932e45b 100644 --- a/apps/server/src/operations/webhook/update.ts +++ b/apps/server/src/operations/webhook/update.ts @@ -1,9 +1,9 @@ import { db } from "database"; import { createOperation } from "lib/operation"; -import { webhook } from "@floyd-run/schema/inputs"; +import { webhookInput } from "@floyd-run/schema/inputs"; export default createOperation({ - input: webhook.updateSubscriptionSchema, + input: webhookInput.updateSubscription, execute: async (input) => { const subscription = await db .updateTable("webhookSubscriptions") diff --git a/apps/server/src/workers/expiration-worker.ts b/apps/server/src/workers/expiration-worker.ts index 17aff5b..2d0a9bf 100644 --- a/apps/server/src/workers/expiration-worker.ts +++ b/apps/server/src/workers/expiration-worker.ts @@ -52,21 +52,21 @@ async function processExpiredBookings(): Promise { .execute(); // Enqueue webhook events - for (const bkg of expiredBookings) { + for (const booking of expiredBookings) { const allocations = await trx .selectFrom("allocations") .selectAll() - .where("bookingId", "=", bkg.id) + .where("bookingId", "=", booking.id) .execute(); - const expiredBkg = { - ...bkg, + const expiredBooking = { + ...booking, status: "expired" as const, expiresAt: null, updatedAt: serverTime, }; - await enqueueWebhookEvent(trx, "booking.expired", bkg.ledgerId, { - booking: serializeBooking(expiredBkg, allocations), + await enqueueWebhookEvent(trx, "booking.expired", booking.ledgerId, { + booking: serializeBooking(expiredBooking, allocations), }); } diff --git a/apps/server/test/integration/setup/factories/booking.factory.ts b/apps/server/test/integration/setup/factories/booking.factory.ts index d7ca05f..b08f139 100644 --- a/apps/server/test/integration/setup/factories/booking.factory.ts +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -38,12 +38,12 @@ export async function createBooking(overrides?: { } if (!ledgerId) { - const svc = await db + const service = await db .selectFrom("services") .where("id", "=", serviceId) .selectAll() .executeTakeFirstOrThrow(); - ledgerId = svc.ledgerId; + ledgerId = service.ledgerId; } const startAt = overrides?.startAt ?? faker.date.future(); diff --git a/apps/server/test/integration/v1/bookings/create.spec.ts b/apps/server/test/integration/v1/bookings/create.spec.ts index e02f711..9da8b19 100644 --- a/apps/server/test/integration/v1/bookings/create.spec.ts +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -336,15 +336,15 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(response.status).toBe(201); const { data } = (await response.json()) as { data: Booking }; - const alloc = data.allocations[0]!; + const allocation = data.allocations[0]!; // Allocation startAt/endAt = buffer-expanded blocked window - expect(alloc.startAt).toBe("2026-06-01T09:45:00.000Z"); // 10:00 - 15min - expect(alloc.endAt).toBe("2026-06-01T11:10:00.000Z"); // 11:00 + 10min + expect(allocation.startAt).toBe("2026-06-01T09:45:00.000Z"); // 10:00 - 15min + expect(allocation.endAt).toBe("2026-06-01T11:10:00.000Z"); // 11:00 + 10min // Buffer amounts stored for deriving original customer time - expect(alloc.bufferBeforeMs).toBe(900_000); - expect(alloc.bufferAfterMs).toBe(600_000); + expect(allocation.bufferBeforeMs).toBe(900_000); + expect(allocation.bufferAfterMs).toBe(600_000); }); it("stores zero buffers when no policy", async () => { @@ -368,13 +368,13 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(response.status).toBe(201); const { data } = (await response.json()) as { data: Booking }; - const alloc = data.allocations[0]!; + const allocation = data.allocations[0]!; // Without policy, allocation times = input times (no buffers) - expect(alloc.startAt).toBe(startAt); - expect(alloc.endAt).toBe(endAt); - expect(alloc.bufferBeforeMs).toBe(0); - expect(alloc.bufferAfterMs).toBe(0); + expect(allocation.startAt).toBe(startAt); + expect(allocation.endAt).toBe(endAt); + expect(allocation.bufferBeforeMs).toBe(0); + expect(allocation.bufferAfterMs).toBe(0); }); it("detects conflicts across buffer windows", async () => { diff --git a/packages/schema/inputs/allocation.ts b/packages/schema/inputs/allocation.ts index e152620..762264f 100644 --- a/packages/schema/inputs/allocation.ts +++ b/packages/schema/inputs/allocation.ts @@ -1,7 +1,7 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -export const createSchema = z +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" }), @@ -15,16 +15,16 @@ export const createSchema = z path: ["endAt"], }); -export const getSchema = z.object({ +export const get = 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" }), }); -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, "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 de688f6..9add7d9 100644 --- a/packages/schema/inputs/availability.ts +++ b/packages/schema/inputs/availability.ts @@ -1,7 +1,7 @@ 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" }), @@ -23,11 +23,11 @@ const serviceAvailabilityBase = { includeUnavailable: z.boolean().default(false), }; -export const slotsSchema = z.object({ +export const slots = z.object({ ...serviceAvailabilityBase, durationMs: z.number().int().positive(), }); -export const windowsSchema = z.object({ +export const windows = z.object({ ...serviceAvailabilityBase, }); diff --git a/packages/schema/inputs/booking.ts b/packages/schema/inputs/booking.ts index d7660ea..a929799 100644 --- a/packages/schema/inputs/booking.ts +++ b/packages/schema/inputs/booking.ts @@ -2,7 +2,7 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; import { BookingStatus } from "../constants"; -export const createSchema = z +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" }), @@ -17,21 +17,21 @@ export const createSchema = z path: ["endAt"], }); -export const getSchema = z.object({ +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 listSchema = z.object({ +export const list = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), }); -export const confirmSchema = z.object({ +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 cancelSchema = z.object({ +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 83f4efa..72e8568 100644 --- a/packages/schema/inputs/index.ts +++ b/packages/schema/inputs/index.ts @@ -1,8 +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 policy from "./policy"; -export * as service from "./service"; -export * as booking from "./booking"; +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 index 4e20528..b31f267 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -153,27 +153,27 @@ const policyConfigSchema = z }) .passthrough(); -export const createSchema = z.object({ +export const create = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), config: policyConfigSchema, }); -export const updateSchema = z.object({ +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 getSchema = z.object({ +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 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, "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 ff69b02..4360ebc 100644 --- a/packages/schema/inputs/resource.ts +++ b/packages/schema/inputs/resource.ts @@ -1,7 +1,7 @@ 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() @@ -21,16 +21,16 @@ export const createSchema = z.object({ .optional(), }); -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 index e688981..af3127c 100644 --- a/packages/schema/inputs/service.ts +++ b/packages/schema/inputs/service.ts @@ -1,7 +1,7 @@ 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" }), name: z.string().min(1).max(255), policyId: z @@ -15,7 +15,7 @@ export const createSchema = z.object({ metadata: z.record(z.string(), z.unknown()).nullable().optional(), }); -export const updateSchema = z.object({ +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), @@ -30,16 +30,16 @@ export const updateSchema = z.object({ metadata: z.record(z.string(), z.unknown()).nullable().optional(), }); -export const getSchema = z.object({ +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 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, "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 bf0f0ae..2ecfc4e 100644 --- a/packages/schema/inputs/webhook.ts +++ b/packages/schema/inputs/webhook.ts @@ -1,16 +1,16 @@ 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" }), @@ -18,14 +18,14 @@ export const updateSubscriptionSchema = z.object({ 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" }), From e2dfeea911c2e12bc50c65c02dc9362ea0bc71dd Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 03:19:11 +0300 Subject: [PATCH 20/31] Fix some naming conventions --- apps/server/src/app.ts | 31 +++++- apps/server/src/domain/policy/evaluate.ts | 18 ++-- apps/server/src/infra/webhooks.ts | 2 +- apps/server/src/lib/errors.ts | 10 +- ...0213001748_rename-cancelled-to-canceled.ts | 37 +++++++ .../operations/allocation/internal/insert.ts | 2 +- .../src/operations/allocation/remove.ts | 2 +- apps/server/src/operations/booking/cancel.ts | 10 +- apps/server/src/operations/booking/confirm.ts | 4 +- apps/server/src/operations/booking/create.ts | 4 +- apps/server/src/operations/policy/remove.ts | 2 +- apps/server/src/operations/resource/remove.ts | 2 +- apps/server/src/operations/service/remove.ts | 4 +- apps/server/src/routes/v1/policies.ts | 4 +- apps/server/src/routes/v1/webhooks.ts | 12 +-- apps/server/src/scripts/generate-openapi.ts | 102 +++++++++--------- .../setup/factories/booking.factory.ts | 2 +- .../integration/v1/allocations/create.spec.ts | 2 +- .../integration/v1/allocations/delete.spec.ts | 2 +- .../integration/v1/bookings/cancel.spec.ts | 12 +-- .../integration/v1/bookings/confirm.spec.ts | 4 +- .../integration/v1/bookings/create.spec.ts | 8 +- .../integration/v1/policies/create.spec.ts | 8 +- .../integration/v1/resources/delete.spec.ts | 4 +- .../integration/v1/services/delete.spec.ts | 8 +- packages/schema/constants/index.ts | 4 +- packages/schema/inputs/policy.ts | 4 +- packages/schema/outputs/allocation.ts | 10 +- packages/schema/outputs/availability.ts | 30 +++--- packages/schema/outputs/booking.ts | 12 +-- packages/schema/outputs/error.ts | 14 ++- packages/schema/outputs/ledger.ts | 10 +- packages/schema/outputs/policy.ts | 10 +- packages/schema/outputs/resource.ts | 10 +- packages/schema/outputs/service.ts | 10 +- packages/schema/outputs/webhook.ts | 20 ++-- packages/schema/types/index.ts | 20 ++-- 37 files changed, 255 insertions(+), 195 deletions(-) create mode 100644 apps/server/src/migrations/20260213001748_rename-cancelled-to-canceled.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3b65997..a25eff9 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -19,17 +19,38 @@ app.route("/", routes); app.onError((err, c) => { if (err instanceof InputError) { return c.json( - { error: { code: "VALIDATION_ERROR", message: err.message, details: err.issues } }, + { 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) { @@ -41,13 +62,13 @@ 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 { return c.json([err, err.stack], 500); } diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index ecc7275..d65cb40 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -10,15 +10,15 @@ // ─── Types ─────────────────────────────────────────────────────────────────── export const REASON_CODES = { - BLACKOUT_WINDOW: "blackout_window", - CLOSED_BY_SCHEDULE: "closed_by_schedule", - OVERNIGHT_NOT_SUPPORTED: "overnight_not_supported", - INVALID_DURATION: "invalid_duration", - MISALIGNED_START_TIME: "misaligned_start_time", - LEAD_TIME_VIOLATION: "lead_time_violation", - HORIZON_EXCEEDED: "horizon_exceeded", - POLICY_INVALID_CONFIG: "policy_invalid_config", - POLICY_EVAL_ERROR: "policy_eval_error", + 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]; diff --git a/apps/server/src/infra/webhooks.ts b/apps/server/src/infra/webhooks.ts index 35b8522..c8adae6 100644 --- a/apps/server/src/infra/webhooks.ts +++ b/apps/server/src/infra/webhooks.ts @@ -10,7 +10,7 @@ export type WebhookEventType = | "allocation.deleted" | "booking.created" | "booking.confirmed" - | "booking.cancelled" + | "booking.canceled" | "booking.expired"; export interface WebhookEvent { diff --git a/apps/server/src/lib/errors.ts b/apps/server/src/lib/errors.ts index 617fc1b..689d5ca 100644 --- a/apps/server/src/lib/errors.ts +++ b/apps/server/src/lib/errors.ts @@ -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/migrations/20260213001748_rename-cancelled-to-canceled.ts b/apps/server/src/migrations/20260213001748_rename-cancelled-to-canceled.ts new file mode 100644 index 0000000..ceb79d3 --- /dev/null +++ b/apps/server/src/migrations/20260213001748_rename-cancelled-to-canceled.ts @@ -0,0 +1,37 @@ +import type { Database } from "database/schema"; +import { Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // 1. Drop old constraints first (they reference 'cancelled') + await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_status_check`.execute(db); + await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_expires_at_consistency`.execute( + db, + ); + + // 2. Update existing rows + await sql`UPDATE bookings SET status = 'canceled' WHERE status = 'cancelled'`.execute(db); + + // 3. Re-add constraints with 'canceled' spelling + await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_status_check CHECK (status IN ('hold', 'confirmed', 'canceled', 'expired'))`.execute( + db, + ); + await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_expires_at_consistency CHECK ((status = 'hold' AND expires_at IS NOT NULL) OR (status IN ('confirmed', 'canceled', 'expired') AND expires_at IS NULL))`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_status_check`.execute(db); + await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_expires_at_consistency`.execute( + db, + ); + + await sql`UPDATE bookings SET status = 'cancelled' WHERE status = 'canceled'`.execute(db); + + await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_status_check CHECK (status IN ('hold', 'confirmed', 'cancelled', 'expired'))`.execute( + db, + ); + await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_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, + ); +} diff --git a/apps/server/src/operations/allocation/internal/insert.ts b/apps/server/src/operations/allocation/internal/insert.ts index 7ea25e7..fbf59cf 100644 --- a/apps/server/src/operations/allocation/internal/insert.ts +++ b/apps/server/src/operations/allocation/internal/insert.ts @@ -32,7 +32,7 @@ export async function insertAllocation( .execute(); if (conflicting.length > 0) { - throw new ConflictError("overlap_conflict", { + throw new ConflictError("allocation.overlap", { conflictingAllocationIds: conflicting.map((a) => a.id), }); } diff --git a/apps/server/src/operations/allocation/remove.ts b/apps/server/src/operations/allocation/remove.ts index a59a166..7c3310b 100644 --- a/apps/server/src/operations/allocation/remove.ts +++ b/apps/server/src/operations/allocation/remove.ts @@ -24,7 +24,7 @@ export default createOperation({ // 2. Cannot delete booking-owned allocations if (existing.bookingId !== null) { - throw new ConflictError("booking_owned_allocation", { + throw new ConflictError("allocation.managed_by_booking", { bookingId: existing.bookingId, }); } diff --git a/apps/server/src/operations/booking/cancel.ts b/apps/server/src/operations/booking/cancel.ts index 4b8d798..0af2db7 100644 --- a/apps/server/src/operations/booking/cancel.ts +++ b/apps/server/src/operations/booking/cancel.ts @@ -26,8 +26,8 @@ export default createOperation({ const serverTime = await getServerTime(trx); // 3. Validate state - if (existing.status === "cancelled") { - // Idempotent — already cancelled + if (existing.status === "canceled") { + // Idempotent — already canceled const allocations = await trx .selectFrom("allocations") .selectAll() @@ -37,9 +37,9 @@ export default createOperation({ } if (existing.status !== "hold" && existing.status !== "confirmed") { - throw new ConflictError("invalid_state_transition", { + throw new ConflictError("booking.invalid_transition", { currentStatus: existing.status, - requestedStatus: "cancelled", + requestedStatus: "canceled", }); } @@ -47,7 +47,7 @@ export default createOperation({ const booking = await trx .updateTable("bookings") .set({ - status: "cancelled", + status: "canceled", expiresAt: null, updatedAt: serverTime, }) diff --git a/apps/server/src/operations/booking/confirm.ts b/apps/server/src/operations/booking/confirm.ts index c75f511..d630b02 100644 --- a/apps/server/src/operations/booking/confirm.ts +++ b/apps/server/src/operations/booking/confirm.ts @@ -37,7 +37,7 @@ export default createOperation({ } if (existing.status !== "hold") { - throw new ConflictError("invalid_state_transition", { + throw new ConflictError("booking.invalid_transition", { currentStatus: existing.status, requestedStatus: "confirmed", }); @@ -45,7 +45,7 @@ export default createOperation({ // 4. Check if hold has expired if (existing.expiresAt && serverTime >= existing.expiresAt) { - throw new ConflictError("hold_expired", { + throw new ConflictError("booking.hold_expired", { expiresAt: existing.expiresAt, serverTime, }); diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index de400b4..d320d2d 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -51,7 +51,7 @@ export default createOperation({ .executeTakeFirst(); if (!serviceResource) { - throw new ConflictError("resource_not_in_service", { + throw new ConflictError("service.resource_not_member", { serviceId: input.serviceId, resourceId: input.resourceId, }); @@ -79,7 +79,7 @@ export default createOperation({ ); if (!result.allowed) { - throw new ConflictError("policy_rejected", { + throw new ConflictError("policy.rejected", { code: result.code, message: result.message, ...("details" in result ? { details: result.details } : {}), diff --git a/apps/server/src/operations/policy/remove.ts b/apps/server/src/operations/policy/remove.ts index 74af62a..fa9b04e 100644 --- a/apps/server/src/operations/policy/remove.ts +++ b/apps/server/src/operations/policy/remove.ts @@ -16,7 +16,7 @@ export default createOperation({ return { deleted: result.numDeletedRows > 0n }; } catch (err: unknown) { if (err instanceof Error && "code" in err && err.code === "23503") { - throw new ConflictError("policy_in_use", { + throw new ConflictError("policy.in_use", { message: "Policy is referenced by one or more services", }); } diff --git a/apps/server/src/operations/resource/remove.ts b/apps/server/src/operations/resource/remove.ts index 2557191..a32c52d 100644 --- a/apps/server/src/operations/resource/remove.ts +++ b/apps/server/src/operations/resource/remove.ts @@ -18,7 +18,7 @@ export default createOperation({ } } catch (err: unknown) { if (err instanceof Error && "code" in err && err.code === "23503") { - throw new ConflictError("resource_in_use", { + throw new ConflictError("resource.in_use", { message: "Resource has active allocations or service associations", }); } diff --git a/apps/server/src/operations/service/remove.ts b/apps/server/src/operations/service/remove.ts index 5233aad..2f28c50 100644 --- a/apps/server/src/operations/service/remove.ts +++ b/apps/server/src/operations/service/remove.ts @@ -30,10 +30,10 @@ export default createOperation({ .executeTakeFirst(); if (activeBooking) { - throw new ConflictError("active_bookings_exist"); + throw new ConflictError("resource.active_bookings"); } - // 3. Clean up non-active bookings (cancelled/expired) and their allocations + // 3. Clean up non-active bookings (canceled/expired) and their allocations const staleBookings = await trx .selectFrom("bookings") .select("id") diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts index fc58774..b5af94d 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -30,7 +30,7 @@ export const policies = new Hono() const responseBody: Record = { data: serializePolicy(policy) }; if (warnings.length > 0) { - responseBody["warnings"] = warnings; + responseBody["meta"] = { warnings }; } return c.json(responseBody, 201); }) @@ -45,7 +45,7 @@ export const policies = new Hono() const responseBody: Record = { data: serializePolicy(policy) }; if (warnings.length > 0) { - responseBody["warnings"] = warnings; + responseBody["meta"] = { warnings }; } return c.json(responseBody); }) diff --git a/apps/server/src/routes/v1/webhooks.ts b/apps/server/src/routes/v1/webhooks.ts index 487576e..c5a8aaf 100644 --- a/apps/server/src/routes/v1/webhooks.ts +++ b/apps/server/src/routes/v1/webhooks.ts @@ -34,11 +34,11 @@ export const webhooks = new Hono() }) // Update subscription - .patch("/:subscriptionId", async (c) => { + .patch("/:id", async (c) => { const body = await c.req.json(); const { subscription } = await operations.webhook.update({ ...body, - id: c.req.param("subscriptionId")!, + id: c.req.param("id")!, ledgerId: c.req.param("ledgerId")!, }); @@ -50,9 +50,9 @@ export const webhooks = new Hono() }) // Delete subscription - .delete("/:subscriptionId", async (c) => { + .delete("/:id", async (c) => { const { deleted } = await operations.webhook.remove({ - id: c.req.param("subscriptionId")!, + id: c.req.param("id")!, ledgerId: c.req.param("ledgerId")!, }); @@ -64,9 +64,9 @@ export const webhooks = new Hono() }) // Rotate secret - .post("/:subscriptionId/rotate-secret", async (c) => { + .post("/:id/rotate-secret", async (c) => { const { subscription, secret } = await operations.webhook.rotateSecret({ - id: c.req.param("subscriptionId")!, + id: c.req.param("id")!, ledgerId: c.req.param("ledgerId")!, }); diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index df770a1..2c41d24 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -17,19 +17,19 @@ import { 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("Slot", availability.slotSchema); -registry.register("ResourceSlots", availability.resourceSlotsSchema); -registry.register("Window", availability.windowSchema); -registry.register("ResourceWindows", availability.resourceWindowsSchema); -registry.register("Policy", policy.schema); -registry.register("Service", service.schema); -registry.register("Booking", booking.schema); +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 @@ -41,7 +41,7 @@ registry.registerPath({ responses: { 200: { description: "List of ledgers", - content: { "application/json": { schema: ledger.listSchema } }, + content: { "application/json": { schema: ledger.list } }, }, }, }); @@ -59,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", @@ -81,7 +81,7 @@ registry.registerPath({ responses: { 201: { description: "Ledger created", - content: { "application/json": { schema: ledger.getSchema } }, + content: { "application/json": { schema: ledger.get } }, }, }, }); @@ -96,7 +96,7 @@ registry.registerPath({ responses: { 200: { description: "List of resources", - content: { "application/json": { schema: resource.listSchema } }, + content: { "application/json": { schema: resource.list } }, }, }, }); @@ -112,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", @@ -144,7 +144,7 @@ registry.registerPath({ responses: { 201: { description: "Resource created", - content: { "application/json": { schema: resource.getSchema } }, + content: { "application/json": { schema: resource.get } }, }, }, }); @@ -201,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 } }, }, }, }); @@ -216,7 +216,7 @@ registry.registerPath({ responses: { 200: { description: "List of allocations", - content: { "application/json": { schema: allocation.listSchema } }, + content: { "application/json": { schema: allocation.list } }, }, }, }); @@ -232,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", @@ -269,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", @@ -311,7 +311,7 @@ registry.registerPath({ responses: { 200: { description: "List of services", - content: { "application/json": { schema: service.listSchema } }, + content: { "application/json": { schema: service.list } }, }, }, }); @@ -327,7 +327,7 @@ registry.registerPath({ responses: { 200: { description: "Service details", - content: { "application/json": { schema: service.getSchema } }, + content: { "application/json": { schema: service.get } }, }, 404: { description: "Service not found", @@ -370,7 +370,7 @@ registry.registerPath({ responses: { 201: { description: "Service created", - content: { "application/json": { schema: service.getSchema } }, + content: { "application/json": { schema: service.get } }, }, 404: { description: "Policy or resource not found", @@ -414,7 +414,7 @@ registry.registerPath({ responses: { 200: { description: "Service updated", - content: { "application/json": { schema: service.getSchema } }, + content: { "application/json": { schema: service.get } }, }, 404: { description: "Service, policy, or resource not found", @@ -488,7 +488,7 @@ registry.registerPath({ responses: { 200: { description: "Available slots per resource", - content: { "application/json": { schema: availability.slotsResponseSchema } }, + content: { "application/json": { schema: availability.slotsResponse } }, }, 404: { description: "Service not found", @@ -539,7 +539,7 @@ registry.registerPath({ responses: { 200: { description: "Available windows per resource", - content: { "application/json": { schema: availability.windowsResponseSchema } }, + content: { "application/json": { schema: availability.windowsResponse } }, }, 404: { description: "Service not found", @@ -562,7 +562,7 @@ registry.registerPath({ responses: { 200: { description: "List of bookings", - content: { "application/json": { schema: booking.listSchema } }, + content: { "application/json": { schema: booking.list } }, }, }, }); @@ -579,7 +579,7 @@ registry.registerPath({ responses: { 200: { description: "Booking details with allocations", - content: { "application/json": { schema: booking.getSchema } }, + content: { "application/json": { schema: booking.get } }, }, 404: { description: "Booking not found", @@ -621,7 +621,7 @@ registry.registerPath({ responses: { 201: { description: "Booking created", - content: { "application/json": { schema: booking.getSchema } }, + content: { "application/json": { schema: booking.get } }, }, 404: { description: "Service or resource not found", @@ -647,7 +647,7 @@ registry.registerPath({ responses: { 200: { description: "Booking confirmed", - content: { "application/json": { schema: booking.getSchema } }, + content: { "application/json": { schema: booking.get } }, }, 404: { description: "Booking not found", @@ -666,21 +666,21 @@ registry.registerPath({ tags: ["Bookings"], summary: "Cancel a booking", description: - "Cancels a booking in hold or confirmed status. Idempotent — cancelling an already cancelled booking returns success. Supports idempotency via the Idempotency-Key header.", + "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 cancelled", - content: { "application/json": { schema: booking.getSchema } }, + description: "Booking canceled", + content: { "application/json": { schema: booking.get } }, }, 404: { description: "Booking not found", content: { "application/json": { schema: error.schema } }, }, 409: { - description: "Booking cannot be cancelled (invalid state)", + description: "Booking cannot be canceled (invalid state)", content: { "application/json": { schema: error.schema } }, }, }, @@ -696,7 +696,7 @@ registry.registerPath({ responses: { 200: { description: "List of webhook subscriptions", - content: { "application/json": { schema: webhook.listSubscriptionsSchema } }, + content: { "application/json": { schema: webhook.listSubscriptions } }, }, }, }); @@ -723,18 +723,18 @@ 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": { @@ -748,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", @@ -759,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" }, @@ -776,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", @@ -806,7 +806,7 @@ registry.registerPath({ responses: { 200: { description: "List of policies", - content: { "application/json": { schema: policy.listSchema } }, + content: { "application/json": { schema: policy.list } }, }, }, }); @@ -822,7 +822,7 @@ registry.registerPath({ responses: { 200: { description: "Policy details", - content: { "application/json": { schema: policy.getSchema } }, + content: { "application/json": { schema: policy.get } }, }, 404: { description: "Policy not found", @@ -861,7 +861,7 @@ registry.registerPath({ responses: { 201: { description: "Policy created (may include warnings)", - content: { "application/json": { schema: policy.getSchema } }, + content: { "application/json": { schema: policy.get } }, }, }, }); @@ -896,7 +896,7 @@ registry.registerPath({ responses: { 200: { description: "Policy updated (may include warnings)", - content: { "application/json": { schema: policy.getSchema } }, + content: { "application/json": { schema: policy.get } }, }, 404: { description: "Policy not found", diff --git a/apps/server/test/integration/setup/factories/booking.factory.ts b/apps/server/test/integration/setup/factories/booking.factory.ts index b08f139..3e69168 100644 --- a/apps/server/test/integration/setup/factories/booking.factory.ts +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -8,7 +8,7 @@ export async function createBooking(overrides?: { ledgerId?: string; serviceId?: string; resourceId?: string; - status?: "hold" | "confirmed" | "cancelled" | "expired"; + status?: "hold" | "confirmed" | "canceled" | "expired"; expiresAt?: Date | null; metadata?: Record | null; startAt?: Date; diff --git a/apps/server/test/integration/v1/allocations/create.spec.ts b/apps/server/test/integration/v1/allocations/create.spec.ts index 31d1339..4e5614e 100644 --- a/apps/server/test/integration/v1/allocations/create.spec.ts +++ b/apps/server/test/integration/v1/allocations/create.spec.ts @@ -128,7 +128,7 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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 non-expired temporary allocation", async () => { diff --git a/apps/server/test/integration/v1/allocations/delete.spec.ts b/apps/server/test/integration/v1/allocations/delete.spec.ts index 3f7c505..67912eb 100644 --- a/apps/server/test/integration/v1/allocations/delete.spec.ts +++ b/apps/server/test/integration/v1/allocations/delete.spec.ts @@ -45,6 +45,6 @@ describe("DELETE /v1/ledgers/:ledgerId/allocations/:id", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("booking_owned_allocation"); + expect(body.error.code).toBe("allocation.managed_by_booking"); }); }); diff --git a/apps/server/test/integration/v1/bookings/cancel.spec.ts b/apps/server/test/integration/v1/bookings/cancel.spec.ts index b8a4c49..f02a0b5 100644 --- a/apps/server/test/integration/v1/bookings/cancel.spec.ts +++ b/apps/server/test/integration/v1/bookings/cancel.spec.ts @@ -21,7 +21,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/cancel", () => { return data; } - it("returns 200 when cancelling a hold booking", async () => { + it("returns 200 when canceling a hold booking", async () => { const { ledger } = await createLedger(); const holdBooking = await createHoldBooking(ledger.id); @@ -35,14 +35,14 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/cancel", () => { meta: { serverTime: string }; }; expect(data.id).toBe(holdBooking.id); - expect(data.status).toBe("cancelled"); + 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 cancelling a confirmed booking", async () => { + it("returns 200 when canceling a confirmed booking", async () => { const { ledger } = await createLedger(); const holdBooking = await createHoldBooking(ledger.id); @@ -59,11 +59,11 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/cancel", () => { expect(response.status).toBe(200); const { data } = (await response.json()) as { data: Booking }; - expect(data.status).toBe("cancelled"); + expect(data.status).toBe("canceled"); expect(data.allocations[0]!.active).toBe(false); }); - it("is idempotent — cancelling an already cancelled booking returns same data", async () => { + it("is idempotent — canceling an already canceled booking returns same data", async () => { const { ledger } = await createLedger(); const holdBooking = await createHoldBooking(ledger.id); @@ -76,7 +76,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings/: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("cancelled"); + expect(data.status).toBe("canceled"); }); it("returns 404 for non-existent booking", async () => { diff --git a/apps/server/test/integration/v1/bookings/confirm.spec.ts b/apps/server/test/integration/v1/bookings/confirm.spec.ts index a5257f0..ebc353f 100644 --- a/apps/server/test/integration/v1/bookings/confirm.spec.ts +++ b/apps/server/test/integration/v1/bookings/confirm.spec.ts @@ -67,7 +67,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/confirm", () => { expect(response.status).toBe(404); }); - it("returns 409 when confirming a cancelled booking", async () => { + it("returns 409 when confirming a canceled booking", async () => { const { ledger } = await createLedger(); const holdBooking = await createHoldBooking(ledger.id); @@ -84,6 +84,6 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/confirm", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("invalid_state_transition"); + 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 index 9da8b19..7f68a04 100644 --- a/apps/server/test/integration/v1/bookings/create.spec.ts +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -184,7 +184,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("resource_not_in_service"); + expect(body.error.code).toBe("service.resource_not_member"); }); describe("conflict detection", () => { @@ -215,7 +215,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { 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 existing booking", async () => { @@ -246,7 +246,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { 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("allows adjacent bookings (no overlap)", async () => { @@ -418,7 +418,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { 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("allows bookings outside the buffer window", async () => { diff --git a/apps/server/test/integration/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts index 1aa35ec..15dd63a 100644 --- a/apps/server/test/integration/v1/policies/create.spec.ts +++ b/apps/server/test/integration/v1/policies/create.spec.ts @@ -175,10 +175,10 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { expect(response.status).toBe(201); const body = (await response.json()) as { data: Policy; - warnings: Array<{ code: string; message: string }>; + meta: { warnings: Array<{ code: string; message: string }> }; }; - expect(body.warnings).toBeDefined(); - expect(body.warnings.length).toBeGreaterThan(0); - expect(body.warnings[0]!.code).toBe("unreachable_weekly_rule"); + 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/resources/delete.spec.ts b/apps/server/test/integration/v1/resources/delete.spec.ts index 2369876..b3c2003 100644 --- a/apps/server/test/integration/v1/resources/delete.spec.ts +++ b/apps/server/test/integration/v1/resources/delete.spec.ts @@ -40,7 +40,7 @@ describe("DELETE /v1/ledgers/:ledgerId/resources/:id", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("resource_in_use"); + expect(body.error.code).toBe("resource.in_use"); }); it("returns 409 when resource belongs to a service", async () => { @@ -55,6 +55,6 @@ describe("DELETE /v1/ledgers/:ledgerId/resources/:id", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("resource_in_use"); + expect(body.error.code).toBe("resource.in_use"); }); }); diff --git a/apps/server/test/integration/v1/services/delete.spec.ts b/apps/server/test/integration/v1/services/delete.spec.ts index 5ddabf9..1669a55 100644 --- a/apps/server/test/integration/v1/services/delete.spec.ts +++ b/apps/server/test/integration/v1/services/delete.spec.ts @@ -40,7 +40,7 @@ describe("DELETE /v1/ledgers/:ledgerId/services/:id", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("active_bookings_exist"); + expect(body.error.code).toBe("resource.active_bookings"); }); it("returns 409 when service has confirmed bookings", async () => { @@ -51,12 +51,12 @@ describe("DELETE /v1/ledgers/:ledgerId/services/:id", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("active_bookings_exist"); + expect(body.error.code).toBe("resource.active_bookings"); }); - it("allows deletion when all bookings are cancelled", async () => { + it("allows deletion when all bookings are canceled", async () => { const { ledger } = await createLedger(); - const { serviceId } = await createBooking({ ledgerId: ledger.id, status: "cancelled" }); + const { serviceId } = await createBooking({ ledgerId: ledger.id, status: "canceled" }); const response = await client.delete(`/v1/ledgers/${ledger.id}/services/${serviceId}`); diff --git a/packages/schema/constants/index.ts b/packages/schema/constants/index.ts index cfcb1c2..288b436 100644 --- a/packages/schema/constants/index.ts +++ b/packages/schema/constants/index.ts @@ -14,11 +14,11 @@ export const WebhookDeliveryStatus = { export const BookingStatus = { HOLD: "hold", CONFIRMED: "confirmed", - CANCELLED: "cancelled", + CANCELED: "canceled", EXPIRED: "expired", } as const; -export const PolicyDefault = { +export const ScheduleDefault = { OPEN: "open", CLOSED: "closed", } as const; diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts index b31f267..64ecc46 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -1,6 +1,6 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -import { PolicyDefault } from "../constants"; +import { ScheduleDefault } from "../constants"; // Time string: HH:MM (00:00-23:59) or 24:00 for end-of-day const timeStringSchema = z @@ -146,7 +146,7 @@ const ruleSchema = z const policyConfigSchema = z .object({ schema_version: z.literal(1), - default: z.enum([PolicyDefault.OPEN, PolicyDefault.CLOSED]), + default: z.enum([ScheduleDefault.OPEN, ScheduleDefault.CLOSED]), config: configAuthoringSchema, rules: z.array(ruleSchema).default([]), metadata: z.record(z.string(), z.unknown()).optional(), diff --git a/packages/schema/outputs/allocation.ts b/packages/schema/outputs/allocation.ts index a090c8e..2b0d0a5 100644 --- a/packages/schema/outputs/allocation.ts +++ b/packages/schema/outputs/allocation.ts @@ -1,6 +1,6 @@ import { z } from "./zod"; -export const schema = z.object({ +export const base = z.object({ id: z.string(), ledgerId: z.string(), resourceId: z.string(), @@ -16,8 +16,8 @@ export const schema = z.object({ updatedAt: z.string(), }); -export const getSchema = z.object({ - data: schema, +export const get = z.object({ + data: base, meta: z .object({ serverTime: z.string(), @@ -25,6 +25,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 adc4611..c256411 100644 --- a/packages/schema/outputs/availability.ts +++ b/packages/schema/outputs/availability.ts @@ -1,52 +1,52 @@ import { z } from "./zod"; -export const timelineBlockSchema = z.object({ +export const timelineBlock = z.object({ startAt: z.string(), endAt: 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 slotSchema = z.object({ +export const slot = z.object({ startAt: z.string(), endAt: z.string(), status: z.enum(["available", "unavailable"]).optional(), }); -export const resourceSlotsSchema = z.object({ +export const resourceSlots = z.object({ resourceId: z.string(), timezone: z.string(), - slots: z.array(slotSchema), + slots: z.array(slot), }); -export const slotsResponseSchema = z.object({ - data: z.array(resourceSlotsSchema), +export const slotsResponse = z.object({ + data: z.array(resourceSlots), meta: z.object({ serverTime: z.string() }), }); -export const windowSchema = z.object({ +export const window = z.object({ startAt: z.string(), endAt: z.string(), status: z.enum(["available", "unavailable"]).optional(), }); -export const resourceWindowsSchema = z.object({ +export const resourceWindows = z.object({ resourceId: z.string(), timezone: z.string(), - windows: z.array(windowSchema), + windows: z.array(window), }); -export const windowsResponseSchema = z.object({ - data: z.array(resourceWindowsSchema), +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 index 2b4544d..6eb87ec 100644 --- a/packages/schema/outputs/booking.ts +++ b/packages/schema/outputs/booking.ts @@ -11,7 +11,7 @@ const bookingAllocationSchema = z.object({ active: z.boolean(), }); -export const schema = z.object({ +export const base = z.object({ id: z.string(), ledgerId: z.string(), serviceId: z.string(), @@ -19,7 +19,7 @@ export const schema = z.object({ status: z.enum([ BookingStatus.HOLD, BookingStatus.CONFIRMED, - BookingStatus.CANCELLED, + BookingStatus.CANCELED, BookingStatus.EXPIRED, ]), expiresAt: z.string().nullable(), @@ -29,8 +29,8 @@ export const schema = z.object({ updatedAt: z.string(), }); -export const getSchema = z.object({ - data: schema, +export const get = z.object({ + data: base, meta: z .object({ serverTime: z.string(), @@ -38,6 +38,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/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/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 index 5fa83ec..9d87f49 100644 --- a/packages/schema/outputs/policy.ts +++ b/packages/schema/outputs/policy.ts @@ -1,6 +1,6 @@ import { z } from "./zod"; -export const schema = z.object({ +export const base = z.object({ id: z.string(), ledgerId: z.string(), config: z.record(z.string(), z.unknown()), @@ -9,10 +9,10 @@ export const schema = z.object({ 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/resource.ts b/packages/schema/outputs/resource.ts index afdf2ea..b9119d7 100644 --- a/packages/schema/outputs/resource.ts +++ b/packages/schema/outputs/resource.ts @@ -1,6 +1,6 @@ import { z } from "./zod"; -export const schema = z.object({ +export const base = z.object({ id: z.string(), ledgerId: z.string(), timezone: z.string().nullable(), @@ -8,10 +8,10 @@ export const schema = z.object({ 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 index 19aa8a1..eaf307a 100644 --- a/packages/schema/outputs/service.ts +++ b/packages/schema/outputs/service.ts @@ -1,6 +1,6 @@ import { z } from "./zod"; -export const schema = z.object({ +export const base = z.object({ id: z.string(), ledgerId: z.string(), name: z.string(), @@ -11,10 +11,10 @@ export const schema = z.object({ 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/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 1a0f9fa..6bd9696 100644 --- a/packages/schema/types/index.ts +++ b/packages/schema/types/index.ts @@ -4,20 +4,20 @@ import { BookingStatus, IdempotencyStatus, WebhookDeliveryStatus, - PolicyDefault, + ScheduleDefault, } from "../constants"; import { ConstantType } from "./utils"; export type BookingStatus = ConstantType; export type IdempotencyStatus = ConstantType; export type WebhookDeliveryStatus = ConstantType; -export type PolicyDefault = 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 Policy = z.infer; -export type Service = z.infer; -export type Booking = 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; From bd1bdbdf1e550898cc8a2d4c253be747b3cce176 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 03:46:42 +0300 Subject: [PATCH 21/31] Fix remaning naming issues --- apps/server/openapi.json | 1345 ++++++++--------- apps/server/src/database/schema.ts | 4 +- apps/server/src/domain/policy/evaluate.ts | 68 +- apps/server/src/domain/policy/normalize.ts | 2 +- .../src/domain/scheduling/availability.ts | 72 +- apps/server/src/domain/scheduling/timeline.ts | 12 +- ...2153727_create-bookings-services-tables.ts | 33 +- ...0213001748_rename-cancelled-to-canceled.ts | 37 - .../src/operations/allocation/create.ts | 4 +- .../operations/allocation/internal/insert.ts | 12 +- .../src/operations/availability/query.ts | 18 +- .../src/operations/availability/slots.ts | 24 +- .../src/operations/availability/windows.ts | 24 +- apps/server/src/operations/booking/create.ts | 20 +- apps/server/src/routes/v1/allocations.ts | 2 +- apps/server/src/routes/v1/availability.ts | 4 +- apps/server/src/routes/v1/bookings.ts | 2 +- apps/server/src/routes/v1/serializers.ts | 20 +- apps/server/src/scripts/generate-openapi.ts | 28 +- .../setup/factories/allocation.factory.ts | 12 +- .../setup/factories/booking.factory.ts | 12 +- .../integration/v1/allocations/create.spec.ts | 92 +- .../v1/allocations/idempotency.spec.ts | 28 +- .../integration/v1/availability/query.spec.ts | 190 ++- .../integration/v1/availability/slots.spec.ts | 114 +- .../v1/availability/windows.spec.ts | 114 +- .../integration/v1/bookings/cancel.spec.ts | 4 +- .../integration/v1/bookings/confirm.spec.ts | 4 +- .../integration/v1/bookings/create.spec.ts | 124 +- .../integration/v1/resources/delete.spec.ts | 4 +- .../test/unit/domain/policy/evaluate.spec.ts | 100 +- .../domain/scheduling/availability.spec.ts | 154 +- packages/schema/inputs/allocation.ts | 10 +- packages/schema/inputs/availability.ts | 8 +- packages/schema/inputs/booking.ts | 10 +- packages/schema/inputs/policy.ts | 22 +- packages/schema/outputs/allocation.ts | 10 +- packages/schema/outputs/availability.ts | 12 +- packages/schema/outputs/booking.ts | 10 +- 39 files changed, 1383 insertions(+), 1382 deletions(-) delete mode 100644 apps/server/src/migrations/20260213001748_rename-cancelled-to-canceled.ts diff --git a/apps/server/openapi.json b/apps/server/openapi.json index f770427..32a584c 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -73,17 +73,23 @@ "active": { "type": "boolean" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "expiresAt": { "type": ["string", "null"] @@ -105,10 +111,9 @@ "resourceId", "bookingId", "active", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -147,10 +152,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -158,7 +163,7 @@ "enum": ["free", "busy"] } }, - "required": ["startAt", "endAt", "status"] + "required": ["startTime", "endTime", "status"] } } }, @@ -167,10 +172,10 @@ "TimelineBlock": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -178,15 +183,15 @@ "enum": ["free", "busy"] } }, - "required": ["startAt", "endAt", "status"] + "required": ["startTime", "endTime", "status"] }, "Slot": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -194,7 +199,7 @@ "enum": ["available", "unavailable"] } }, - "required": ["startAt", "endAt"] + "required": ["startTime", "endTime"] }, "ResourceSlots": { "type": "object", @@ -210,10 +215,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -221,7 +226,7 @@ "enum": ["available", "unavailable"] } }, - "required": ["startAt", "endAt"] + "required": ["startTime", "endTime"] } } }, @@ -230,10 +235,10 @@ "Window": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -241,7 +246,7 @@ "enum": ["available", "unavailable"] } }, - "required": ["startAt", "endAt"] + "required": ["startTime", "endTime"] }, "ResourceWindows": { "type": "object", @@ -257,10 +262,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -268,7 +273,7 @@ "enum": ["available", "unavailable"] } }, - "required": ["startAt", "endAt"] + "required": ["startTime", "endTime"] } } }, @@ -354,9 +359,12 @@ "serviceId": { "type": "string" }, + "policyId": { + "type": ["string", "null"] + }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": ["hold", "confirmed", "canceled", "expired"] }, "expiresAt": { "type": ["string", "null"] @@ -372,31 +380,29 @@ "resourceId": { "type": "string" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "active": { "type": "boolean" } }, - "required": [ - "id", - "resourceId", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", - "active" - ] + "required": ["id", "resourceId", "startTime", "endTime", "buffer", "active"] } }, "metadata": { @@ -414,6 +420,7 @@ "id", "ledgerId", "serviceId", + "policyId", "status", "expiresAt", "allocations", @@ -426,27 +433,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"] @@ -591,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"] @@ -808,27 +809,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"] @@ -871,27 +869,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"] @@ -931,20 +926,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"] } } } @@ -970,10 +965,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -981,7 +976,7 @@ "enum": ["free", "busy"] } }, - "required": ["startAt", "endAt", "status"] + "required": ["startTime", "endTime", "status"] } } }, @@ -1039,17 +1034,23 @@ "active": { "type": "boolean" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "expiresAt": { "type": ["string", "null"] @@ -1071,10 +1072,9 @@ "resourceId", "bookingId", "active", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -1114,11 +1114,11 @@ "type": "string", "example": "rsc_01abc123def456ghi789jkl012" }, - "startAt": { + "startTime": { "type": "string", "format": "date-time" }, - "endAt": { + "endTime": { "type": "string", "format": "date-time" }, @@ -1132,7 +1132,7 @@ "additionalProperties": {} } }, - "required": ["resourceId", "startAt", "endAt"] + "required": ["resourceId", "startTime", "endTime"] } } } @@ -1163,17 +1163,23 @@ "active": { "type": "boolean" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "expiresAt": { "type": ["string", "null"] @@ -1195,10 +1201,9 @@ "resourceId", "bookingId", "active", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -1228,27 +1233,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"] @@ -1307,17 +1309,23 @@ "active": { "type": "boolean" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "expiresAt": { "type": ["string", "null"] @@ -1339,10 +1347,9 @@ "resourceId", "bookingId", "active", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "expiresAt", "metadata", "createdAt", @@ -1372,27 +1379,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"] @@ -1436,27 +1440,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"] @@ -1472,27 +1473,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"] @@ -1691,27 +1689,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"] @@ -1809,27 +1804,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"] @@ -1959,27 +1951,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"] @@ -2023,27 +2012,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"] @@ -2059,27 +2045,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"] @@ -2119,16 +2102,16 @@ "schema": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string", "format": "date-time", "description": "Start of the query window (ISO 8601)", "example": "2026-03-02T00:00:00Z" }, - "endAt": { + "endTime": { "type": "string", "format": "date-time", - "description": "End of the query window (ISO 8601). Max 7 days from startAt.", + "description": "End of the query window (ISO 8601). Max 7 days from startTime.", "example": "2026-03-07T00:00:00Z" }, "durationMs": { @@ -2149,7 +2132,7 @@ "description": "Return all grid positions with status. Defaults to false." } }, - "required": ["startAt", "endAt", "durationMs"] + "required": ["startTime", "endTime", "durationMs"] } } } @@ -2178,10 +2161,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -2189,7 +2172,7 @@ "enum": ["available", "unavailable"] } }, - "required": ["startAt", "endAt"] + "required": ["startTime", "endTime"] } } }, @@ -2219,27 +2202,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"] @@ -2255,27 +2235,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"] @@ -2315,16 +2292,16 @@ "schema": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string", "format": "date-time", "description": "Start of the query window (ISO 8601)", "example": "2026-03-02T00:00:00Z" }, - "endAt": { + "endTime": { "type": "string", "format": "date-time", - "description": "End of the query window (ISO 8601). Max 31 days from startAt.", + "description": "End of the query window (ISO 8601). Max 31 days from startTime.", "example": "2026-03-07T00:00:00Z" }, "resourceIds": { @@ -2339,7 +2316,7 @@ "description": "Return full schedule with status. Defaults to false." } }, - "required": ["startAt", "endAt"] + "required": ["startTime", "endTime"] } } } @@ -2368,10 +2345,10 @@ "items": { "type": "object", "properties": { - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, "status": { @@ -2379,7 +2356,7 @@ "enum": ["available", "unavailable"] } }, - "required": ["startAt", "endAt"] + "required": ["startTime", "endTime"] } } }, @@ -2409,27 +2386,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"] @@ -2445,27 +2419,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"] @@ -2512,9 +2483,12 @@ "serviceId": { "type": "string" }, + "policyId": { + "type": ["string", "null"] + }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": ["hold", "confirmed", "canceled", "expired"] }, "expiresAt": { "type": ["string", "null"] @@ -2530,17 +2504,23 @@ "resourceId": { "type": "string" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "active": { "type": "boolean" @@ -2549,10 +2529,9 @@ "required": [ "id", "resourceId", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "active" ] } @@ -2572,6 +2551,7 @@ "id", "ledgerId", "serviceId", + "policyId", "status", "expiresAt", "allocations", @@ -2592,7 +2572,7 @@ "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 startAt/endAt represent the buffer-expanded blocked window. The original customer time can be derived using bufferBeforeMs and bufferAfterMs on the allocation. Supports idempotency via the Idempotency-Key header.", + "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": { @@ -2617,12 +2597,12 @@ "type": "string", "example": "rsc_01abc123def456ghi789jkl012" }, - "startAt": { + "startTime": { "type": "string", "format": "date-time", "example": "2026-01-15T10:00:00Z" }, - "endAt": { + "endTime": { "type": "string", "format": "date-time", "example": "2026-01-15T11:00:00Z" @@ -2638,7 +2618,7 @@ "additionalProperties": {} } }, - "required": ["serviceId", "resourceId", "startAt", "endAt"] + "required": ["serviceId", "resourceId", "startTime", "endTime"] } } } @@ -2663,9 +2643,12 @@ "serviceId": { "type": "string" }, + "policyId": { + "type": ["string", "null"] + }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": ["hold", "confirmed", "canceled", "expired"] }, "expiresAt": { "type": ["string", "null"] @@ -2681,17 +2664,23 @@ "resourceId": { "type": "string" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "active": { "type": "boolean" @@ -2700,10 +2689,9 @@ "required": [ "id", "resourceId", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "active" ] } @@ -2723,6 +2711,7 @@ "id", "ledgerId", "serviceId", + "policyId", "status", "expiresAt", "allocations", @@ -2754,27 +2743,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"] @@ -2790,27 +2776,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"] @@ -2864,9 +2847,12 @@ "serviceId": { "type": "string" }, + "policyId": { + "type": ["string", "null"] + }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": ["hold", "confirmed", "canceled", "expired"] }, "expiresAt": { "type": ["string", "null"] @@ -2882,17 +2868,23 @@ "resourceId": { "type": "string" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "active": { "type": "boolean" @@ -2901,10 +2893,9 @@ "required": [ "id", "resourceId", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "active" ] } @@ -2924,6 +2915,7 @@ "id", "ledgerId", "serviceId", + "policyId", "status", "expiresAt", "allocations", @@ -2955,27 +2947,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"] @@ -3029,9 +3018,12 @@ "serviceId": { "type": "string" }, + "policyId": { + "type": ["string", "null"] + }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": ["hold", "confirmed", "canceled", "expired"] }, "expiresAt": { "type": ["string", "null"] @@ -3047,17 +3039,23 @@ "resourceId": { "type": "string" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "active": { "type": "boolean" @@ -3066,10 +3064,9 @@ "required": [ "id", "resourceId", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "active" ] } @@ -3089,6 +3086,7 @@ "id", "ledgerId", "serviceId", + "policyId", "status", "expiresAt", "allocations", @@ -3120,27 +3118,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"] @@ -3156,27 +3151,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"] @@ -3191,7 +3183,7 @@ "post": { "tags": ["Bookings"], "summary": "Cancel a booking", - "description": "Cancels a booking in hold or confirmed status. Idempotent — cancelling an already cancelled booking returns success. Supports idempotency via the Idempotency-Key header.", + "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": { @@ -3212,7 +3204,7 @@ ], "responses": { "200": { - "description": "Booking cancelled", + "description": "Booking canceled", "content": { "application/json": { "schema": { @@ -3230,9 +3222,12 @@ "serviceId": { "type": "string" }, + "policyId": { + "type": ["string", "null"] + }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": ["hold", "confirmed", "canceled", "expired"] }, "expiresAt": { "type": ["string", "null"] @@ -3248,17 +3243,23 @@ "resourceId": { "type": "string" }, - "startAt": { + "startTime": { "type": "string" }, - "endAt": { + "endTime": { "type": "string" }, - "bufferBeforeMs": { - "type": "number" - }, - "bufferAfterMs": { - "type": "number" + "buffer": { + "type": "object", + "properties": { + "beforeMs": { + "type": "number" + }, + "afterMs": { + "type": "number" + } + }, + "required": ["beforeMs", "afterMs"] }, "active": { "type": "boolean" @@ -3267,10 +3268,9 @@ "required": [ "id", "resourceId", - "startAt", - "endAt", - "bufferBeforeMs", - "bufferAfterMs", + "startTime", + "endTime", + "buffer", "active" ] } @@ -3290,6 +3290,7 @@ "id", "ledgerId", "serviceId", + "policyId", "status", "expiresAt", "allocations", @@ -3321,27 +3322,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"] @@ -3350,34 +3348,31 @@ } }, "409": { - "description": "Booking cannot be cancelled (invalid state)", + "description": "Booking cannot be canceled (invalid state)", "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"] @@ -3514,7 +3509,7 @@ } } }, - "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}": { + "/v1/ledgers/{ledgerId}/webhooks/{id}": { "patch": { "tags": ["Webhooks"], "summary": "Update a webhook subscription", @@ -3532,7 +3527,7 @@ "type": "string" }, "required": true, - "name": "subscriptionId", + "name": "id", "in": "path" } ], @@ -3594,27 +3589,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"] @@ -3641,7 +3633,7 @@ "type": "string" }, "required": true, - "name": "subscriptionId", + "name": "id", "in": "path" } ], @@ -3657,27 +3649,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"] @@ -3688,7 +3677,7 @@ } } }, - "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}/rotate-secret": { + "/v1/ledgers/{ledgerId}/webhooks/{id}/rotate-secret": { "post": { "tags": ["Webhooks"], "summary": "Rotate webhook secret", @@ -3707,7 +3696,7 @@ "type": "string" }, "required": true, - "name": "subscriptionId", + "name": "id", "in": "path" } ], @@ -3757,27 +3746,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"] @@ -4033,27 +4019,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"] @@ -4180,27 +4163,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"] @@ -4243,27 +4223,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"] diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index b28f80f..063c62f 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -25,8 +25,8 @@ export interface AllocationsTable { resourceId: string; bookingId: string | null; active: boolean; - startAt: Date; - endAt: Date; + startTime: Date; + endTime: Date; bufferBeforeMs: number; bufferAfterMs: number; expiresAt: Date | null; diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index d65cb40..2c779d0 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -24,8 +24,8 @@ export const REASON_CODES = { export type ReasonCode = (typeof REASON_CODES)[keyof typeof REASON_CODES]; export interface EvaluationInput { - startAt: Date; - endAt: Date; + startTime: Date; + endTime: Date; } export interface EvaluationContext { @@ -44,9 +44,9 @@ interface GridConfig { interval_ms: number; } -interface BookingWindowConfig { - min_lead_time_ms?: number; - max_lead_time_ms?: number; +interface LeadTimeConfig { + min_ms?: number; + max_ms?: number; } interface BuffersConfig { @@ -54,12 +54,16 @@ interface BuffersConfig { after_ms?: number; } +interface HoldConfig { + duration_ms?: number; +} + export interface ResolvedConfig { duration?: DurationConfig | undefined; grid?: GridConfig | undefined; - booking_window?: BookingWindowConfig | undefined; + lead_time?: LeadTimeConfig | undefined; buffers?: BuffersConfig | undefined; - hold_duration_ms?: number | undefined; + hold?: HoldConfig | undefined; } interface TimeWindow { @@ -94,8 +98,8 @@ export type EvaluationResult = | { allowed: true; resolvedConfig: ResolvedConfig; - effectiveStartAt: Date; - effectiveEndAt: Date; + effectiveStartTime: Date; + effectiveEndTime: Date; bufferBeforeMs: number; bufferAfterMs: number; } @@ -242,12 +246,12 @@ export function evaluatePolicy( const rules = policy.rules ?? []; // Computed values - const localStartDate = toLocalDate(request.startAt, context.timezone); - const localEndDate = toLocalDate(request.endAt, context.timezone); - const localStartMs = msSinceLocalMidnight(request.startAt, context.timezone); - const localEndMs = msSinceLocalMidnight(request.endAt, context.timezone); + 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.endAt.getTime() - request.startAt.getTime(); + const durationMs = request.endTime.getTime() - request.startTime.getTime(); // Midnight normalization let spansMultipleDays = localEndDate !== localStartDate; @@ -270,8 +274,8 @@ export function evaluatePolicy( return { allowed: true, resolvedConfig: policy.config as unknown as ResolvedConfig, - effectiveStartAt: new Date(request.startAt.getTime() - bufferBeforeMs), - effectiveEndAt: new Date(request.endAt.getTime() + bufferAfterMs), + effectiveStartTime: new Date(request.startTime.getTime() - bufferBeforeMs), + effectiveEndTime: new Date(request.endTime.getTime() + bufferAfterMs), bufferBeforeMs, bufferAfterMs, }; @@ -280,7 +284,7 @@ export function evaluatePolicy( // ─── Step 1: Blackout pre-pass ───────────────────────────────────────── // Compute all local dates the booking overlaps (half-open: end exclusive) - const overlapEndInstant = new Date(request.endAt.getTime() - 1); + const overlapEndInstant = new Date(request.endTime.getTime() - 1); const lastOverlapDate = toLocalDate(overlapEndInstant, context.timezone); const overlapDates = dateRange(localStartDate, lastOverlapDate); @@ -360,9 +364,9 @@ export function evaluatePolicy( const resolved = { duration: resolvedRaw["duration"] as DurationConfig | undefined, grid: resolvedRaw["grid"] as GridConfig | undefined, - booking_window: resolvedRaw["booking_window"] as BookingWindowConfig | undefined, + lead_time: resolvedRaw["lead_time"] as LeadTimeConfig | undefined, buffers: resolvedRaw["buffers"] as BuffersConfig | undefined, - hold_duration_ms: resolvedRaw["hold_duration_ms"] as number | undefined, + hold: resolvedRaw["hold"] as HoldConfig | undefined, }; // ─── Step 5: Duration check ──────────────────────────────────────────── @@ -410,7 +414,7 @@ export function evaluatePolicy( // ─── Step 6: Grid alignment ──────────────────────────────────────────── if (resolved.grid) { - const msSinceMidnight = msSinceLocalMidnight(request.startAt, context.timezone); + const msSinceMidnight = msSinceLocalMidnight(request.startTime, context.timezone); if (msSinceMidnight % resolved.grid.interval_ms !== 0) { return { allowed: false, @@ -427,16 +431,16 @@ export function evaluatePolicy( // ─── Step 7: Lead time ───────────────────────────────────────────────── - if (resolved.booking_window?.min_lead_time_ms !== undefined) { - const leadTime = request.startAt.getTime() - context.decisionTime.getTime(); - if (leadTime < resolved.booking_window.min_lead_time_ms) { + 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_lead_time_ms: resolved.booking_window.min_lead_time_ms, + min_ms: resolved.lead_time.min_ms, }, }; } @@ -444,16 +448,16 @@ export function evaluatePolicy( // ─── Step 8: Horizon ─────────────────────────────────────────────────── - if (resolved.booking_window?.max_lead_time_ms !== undefined) { - const leadTime = request.startAt.getTime() - context.decisionTime.getTime(); - if (leadTime > resolved.booking_window.max_lead_time_ms) { + 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_lead_time_ms: resolved.booking_window.max_lead_time_ms, + max_ms: resolved.lead_time.max_ms, }, }; } @@ -463,14 +467,14 @@ export function evaluatePolicy( const bufferBeforeMs = resolved.buffers?.before_ms ?? 0; const bufferAfterMs = resolved.buffers?.after_ms ?? 0; - const effectiveStartAt = new Date(request.startAt.getTime() - bufferBeforeMs); - const effectiveEndAt = new Date(request.endAt.getTime() + bufferAfterMs); + const effectiveStartTime = new Date(request.startTime.getTime() - bufferBeforeMs); + const effectiveEndTime = new Date(request.endTime.getTime() + bufferAfterMs); return { allowed: true, resolvedConfig: resolved, - effectiveStartAt, - effectiveEndAt, + effectiveStartTime, + effectiveEndTime, bufferBeforeMs, bufferAfterMs, }; diff --git a/apps/server/src/domain/policy/normalize.ts b/apps/server/src/domain/policy/normalize.ts index 3ddf140..89cebe7 100644 --- a/apps/server/src/domain/policy/normalize.ts +++ b/apps/server/src/domain/policy/normalize.ts @@ -93,7 +93,7 @@ function normalizeConfigSection(config: Record): Record ({ - start: a.startAt.getTime(), - end: a.endAt.getTime(), + start: a.startTime.getTime(), + end: a.endTime.getTime(), })); const slots: SlotResult[] = []; @@ -303,10 +303,8 @@ export function generateSlots( const gridInterval = (day.config.grid as GridConfig | undefined)?.interval_ms ?? durationMs; const beforeMs = (day.config.buffers as BuffersConfig | undefined)?.before_ms ?? 0; const afterMs = (day.config.buffers as BuffersConfig | undefined)?.after_ms ?? 0; - const minLeadMs = (day.config.booking_window as BookingWindowConfig | undefined) - ?.min_lead_time_ms; - const maxLeadMs = (day.config.booking_window as BookingWindowConfig | undefined) - ?.max_lead_time_ms; + const minLeadMs = (day.config.lead_time as LeadTimeConfig | undefined)?.min_ms; + const maxLeadMs = (day.config.lead_time as LeadTimeConfig | undefined)?.max_ms; for (const window of day.windows) { // Generate candidates at grid steps within this window @@ -348,15 +346,15 @@ export function generateSlots( if (available) { const slot: SlotResult = { - startAt: candidateStart.toISOString(), - endAt: candidateEnd.toISOString(), + startTime: candidateStart.toISOString(), + endTime: candidateEnd.toISOString(), }; if (includeUnavailable) slot.status = "available"; slots.push(slot); } else if (includeUnavailable) { slots.push({ - startAt: candidateStart.toISOString(), - endAt: candidateEnd.toISOString(), + startTime: candidateStart.toISOString(), + endTime: candidateEnd.toISOString(), status: "unavailable", }); } @@ -423,8 +421,8 @@ export function computeWindows( // Convert allocations to Interval format const busyIntervals: Interval[] = allocations.map((a) => ({ - start: a.startAt, - end: a.endAt, + start: a.startTime, + end: a.endTime, })); // Step 1-2: Convert schedule windows to absolute intervals, clamped to query range @@ -518,14 +516,14 @@ export function computeWindows( // Step 6: Filter by lead time and horizon const leadFiltered = filteredWindows.filter((w) => { const config = getConfigForTime(w.start.getTime()); - const bwConfig = config.booking_window as BookingWindowConfig | undefined; + const ltConfig = config.lead_time as LeadTimeConfig | undefined; - // For windows, we check if the window START is within the booking window + // For windows, we check if the window START is within the lead time window const leadTime = w.start.getTime() - serverTimeMs; - if (bwConfig?.min_lead_time_ms !== undefined && leadTime < bwConfig.min_lead_time_ms) { + if (ltConfig?.min_ms !== undefined && leadTime < ltConfig.min_ms) { // Trim the window start to meet lead time, rather than discarding entirely - const minStart = serverTimeMs + bwConfig.min_lead_time_ms; + const minStart = serverTimeMs + ltConfig.min_ms; if (minStart < w.end.getTime()) { w.start = new Date(minStart); } else { @@ -533,8 +531,8 @@ export function computeWindows( } } - if (bwConfig?.max_lead_time_ms !== undefined) { - const maxStart = serverTimeMs + bwConfig.max_lead_time_ms; + 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) { @@ -566,16 +564,16 @@ export function computeWindows( for (const entry of allEntries) { results.push({ - startAt: entry.interval.start.toISOString(), - endAt: entry.interval.end.toISOString(), + startTime: entry.interval.start.toISOString(), + endTime: entry.interval.end.toISOString(), status: entry.status, }); } } else { for (const w of finalWindows) { results.push({ - startAt: w.start.toISOString(), - endAt: w.end.toISOString(), + startTime: w.start.toISOString(), + endTime: w.end.toISOString(), }); } } diff --git a/apps/server/src/domain/scheduling/timeline.ts b/apps/server/src/domain/scheduling/timeline.ts index 07486f1..beb0a66 100644 --- a/apps/server/src/domain/scheduling/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/migrations/20260212153727_create-bookings-services-tables.ts b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts index cf49e5e..d5319ec 100644 --- a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts +++ b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts @@ -46,12 +46,12 @@ export async function up(db: Kysely): Promise { .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', 'cancelled', 'expired')`), + 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', 'cancelled', 'expired') AND expires_at IS NULL)`, + 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()`)) @@ -74,6 +74,15 @@ export async function up(db: Kysely): Promise { 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") @@ -108,12 +117,11 @@ export async function up(db: Kysely): Promise { // Drop old indexes await db.schema.dropIndex("idx_allocations_status").ifExists().execute(); - await db.schema.dropIndex("idx_allocations_time_range").ifExists().execute(); // Add new indexes await sql` CREATE INDEX idx_allocations_active - ON allocations(resource_id, start_at, end_at) + ON allocations(resource_id, start_time, end_time) WHERE active = true `.execute(db); @@ -158,11 +166,6 @@ export async function down(db: Kysely): Promise { .column("status") .execute(); - await sql` - CREATE INDEX idx_allocations_time_range ON allocations - USING GIST (resource_id, tstzrange(start_at, end_at, '[)')) - `.execute(db); - // Drop new columns await db.schema.alterTable("allocations").dropColumn("buffer_after_ms").execute(); await db.schema.alterTable("allocations").dropColumn("buffer_before_ms").execute(); @@ -174,6 +177,18 @@ export async function down(db: Kysely): Promise { 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/migrations/20260213001748_rename-cancelled-to-canceled.ts b/apps/server/src/migrations/20260213001748_rename-cancelled-to-canceled.ts deleted file mode 100644 index ceb79d3..0000000 --- a/apps/server/src/migrations/20260213001748_rename-cancelled-to-canceled.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Database } from "database/schema"; -import { Kysely, sql } from "kysely"; - -export async function up(db: Kysely): Promise { - // 1. Drop old constraints first (they reference 'cancelled') - await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_status_check`.execute(db); - await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_expires_at_consistency`.execute( - db, - ); - - // 2. Update existing rows - await sql`UPDATE bookings SET status = 'canceled' WHERE status = 'cancelled'`.execute(db); - - // 3. Re-add constraints with 'canceled' spelling - await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_status_check CHECK (status IN ('hold', 'confirmed', 'canceled', 'expired'))`.execute( - db, - ); - await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_expires_at_consistency CHECK ((status = 'hold' AND expires_at IS NOT NULL) OR (status IN ('confirmed', 'canceled', 'expired') AND expires_at IS NULL))`.execute( - db, - ); -} - -export async function down(db: Kysely): Promise { - await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_status_check`.execute(db); - await sql`ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_expires_at_consistency`.execute( - db, - ); - - await sql`UPDATE bookings SET status = 'cancelled' WHERE status = 'canceled'`.execute(db); - - await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_status_check CHECK (status IN ('hold', 'confirmed', 'cancelled', 'expired'))`.execute( - db, - ); - await sql`ALTER TABLE bookings ADD CONSTRAINT bookings_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, - ); -} diff --git a/apps/server/src/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts index 519dc25..b4db451 100644 --- a/apps/server/src/operations/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -31,8 +31,8 @@ export default createOperation({ ledgerId: input.ledgerId, resourceId: input.resourceId, bookingId: null, - startAt: input.startAt, - endAt: input.endAt, + startTime: input.startTime, + endTime: input.endTime, bufferBeforeMs: 0, bufferAfterMs: 0, expiresAt: input.expiresAt ?? null, diff --git a/apps/server/src/operations/allocation/internal/insert.ts b/apps/server/src/operations/allocation/internal/insert.ts index fbf59cf..829056b 100644 --- a/apps/server/src/operations/allocation/internal/insert.ts +++ b/apps/server/src/operations/allocation/internal/insert.ts @@ -7,8 +7,8 @@ interface InsertAllocationParams { ledgerId: string; resourceId: string; bookingId: string | null; - startAt: Date; - endAt: Date; + startTime: Date; + endTime: Date; bufferBeforeMs: number; bufferAfterMs: number; expiresAt: Date | null; @@ -27,8 +27,8 @@ export async function insertAllocation( .where("resourceId", "=", params.resourceId) .where("active", "=", true) .where((eb) => eb.or([eb("expiresAt", "is", null), eb("expiresAt", ">", params.serverTime)])) - .where("startAt", "<", params.endAt) - .where("endAt", ">", params.startAt) + .where("startTime", "<", params.endTime) + .where("endTime", ">", params.startTime) .execute(); if (conflicting.length > 0) { @@ -46,8 +46,8 @@ export async function insertAllocation( resourceId: params.resourceId, bookingId: params.bookingId, active: true, - startAt: params.startAt, - endAt: params.endAt, + startTime: params.startTime, + endTime: params.endTime, bufferBeforeMs: params.bufferBeforeMs, bufferAfterMs: params.bufferAfterMs, expiresAt: params.expiresAt, diff --git a/apps/server/src/operations/availability/query.ts b/apps/server/src/operations/availability/query.ts index cb1943e..6e9740c 100644 --- a/apps/server/src/operations/availability/query.ts +++ b/apps/server/src/operations/availability/query.ts @@ -7,26 +7,26 @@ import { clampInterval, mergeIntervals, buildTimeline } from "domain/scheduling/ interface BlockingAllocation { resourceId: string; - startAt: Date; - endAt: Date; + startTime: Date; + endTime: Date; } 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(ARRAY[${sql.join(resourceIds.map((id) => sql`${id}`))}]::text[]) AND active = true AND (expires_at IS NULL OR expires_at > clock_timestamp()) - AND start_at < ${endAt} - AND end_at > ${startAt} - ORDER BY resource_id, start_at + AND start_time < ${endTime} + AND end_time > ${startTime} + ORDER BY resource_id, start_time `.execute(db); // Group allocations by resource_id @@ -47,10 +47,10 @@ export default createOperation({ // 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 index 064a8af..d6054cb 100644 --- a/apps/server/src/operations/availability/slots.ts +++ b/apps/server/src/operations/availability/slots.ts @@ -12,18 +12,18 @@ const MAX_SLOTS_RANGE_MS = 7 * 24 * 60 * 60_000; // 7 days export default createOperation({ input: availabilityInput.slots, execute: async (input) => { - const { ledgerId, serviceId, startAt, endAt, durationMs, includeUnavailable } = input; + const { ledgerId, serviceId, startTime, endTime, durationMs, includeUnavailable } = input; // Validate query window - if (endAt.getTime() - startAt.getTime() > MAX_SLOTS_RANGE_MS) { + if (endTime.getTime() - startTime.getTime() > MAX_SLOTS_RANGE_MS) { throw new InputError([ - { code: "custom", message: "Query range exceeds maximum of 7 days", path: ["endAt"] }, + { code: "custom", message: "Query range exceeds maximum of 7 days", path: ["endTime"] }, ]); } - if (endAt <= startAt) { + if (endTime <= startTime) { throw new InputError([ - { code: "custom", message: "endAt must be after startAt", path: ["endAt"] }, + { code: "custom", message: "endTime must be after startTime", path: ["endTime"] }, ]); } @@ -99,15 +99,15 @@ export default createOperation({ const blockingAllocations = targetResourceIds.length > 0 ? 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(ARRAY[${sql.join(targetResourceIds.map((id) => sql`${id}`))}]::text[]) AND active = true AND (expires_at IS NULL OR expires_at > clock_timestamp()) - AND start_at < ${endAt} - AND end_at > ${startAt} - ORDER BY resource_id, start_at + AND start_time < ${endTime} + AND end_time > ${startTime} + ORDER BY resource_id, start_time `.execute(db) : { rows: [] }; @@ -127,7 +127,7 @@ export default createOperation({ const timezone = resource?.timezone ?? "UTC"; const allocs = allocByResource.get(resourceId) ?? []; - const resolvedDays = resolveServiceDays(policy, startAt, endAt, timezone); + const resolvedDays = resolveServiceDays(policy, startTime, endTime, timezone); const slots = generateSlots( resolvedDays, allocs, @@ -135,8 +135,8 @@ export default createOperation({ serverTime, timezone, includeUnavailable, - startAt, - endAt, + startTime, + endTime, ); return { resourceId, timezone, slots }; diff --git a/apps/server/src/operations/availability/windows.ts b/apps/server/src/operations/availability/windows.ts index d50d5c3..5822ef6 100644 --- a/apps/server/src/operations/availability/windows.ts +++ b/apps/server/src/operations/availability/windows.ts @@ -12,18 +12,18 @@ const MAX_WINDOWS_RANGE_MS = 31 * 24 * 60 * 60_000; // 31 days export default createOperation({ input: availabilityInput.windows, execute: async (input) => { - const { ledgerId, serviceId, startAt, endAt, includeUnavailable } = input; + const { ledgerId, serviceId, startTime, endTime, includeUnavailable } = input; // Validate query window - if (endAt.getTime() - startAt.getTime() > MAX_WINDOWS_RANGE_MS) { + if (endTime.getTime() - startTime.getTime() > MAX_WINDOWS_RANGE_MS) { throw new InputError([ - { code: "custom", message: "Query range exceeds maximum of 31 days", path: ["endAt"] }, + { code: "custom", message: "Query range exceeds maximum of 31 days", path: ["endTime"] }, ]); } - if (endAt <= startAt) { + if (endTime <= startTime) { throw new InputError([ - { code: "custom", message: "endAt must be after startAt", path: ["endAt"] }, + { code: "custom", message: "endTime must be after startTime", path: ["endTime"] }, ]); } @@ -99,15 +99,15 @@ export default createOperation({ const blockingAllocations = targetResourceIds.length > 0 ? 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(ARRAY[${sql.join(targetResourceIds.map((id) => sql`${id}`))}]::text[]) AND active = true AND (expires_at IS NULL OR expires_at > clock_timestamp()) - AND start_at < ${endAt} - AND end_at > ${startAt} - ORDER BY resource_id, start_at + AND start_time < ${endTime} + AND end_time > ${startTime} + ORDER BY resource_id, start_time `.execute(db) : { rows: [] }; @@ -127,15 +127,15 @@ export default createOperation({ const timezone = resource?.timezone ?? "UTC"; const allocs = allocByResource.get(resourceId) ?? []; - const resolvedDays = resolveServiceDays(policy, startAt, endAt, timezone); + const resolvedDays = resolveServiceDays(policy, startTime, endTime, timezone); const windows = computeWindows( resolvedDays, allocs, serverTime, timezone, includeUnavailable, - startAt, - endAt, + startTime, + endTime, ); return { resourceId, timezone, windows }; diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index d320d2d..97177d2 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -59,8 +59,8 @@ export default createOperation({ // 5. Policy evaluation (if service has a policy) let holdDurationMs = DEFAULT_HOLD_DURATION_MS; - let startAt = input.startAt; - let endAt = input.endAt; + let startTime = input.startTime; + let endTime = input.endTime; let bufferBeforeMs = 0; let bufferAfterMs = 0; if (service.policyId) { @@ -74,7 +74,7 @@ export default createOperation({ const timezone = resource.timezone ?? "UTC"; const result = evaluatePolicy( policy.config as unknown as PolicyConfig, - { startAt: input.startAt, endAt: input.endAt }, + { startTime: input.startTime, endTime: input.endTime }, { decisionTime: serverTime, timezone }, ); @@ -87,14 +87,14 @@ export default createOperation({ } // Use buffer-expanded times as the allocation's blocked window - startAt = result.effectiveStartAt; - endAt = result.effectiveEndAt; + 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; + if (result.resolvedConfig.hold?.duration_ms !== undefined) { + holdDurationMs = result.resolvedConfig.hold.duration_ms; } } } @@ -118,13 +118,13 @@ export default createOperation({ .returningAll() .executeTakeFirstOrThrow(); - // 8. Conflict check + insert allocation (startAt/endAt = blocked window including buffers) + // 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, - startAt, - endAt, + startTime, + endTime, bufferBeforeMs, bufferAfterMs, expiresAt, diff --git a/apps/server/src/routes/v1/allocations.ts b/apps/server/src/routes/v1/allocations.ts index ee38fc9..536b611 100644 --- a/apps/server/src/routes/v1/allocations.ts +++ b/apps/server/src/routes/v1/allocations.ts @@ -5,7 +5,7 @@ import { idempotent, storeIdempotencyResponse, IdempotencyVariables } from "infr import { serializeAllocation } from "./serializers"; // Significant fields for allocation create idempotency hash -const ALLOCATION_SIGNIFICANT_FIELDS = ["resourceId", "startAt", "endAt", "expiresAt"]; +const ALLOCATION_SIGNIFICANT_FIELDS = ["resourceId", "startTime", "endTime", "expiresAt"]; // Nested under /v1/ledgers/:ledgerId/allocations export const allocations = new Hono<{ Variables: IdempotencyVariables }>() diff --git a/apps/server/src/routes/v1/availability.ts b/apps/server/src/routes/v1/availability.ts index 01039e2..c0610ac 100644 --- a/apps/server/src/routes/v1/availability.ts +++ b/apps/server/src/routes/v1/availability.ts @@ -9,8 +9,8 @@ export const availability = new Hono().post("/", async (c) => { 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 index e017204..bbe072e 100644 --- a/apps/server/src/routes/v1/bookings.ts +++ b/apps/server/src/routes/v1/bookings.ts @@ -5,7 +5,7 @@ import { idempotent, storeIdempotencyResponse, IdempotencyVariables } from "infr import { serializeBooking } from "./serializers"; // Significant fields for booking create idempotency hash -const BOOKING_SIGNIFICANT_FIELDS = ["serviceId", "resourceId", "startAt", "endAt", "status"]; +const BOOKING_SIGNIFICANT_FIELDS = ["serviceId", "resourceId", "startTime", "endTime", "status"]; // Nested under /v1/ledgers/:ledgerId/bookings export const bookings = new Hono<{ Variables: IdempotencyVariables }>() diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index 1529a09..d63381e 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -34,10 +34,12 @@ export function serializeAllocation(allocation: AllocationRow): Allocation { resourceId: allocation.resourceId, bookingId: allocation.bookingId, active: allocation.active, - startAt: allocation.startAt.toISOString(), - endAt: allocation.endAt.toISOString(), - bufferBeforeMs: allocation.bufferBeforeMs, - bufferAfterMs: allocation.bufferAfterMs, + 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(), @@ -98,10 +100,12 @@ export function serializeBooking(booking: BookingRow, allocations: AllocationRow allocations: allocations.map((a) => ({ id: a.id, resourceId: a.resourceId, - startAt: a.startAt.toISOString(), - endAt: a.endAt.toISOString(), - bufferBeforeMs: a.bufferBeforeMs, - bufferAfterMs: a.bufferAfterMs, + startTime: a.startTime.toISOString(), + endTime: a.endTime.toISOString(), + buffer: { + beforeMs: a.bufferBeforeMs, + afterMs: a.bufferAfterMs, + }, active: a.active, })), metadata: booking.metadata, diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 2c41d24..66b9a7b 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -185,11 +185,11 @@ registry.registerPath({ description: "Resource IDs to query", example: ["rsc_01abc123def456ghi789jkl012"], }), - startAt: z.string().datetime().openapi({ + startTime: z.string().datetime().openapi({ description: "Start of the time window (ISO 8601)", example: "2026-01-04T10:00:00Z", }), - endAt: z.string().datetime().openapi({ + endTime: z.string().datetime().openapi({ description: "End of the time window (ISO 8601)", example: "2026-01-04T18:00:00Z", }), @@ -255,8 +255,8 @@ registry.registerPath({ "application/json": { schema: z.object({ resourceId: z.string().openapi({ example: "rsc_01abc123def456ghi789jkl012" }), - startAt: z.string().datetime(), - endAt: z.string().datetime(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), expiresAt: z.string().datetime().nullable().optional().openapi({ description: "If set, the allocation auto-expires after this time", }), @@ -461,12 +461,12 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - startAt: z.string().datetime().openapi({ + startTime: z.string().datetime().openapi({ description: "Start of the query window (ISO 8601)", example: "2026-03-02T00:00:00Z", }), - endAt: z.string().datetime().openapi({ - description: "End of the query window (ISO 8601). Max 7 days from startAt.", + endTime: z.string().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({ @@ -516,12 +516,12 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - startAt: z.string().datetime().openapi({ + startTime: z.string().datetime().openapi({ description: "Start of the query window (ISO 8601)", example: "2026-03-02T00:00:00Z", }), - endAt: z.string().datetime().openapi({ - description: "End of the query window (ISO 8601). Max 31 days from startAt.", + endTime: z.string().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({ @@ -595,8 +595,8 @@ registry.registerPath({ 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 startAt/endAt represent the buffer-expanded blocked window. " + - "The original customer time can be derived using bufferBeforeMs and bufferAfterMs on the 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() }), @@ -606,8 +606,8 @@ registry.registerPath({ schema: z.object({ serviceId: z.string().openapi({ example: "svc_01abc123def456ghi789jkl012" }), resourceId: z.string().openapi({ example: "rsc_01abc123def456ghi789jkl012" }), - startAt: z.string().datetime().openapi({ example: "2026-01-15T10:00:00Z" }), - endAt: z.string().datetime().openapi({ example: "2026-01-15T11:00:00Z" }), + startTime: z.string().datetime().openapi({ example: "2026-01-15T10:00:00Z" }), + endTime: z.string().datetime().openapi({ example: "2026-01-15T11:00:00Z" }), status: z .enum(["hold", "confirmed"]) .default("hold") diff --git a/apps/server/test/integration/setup/factories/allocation.factory.ts b/apps/server/test/integration/setup/factories/allocation.factory.ts index adedfff..bd8bea7 100644 --- a/apps/server/test/integration/setup/factories/allocation.factory.ts +++ b/apps/server/test/integration/setup/factories/allocation.factory.ts @@ -8,8 +8,8 @@ export async function createAllocation(overrides?: { resourceId?: string; active?: boolean; bookingId?: string | null; - startAt?: Date; - endAt?: Date; + 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") @@ -43,8 +43,8 @@ export async function createAllocation(overrides?: { resourceId, bookingId: overrides?.bookingId ?? null, active: overrides?.active ?? true, - startAt, - endAt, + startTime, + endTime, bufferBeforeMs: 0, bufferAfterMs: 0, expiresAt: overrides?.expiresAt ?? null, diff --git a/apps/server/test/integration/setup/factories/booking.factory.ts b/apps/server/test/integration/setup/factories/booking.factory.ts index 3e69168..fc4ded7 100644 --- a/apps/server/test/integration/setup/factories/booking.factory.ts +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -11,8 +11,8 @@ export async function createBooking(overrides?: { status?: "hold" | "confirmed" | "canceled" | "expired"; expiresAt?: Date | null; metadata?: Record | null; - startAt?: Date; - endAt?: Date; + startTime?: Date; + endTime?: Date; }) { let ledgerId = overrides?.ledgerId; let serviceId = overrides?.serviceId; @@ -46,8 +46,8 @@ export async function createBooking(overrides?: { ledgerId = service.ledgerId; } - const startAt = overrides?.startAt ?? faker.date.future(); - const endAt = overrides?.endAt ?? new Date(startAt.getTime() + 60 * 60 * 1000); + 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 @@ -79,8 +79,8 @@ export async function createBooking(overrides?: { resourceId, bookingId: booking.id, active: status === "hold" || status === "confirmed", - startAt, - endAt, + startTime, + endTime, bufferBeforeMs: 0, bufferAfterMs: 0, expiresAt, diff --git a/apps/server/test/integration/v1/allocations/create.spec.ts b/apps/server/test/integration/v1/allocations/create.spec.ts index 4e5614e..a973df5 100644 --- a/apps/server/test/integration/v1/allocations/create.spec.ts +++ b/apps/server/test/integration/v1/allocations/create.spec.ts @@ -6,13 +6,13 @@ import { Allocation } from "@floyd-run/schema/types"; describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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, - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), }); expect(response.status).toBe(201); @@ -22,22 +22,22 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { expect(data.resourceId).toBe(resource.id); expect(data.active).toBe(true); expect(data.bookingId).toBeNull(); - expect(data.startAt).toBe(startAt.toISOString()); - expect(data.endAt).toBe(endAt.toISOString()); + expect(data.startTime).toBe(startTime.toISOString()); + expect(data.endTime).toBe(endTime.toISOString()); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); 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, - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), expiresAt: expiresAt.toISOString(), }); @@ -52,8 +52,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), metadata, }); @@ -70,26 +70,26 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { expect(response.status).toBe(422); }); - it("returns 422 when endAt equals startAt", 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, - startAt: time, - endAt: time, + startTime: time, + endTime: time, }); expect(response.status).toBe(422); }); - it("returns 422 when endAt is before startAt", 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-01T11:00:00Z").toISOString(), - endAt: new Date("2026-02-01T10:00:00Z").toISOString(), + startTime: new Date("2026-02-01T11:00:00Z").toISOString(), + endTime: new Date("2026-02-01T10:00:00Z").toISOString(), }); expect(response.status).toBe(422); @@ -100,8 +100,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: "rsc_00000000000000000000000000", - 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); @@ -114,16 +114,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, - 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, - 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); @@ -138,8 +138,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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, - 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); @@ -147,8 +147,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // Try to create overlapping allocation const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - 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); @@ -161,8 +161,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { const expiresAt = new Date(Date.now() - 1000); // 1 second ago const first = 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(), + 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); @@ -170,8 +170,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // Should succeed because the previous allocation expired const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - 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); @@ -183,16 +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, - 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, - 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); @@ -205,16 +205,16 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { // Create allocation on resource1 const first = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource1.id, - 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, - 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); @@ -229,15 +229,15 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { resourceId: resource.id, ledgerId, active: false, - startAt: new Date("2026-02-01T10:00:00Z"), - endAt: new Date("2026-02-01T11:00:00Z"), + startTime: new Date("2026-02-01T10:00:00Z"), + endTime: new Date("2026-02-01T11:00:00Z"), }); // Should succeed because inactive allocations don't block const response = await client.post(`/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - 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); @@ -248,8 +248,8 @@ describe("POST /v1/ledgers/:ledgerId/allocations", () => { 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(), + 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/idempotency.spec.ts b/apps/server/test/integration/v1/allocations/idempotency.spec.ts index e6ed230..f43907f 100644 --- a/apps/server/test/integration/v1/allocations/idempotency.spec.ts +++ b/apps/server/test/integration/v1/allocations/idempotency.spec.ts @@ -11,8 +11,8 @@ describe("Idempotency", () => { const body = { resourceId: resource.id, - 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 @@ -42,8 +42,8 @@ describe("Idempotency", () => { `/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(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }, { headers: { "Idempotency-Key": idempotencyKey } }, ); @@ -54,8 +54,8 @@ describe("Idempotency", () => { `/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - 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 } }, ); @@ -73,8 +73,8 @@ describe("Idempotency", () => { `/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(), + startTime: new Date("2026-02-01T10:00:00Z").toISOString(), + endTime: new Date("2026-02-01T11:00:00Z").toISOString(), }, { headers: { "Idempotency-Key": `key1-${Date.now()}` } }, ); @@ -86,8 +86,8 @@ describe("Idempotency", () => { `/v1/ledgers/${ledgerId}/allocations`, { resourceId: resource.id, - 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()}` } }, ); @@ -104,8 +104,8 @@ describe("Idempotency", () => { const basePayload = { resourceId: resource.id, - 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 @@ -133,8 +133,8 @@ describe("Idempotency", () => { 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(), + 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/availability/query.spec.ts b/apps/server/test/integration/v1/availability/query.spec.ts index 1df82e3..2372c33 100644 --- a/apps/server/test/integration/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,7 +22,7 @@ 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 active allocation", async () => { @@ -36,26 +36,38 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: allocStart, - endAt: allocEnd, + 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", + }, ]); }); @@ -71,18 +83,18 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: allocStart, - endAt: allocEnd, + 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); @@ -104,25 +116,25 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: allocStart, - endAt: allocEnd, + 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 allocation should not block - entire window is free - expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + expect(data[0]!.timeline).toEqual([{ startTime, endTime, status: "free" }]); }); it("ignores inactive allocations", async () => { @@ -133,23 +145,23 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: false, - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + 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 () => { @@ -161,24 +173,24 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + 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, active: true, - startAt: new Date("2026-01-01T10:45:00.000Z"), - endAt: new Date("2026-01-01T11:15:00.000Z"), + 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", + }, ]); }); @@ -201,24 +225,24 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + 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, active: true, - startAt: new Date("2026-01-01T11:00:00.000Z"), - endAt: new Date("2026-01-01T11:30:00.000Z"), + 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", + }, ]); }); @@ -241,24 +277,24 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-01-01T09:00:00.000Z"), - endAt: new Date("2026-01-01T13:00:00.000Z"), + 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 () => { @@ -271,17 +307,17 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource1.id, active: true, - startAt: new Date("2026-01-01T10:30:00.000Z"), - endAt: new Date("2026-01-01T11:00:00.000Z"), + 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 () => { @@ -307,22 +343,22 @@ describe("POST /v1/ledgers/:ledgerId/availability", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-01-01T08:00:00.000Z"), - endAt: new Date("2026-01-01T09:00:00.000Z"), + 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 index 1bbbfce..ef9ce09 100644 --- a/apps/server/test/integration/v1/availability/slots.spec.ts +++ b/apps/server/test/integration/v1/availability/slots.spec.ts @@ -13,7 +13,7 @@ interface SlotsResponse { data: { resourceId: string; timezone: string; - slots: { startAt: string; endAt: string; status?: "available" | "unavailable" }[]; + slots: { startTime: string; endTime: string; status?: "available" | "unavailable" }[]; }[]; meta: { serverTime: string }; } @@ -58,8 +58,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 3600000, // 60 min }, ); @@ -75,10 +75,10 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { // 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]!.startAt).toBe("2026-03-02T09:00:00.000Z"); - expect(slots[0]!.endAt).toBe("2026-03-02T10:00:00.000Z"); - expect(slots[slots.length - 1]!.startAt).toBe("2026-03-02T16:00:00.000Z"); - expect(slots[slots.length - 1]!.endAt).toBe("2026-03-02T17:00:00.000Z"); + 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"); @@ -91,8 +91,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 5400000, // 90 min }, ); @@ -102,13 +102,13 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const slots = body.data[0]!.slots; // First slot 09:00-10:30, second 09:30-11:00 — they overlap - expect(slots[0]!.startAt).toBe("2026-03-02T09:00:00.000Z"); - expect(slots[0]!.endAt).toBe("2026-03-02T10:30:00.000Z"); - expect(slots[1]!.startAt).toBe("2026-03-02T09:30:00.000Z"); - expect(slots[1]!.endAt).toBe("2026-03-02T11:00:00.000Z"); + 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]!.startAt).toBe("2026-03-02T15:30:00.000Z"); + expect(slots[slots.length - 1]!.startTime).toBe("2026-03-02T15:30:00.000Z"); }); it("no grid: step defaults to durationMs, non-overlapping", async () => { @@ -143,8 +143,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", // Monday - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", // Monday + endTime: "2026-03-02T23:59:59Z", durationMs: 3600000, // 60 min }, ); @@ -155,10 +155,10 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { // No grid → step = durationMs = 60min. Slots at 09:00, 10:00, 11:00, 12:00 expect(slots).toHaveLength(4); - expect(slots[0]!.startAt).toBe("2026-03-02T09:00:00.000Z"); - expect(slots[1]!.startAt).toBe("2026-03-02T10:00:00.000Z"); - expect(slots[2]!.startAt).toBe("2026-03-02T11:00:00.000Z"); - expect(slots[3]!.startAt).toBe("2026-03-02T12:00:00.000Z"); + 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 () => { @@ -203,8 +203,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-06T00:00:00Z", - endAt: "2026-03-08T00:00:00Z", + startTime: "2026-03-06T00:00:00Z", + endTime: "2026-03-08T00:00:00Z", durationMs: 5400000, // 90 min }, ); @@ -214,8 +214,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { 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.startAt.startsWith("2026-03-06")); - const saturdaySlots = slots.filter((s) => s.startAt.startsWith("2026-03-07")); + 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); @@ -229,15 +229,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-03-02T10:00:00Z"), - endAt: new Date("2026-03-02T11:10:00Z"), // buffer-expanded + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, // 30 min }, ); @@ -252,7 +252,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { // 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.startAt); + 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"); @@ -268,15 +268,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-03-02T10:00:00Z"), - endAt: new Date("2026-03-02T11:10:00Z"), + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, includeUnavailable: true, }, @@ -292,12 +292,12 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { } // Conflicting slots should be unavailable - const slot1000 = slots.find((s) => s.startAt === "2026-03-02T10:00:00.000Z"); + 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.startAt === "2026-03-02T09:00:00.000Z"); + const slot0900 = slots.find((s) => s.startTime === "2026-03-02T09:00:00.000Z"); expect(slot0900).toBeDefined(); expect(slot0900!.status).toBe("available"); }); @@ -319,15 +319,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { ledgerId: ledger.id, resourceId: r1.id, active: true, - startAt: new Date("2026-03-02T10:00:00Z"), - endAt: new Date("2026-03-02T11:10:00Z"), + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, }, ); @@ -358,8 +358,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, resourceIds: [r1.id], }, @@ -379,8 +379,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, resourceIds: [outsideResource.id], }, @@ -402,8 +402,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T10:00:00Z", - endAt: "2026-03-02T12:00:00Z", + startTime: "2026-03-02T10:00:00Z", + endTime: "2026-03-02T12:00:00Z", durationMs: 1800000, // 30 min }, ); @@ -414,8 +414,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { // No grid → step = 30min. Window 10:00-12:00 → 4 slots expect(slots).toHaveLength(4); - expect(slots[0]!.startAt).toBe("2026-03-02T10:00:00.000Z"); - expect(slots[3]!.startAt).toBe("2026-03-02T11:30:00.000Z"); + 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 () => { @@ -424,8 +424,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-10T00:00:00Z", // 8 days + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-10T00:00:00Z", // 8 days durationMs: 1800000, }, ); @@ -439,8 +439,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, }, ); @@ -467,8 +467,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, }, ); @@ -485,8 +485,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/slots`, { - startAt: "2026-03-01T00:00:00Z", - endAt: "2026-03-01T23:59:59Z", + startTime: "2026-03-01T00:00:00Z", + endTime: "2026-03-01T23:59:59Z", durationMs: 1800000, }, ); @@ -503,8 +503,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${fakeServiceId}/availability/slots`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", durationMs: 1800000, }, ); diff --git a/apps/server/test/integration/v1/availability/windows.spec.ts b/apps/server/test/integration/v1/availability/windows.spec.ts index f1bc37b..73dc1a7 100644 --- a/apps/server/test/integration/v1/availability/windows.spec.ts +++ b/apps/server/test/integration/v1/availability/windows.spec.ts @@ -13,7 +13,7 @@ interface WindowsResponse { data: { resourceId: string; timezone: string; - windows: { startAt: string; endAt: string; status?: "available" | "unavailable" }[]; + windows: { startTime: string; endTime: string; status?: "available" | "unavailable" }[]; }[]; meta: { serverTime: string }; } @@ -56,8 +56,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); @@ -71,8 +71,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { // Full day open 09:00-17:00, no allocations const windows = body.data[0]!.windows; expect(windows).toHaveLength(1); - expect(windows[0]!.startAt).toBe("2026-03-02T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-02T17:00:00.000Z"); + 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"); }); @@ -84,15 +84,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-03-02T10:00:00Z"), - endAt: new Date("2026-03-02T13:30:00Z"), + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); @@ -110,8 +110,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { // 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]!.startAt).toBe("2026-03-02T13:30:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-02T17:00:00.000Z"); + 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 () => { @@ -149,15 +149,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-03-02T09:45:00Z"), - endAt: new Date("2026-03-02T11:10:00Z"), + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); @@ -174,10 +174,10 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { // End at schedule boundary → no shrinkage: 17:00 // Available: [11:25, 17:00) expect(windows).toHaveLength(2); - expect(windows[0]!.startAt).toBe("2026-03-02T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-02T09:35:00.000Z"); - expect(windows[1]!.startAt).toBe("2026-03-02T11:25:00.000Z"); - expect(windows[1]!.endAt).toBe("2026-03-02T17:00:00.000Z"); + 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 () => { @@ -188,22 +188,22 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-03-02T09:00:00Z"), - endAt: new Date("2026-03-02T12:00:00Z"), + 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, - startAt: new Date("2026-03-02T13:00:00Z"), - endAt: new Date("2026-03-02T17:00:00Z"), + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); @@ -241,8 +241,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-04T00:00:00Z", // 2 days + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-04T00:00:00Z", // 2 days }, ); @@ -252,8 +252,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { // Should be one contiguous 48-hour window expect(windows).toHaveLength(1); - expect(windows[0]!.startAt).toBe("2026-03-02T00:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-04T00:00:00.000Z"); + 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 () => { @@ -263,15 +263,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-03-02T10:00:00Z"), - endAt: new Date("2026-03-02T13:30:00Z"), + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", includeUnavailable: true, }, ); @@ -288,8 +288,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { // 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]!.startAt).toBe("2026-03-02T10:00:00.000Z"); - expect(unavailable[0]!.endAt).toBe("2026-03-02T13:30:00.000Z"); + 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 () => { @@ -309,15 +309,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, resourceId: r1.id, active: true, - startAt: new Date("2026-03-02T10:00:00Z"), - endAt: new Date("2026-03-02T15:00:00Z"), + 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`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); @@ -329,8 +329,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { // r2 should have full 09:00-17:00 window expect(r2Data.windows).toHaveLength(1); - expect(r2Data.windows[0]!.startAt).toBe("2026-03-02T09:00:00.000Z"); - expect(r2Data.windows[0]!.endAt).toBe("2026-03-02T17:00:00.000Z"); + 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); @@ -349,15 +349,15 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-03-02T10:00:00Z"), - endAt: new Date("2026-03-02T12:00:00Z"), + 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`, { - startAt: "2026-03-02T08:00:00Z", - endAt: "2026-03-02T16:00:00Z", + startTime: "2026-03-02T08:00:00Z", + endTime: "2026-03-02T16:00:00Z", }, ); @@ -367,10 +367,10 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { // Two windows: 08:00-10:00 and 12:00-16:00 expect(windows).toHaveLength(2); - expect(windows[0]!.startAt).toBe("2026-03-02T08:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-02T10:00:00.000Z"); - expect(windows[1]!.startAt).toBe("2026-03-02T12:00:00.000Z"); - expect(windows[1]!.endAt).toBe("2026-03-02T16:00:00.000Z"); + 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 () => { @@ -379,8 +379,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, { - startAt: "2026-03-01T00:00:00Z", - endAt: "2026-04-02T00:00:00Z", // 32 days + startTime: "2026-03-01T00:00:00Z", + endTime: "2026-04-02T00:00:00Z", // 32 days }, ); @@ -393,8 +393,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); @@ -420,8 +420,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${service.id}/availability/windows`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); @@ -437,8 +437,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const response = await client.post( `/v1/ledgers/${ledger.id}/services/${fakeServiceId}/availability/windows`, { - startAt: "2026-03-02T00:00:00Z", - endAt: "2026-03-02T23:59:59Z", + startTime: "2026-03-02T00:00:00Z", + endTime: "2026-03-02T23:59:59Z", }, ); diff --git a/apps/server/test/integration/v1/bookings/cancel.spec.ts b/apps/server/test/integration/v1/bookings/cancel.spec.ts index f02a0b5..084ed2f 100644 --- a/apps/server/test/integration/v1/bookings/cancel.spec.ts +++ b/apps/server/test/integration/v1/bookings/cancel.spec.ts @@ -11,8 +11,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/cancel", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "hold", }); diff --git a/apps/server/test/integration/v1/bookings/confirm.spec.ts b/apps/server/test/integration/v1/bookings/confirm.spec.ts index ebc353f..f068ca7 100644 --- a/apps/server/test/integration/v1/bookings/confirm.spec.ts +++ b/apps/server/test/integration/v1/bookings/confirm.spec.ts @@ -11,8 +11,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/confirm", () => { const response = await client.post(`/v1/ledgers/${ledgerId}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "hold", }); diff --git a/apps/server/test/integration/v1/bookings/create.spec.ts b/apps/server/test/integration/v1/bookings/create.spec.ts index 7f68a04..9130fc0 100644 --- a/apps/server/test/integration/v1/bookings/create.spec.ts +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -18,14 +18,14 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { resourceIds: [resource.id], }); - const startAt = "2026-06-01T10:00:00.000Z"; - const endAt = "2026-06-01T11:00:00.000Z"; + 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, - startAt, - endAt, + startTime, + endTime, }); expect(response.status).toBe(201); @@ -40,8 +40,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(data.expiresAt).toBeDefined(); expect(data.allocations).toHaveLength(1); expect(data.allocations[0]!.resourceId).toBe(resource.id); - expect(data.allocations[0]!.startAt).toBe(startAt); - expect(data.allocations[0]!.endAt).toBe(endAt); + 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(); @@ -59,8 +59,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "confirmed", }); @@ -83,8 +83,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", metadata, }); @@ -101,7 +101,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(response.status).toBe(422); }); - it("returns 422 when endAt equals startAt", async () => { + it("returns 422 when endTime equals startTime", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); const { service } = await createService({ @@ -113,14 +113,14 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: time, - endAt: time, + startTime: time, + endTime: time, }); expect(response.status).toBe(422); }); - it("returns 422 when endAt is before startAt", async () => { + it("returns 422 when endTime is before startTime", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); const { service } = await createService({ @@ -131,8 +131,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T11:00:00.000Z", - endAt: "2026-06-01T10:00:00.000Z", + startTime: "2026-06-01T11:00:00.000Z", + endTime: "2026-06-01T10:00:00.000Z", }); expect(response.status).toBe(422); @@ -145,8 +145,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: "svc_00000000000000000000000000", resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", }); expect(response.status).toBe(404); @@ -159,8 +159,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: "rsc_00000000000000000000000000", - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", }); expect(response.status).toBe(404); @@ -178,8 +178,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: r2.id, // r2 is NOT in the service - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", }); expect(response.status).toBe(409); @@ -201,16 +201,16 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { ledgerId: ledger.id, resourceId: resource.id, active: true, - startAt: new Date("2026-06-01T10:00:00.000Z"), - endAt: new Date("2026-06-01T11:00:00.000Z"), + 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, - startAt: "2026-06-01T10:30:00.000Z", - endAt: "2026-06-01T11:30:00.000Z", + startTime: "2026-06-01T10:30:00.000Z", + endTime: "2026-06-01T11:30:00.000Z", }); expect(response.status).toBe(409); @@ -230,8 +230,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const first = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "confirmed", }); expect(first.status).toBe(201); @@ -240,8 +240,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:30:00.000Z", - endAt: "2026-06-01T11:30:00.000Z", + startTime: "2026-06-01T10:30:00.000Z", + endTime: "2026-06-01T11:30:00.000Z", }); expect(response.status).toBe(409); @@ -260,8 +260,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const first = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "confirmed", }); expect(first.status).toBe(201); @@ -269,8 +269,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T11:00:00.000Z", - endAt: "2026-06-01T12:00:00.000Z", + startTime: "2026-06-01T11:00:00.000Z", + endTime: "2026-06-01T12:00:00.000Z", status: "confirmed", }); @@ -290,16 +290,16 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { ledgerId: ledger.id, resourceId: resource.id, active: false, - startAt: new Date("2026-06-01T10:00:00.000Z"), - endAt: new Date("2026-06-01T11:00:00.000Z"), + 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, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", }); expect(response.status).toBe(201); @@ -307,7 +307,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { }); describe("buffers", () => { - it("stores buffer-expanded times as allocation startAt/endAt", async () => { + 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({ @@ -329,8 +329,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "confirmed", }); @@ -338,13 +338,13 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const { data } = (await response.json()) as { data: Booking }; const allocation = data.allocations[0]!; - // Allocation startAt/endAt = buffer-expanded blocked window - expect(allocation.startAt).toBe("2026-06-01T09:45:00.000Z"); // 10:00 - 15min - expect(allocation.endAt).toBe("2026-06-01T11:10:00.000Z"); // 11:00 + 10min + // 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.bufferBeforeMs).toBe(900_000); - expect(allocation.bufferAfterMs).toBe(600_000); + expect(allocation.buffer.beforeMs).toBe(900_000); + expect(allocation.buffer.afterMs).toBe(600_000); }); it("stores zero buffers when no policy", async () => { @@ -355,14 +355,14 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { resourceIds: [resource.id], }); - const startAt = "2026-06-01T10:00:00.000Z"; - const endAt = "2026-06-01T11:00:00.000Z"; + 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, - startAt, - endAt, + startTime, + endTime, status: "confirmed", }); @@ -371,10 +371,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const allocation = data.allocations[0]!; // Without policy, allocation times = input times (no buffers) - expect(allocation.startAt).toBe(startAt); - expect(allocation.endAt).toBe(endAt); - expect(allocation.bufferBeforeMs).toBe(0); - expect(allocation.bufferAfterMs).toBe(0); + 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 () => { @@ -400,8 +400,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const first = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "confirmed", }); expect(first.status).toBe(201); @@ -411,8 +411,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T11:00:00.000Z", - endAt: "2026-06-01T12:00:00.000Z", + startTime: "2026-06-01T11:00:00.000Z", + endTime: "2026-06-01T12:00:00.000Z", status: "confirmed", }); @@ -444,8 +444,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const first = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T10:00:00.000Z", - endAt: "2026-06-01T11:00:00.000Z", + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", status: "confirmed", }); expect(first.status).toBe(201); @@ -455,8 +455,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { serviceId: service.id, resourceId: resource.id, - startAt: "2026-06-01T11:30:00.000Z", - endAt: "2026-06-01T12:30:00.000Z", + startTime: "2026-06-01T11:30:00.000Z", + endTime: "2026-06-01T12:30:00.000Z", status: "confirmed", }); diff --git a/apps/server/test/integration/v1/resources/delete.spec.ts b/apps/server/test/integration/v1/resources/delete.spec.ts index b3c2003..d68b41d 100644 --- a/apps/server/test/integration/v1/resources/delete.spec.ts +++ b/apps/server/test/integration/v1/resources/delete.spec.ts @@ -32,8 +32,8 @@ describe("DELETE /v1/ledgers/:ledgerId/resources/:id", () => { ledgerId, resourceId: resource.id, active: true, - startAt: new Date("2026-06-01T10:00:00Z"), - endAt: new Date("2026-06-01T11:00:00Z"), + 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}`); diff --git a/apps/server/test/unit/domain/policy/evaluate.spec.ts b/apps/server/test/unit/domain/policy/evaluate.spec.ts index e16b9bf..37d0278 100644 --- a/apps/server/test/unit/domain/policy/evaluate.spec.ts +++ b/apps/server/test/unit/domain/policy/evaluate.spec.ts @@ -32,8 +32,8 @@ function makePolicy(overrides: Partial = {}): PolicyConfig { /** Shorthand for a booking request. */ function makeRequest(startISO: string, endISO: string): EvaluationInput { return { - startAt: new Date(startISO), - endAt: new Date(endISO), + startTime: new Date(startISO), + endTime: new Date(endISO), }; } @@ -877,7 +877,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking within lead time is rejected", () => { const policy = makePolicy({ config: { - booking_window: { min_lead_time_ms: 2 * HOUR }, + lead_time: { min_ms: 2 * HOUR }, }, }); // Decision at 08:00, booking at 09:00, only 1h lead time, need 2h @@ -887,13 +887,13 @@ describe("Steps 7-8: Lead time + horizon", () => { const result = evaluatePolicy(policy, request, context); expectDenied(result, REASON_CODES.LEAD_TIME_VIOLATION); expect(result.details?.["leadTimeMs"]).toBe(1 * HOUR); - expect(result.details?.["min_lead_time_ms"]).toBe(2 * HOUR); + expect(result.details?.["min_ms"]).toBe(2 * HOUR); }); it("booking beyond horizon is rejected", () => { const policy = makePolicy({ config: { - booking_window: { max_lead_time_ms: 7 * 24 * HOUR }, + 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 @@ -907,9 +907,9 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking within both lead time and horizon is allowed", () => { const policy = makePolicy({ config: { - booking_window: { - min_lead_time_ms: 1 * HOUR, - max_lead_time_ms: 30 * 24 * HOUR, + lead_time: { + min_ms: 1 * HOUR, + max_ms: 30 * 24 * HOUR, }, }, }); @@ -924,7 +924,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking exactly at min_lead_time boundary is allowed", () => { const policy = makePolicy({ config: { - booking_window: { min_lead_time_ms: 2 * HOUR }, + lead_time: { min_ms: 2 * HOUR }, }, }); // Decision at 08:00, booking at 10:00, exactly 2h lead time @@ -938,7 +938,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking exactly at max_lead_time boundary is allowed", () => { const policy = makePolicy({ config: { - booking_window: { max_lead_time_ms: 7 * 24 * HOUR }, + lead_time: { max_ms: 7 * 24 * HOUR }, }, }); // Decision at 08:00 on Mar 16, booking at exactly 7 days later @@ -949,7 +949,7 @@ describe("Steps 7-8: Lead time + horizon", () => { expectAllowed(result); }); - it("no booking_window config means no lead time or horizon enforcement", () => { + 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"); @@ -962,9 +962,9 @@ describe("Steps 7-8: Lead time + horizon", () => { it("lead time check runs before horizon check (correct order)", () => { const policy = makePolicy({ config: { - booking_window: { - min_lead_time_ms: 2 * HOUR, - max_lead_time_ms: 7 * 24 * HOUR, + lead_time: { + min_ms: 2 * HOUR, + max_ms: 7 * 24 * HOUR, }, }, }); @@ -996,8 +996,8 @@ describe("Step 9: Buffers", () => { expect(result.bufferBeforeMs).toBe(10 * MINUTE); expect(result.bufferAfterMs).toBe(15 * MINUTE); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T08:50:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:15:00.000Z"); + 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", () => { @@ -1010,8 +1010,8 @@ describe("Step 9: Buffers", () => { expect(result.bufferBeforeMs).toBe(0); expect(result.bufferAfterMs).toBe(0); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T09:00:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + 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", () => { @@ -1028,8 +1028,8 @@ describe("Step 9: Buffers", () => { expect(result.bufferBeforeMs).toBe(5 * MINUTE); expect(result.bufferAfterMs).toBe(0); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T08:55:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + 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", () => { @@ -1046,8 +1046,8 @@ describe("Step 9: Buffers", () => { expect(result.bufferBeforeMs).toBe(0); expect(result.bufferAfterMs).toBe(10 * MINUTE); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T09:00:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:10:00.000Z"); + expect(result.effectiveStartTime.toISOString()).toBe("2026-03-16T09:00:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:10:00.000Z"); }); }); @@ -1063,9 +1063,9 @@ describe("Common patterns", () => { config: { duration: { allowed_ms: [30 * MINUTE, 60 * MINUTE] }, grid: { interval_ms: 30 * MINUTE }, - booking_window: { - min_lead_time_ms: 1 * HOUR, - max_lead_time_ms: 14 * 24 * HOUR, + lead_time: { + min_ms: 1 * HOUR, + max_ms: 14 * 24 * HOUR, }, buffers: { before_ms: 0, after_ms: 10 * MINUTE }, }, @@ -1084,7 +1084,7 @@ describe("Common patterns", () => { const result = evaluatePolicy(salonPolicy, request, context); expectAllowed(result); expect(result.bufferAfterMs).toBe(10 * MINUTE); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:40:00.000Z"); + expect(result.effectiveEndTime.toISOString()).toBe("2026-03-16T10:40:00.000Z"); }); it("allows 60-min appointment at 09:30 on Wednesday", () => { @@ -1211,7 +1211,7 @@ describe("Common patterns", () => { default: "open", config: { duration: { min_ms: 24 * HOUR, max_ms: 7 * 24 * HOUR }, - booking_window: { min_lead_time_ms: 24 * HOUR }, + lead_time: { min_ms: 24 * HOUR }, }, rules: [ { @@ -1254,9 +1254,9 @@ describe("Common patterns", () => { duration: { allowed_ms: [60 * MINUTE] }, grid: { interval_ms: 90 * MINUTE }, buffers: { before_ms: 15 * MINUTE, after_ms: 15 * MINUTE }, - booking_window: { - min_lead_time_ms: 2 * HOUR, - max_lead_time_ms: 30 * 24 * HOUR, + lead_time: { + min_ms: 2 * HOUR, + max_ms: 30 * 24 * HOUR, }, }, rules: [ @@ -1284,8 +1284,8 @@ describe("Common patterns", () => { expect(result.bufferBeforeMs).toBe(15 * MINUTE); expect(result.bufferAfterMs).toBe(15 * MINUTE); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T10:15:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T11:45:00.000Z"); + 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)", () => { @@ -1327,7 +1327,7 @@ describe("Admin override (skipPolicy)", () => { config: { duration: { allowed_ms: [60 * MINUTE] }, grid: { interval_ms: 30 * MINUTE }, - booking_window: { min_lead_time_ms: 24 * HOUR }, + lead_time: { min_ms: 24 * HOUR }, }, rules: [ { @@ -1362,8 +1362,8 @@ describe("Admin override (skipPolicy)", () => { expect(result.bufferBeforeMs).toBe(10 * MINUTE); expect(result.bufferAfterMs).toBe(5 * MINUTE); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T08:50:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:05:00.000Z"); + 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", () => { @@ -1376,8 +1376,8 @@ describe("Admin override (skipPolicy)", () => { expect(result.bufferBeforeMs).toBe(0); expect(result.bufferAfterMs).toBe(0); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T09:00:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + 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)", () => { @@ -1541,7 +1541,7 @@ describe("Edge cases", () => { const policy = makePolicy({ config: { grid: { interval_ms: 60 * MINUTE }, - booking_window: { min_lead_time_ms: 24 * HOUR }, + lead_time: { min_ms: 24 * HOUR }, }, }); // Misaligned start (09:15) and too soon -- should get misaligned_start_time @@ -1574,8 +1574,8 @@ describe("Edge cases", () => { expect(result.bufferBeforeMs).toBe(30 * MINUTE); expect(result.bufferAfterMs).toBe(0); - expect(result.effectiveStartAt.toISOString()).toBe("2026-03-16T08:30:00.000Z"); - expect(result.effectiveEndAt.toISOString()).toBe("2026-03-16T10:00:00.000Z"); + 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)", () => { @@ -1671,12 +1671,12 @@ describe("Edge cases", () => { expectAllowed(result); }); - // ─── hold_duration_ms resolution ───────────────────────────────────────── + // ─── hold resolution ───────────────────────────────────────────────────── - it("resolvedConfig includes hold_duration_ms from base config", () => { + it("resolvedConfig includes hold from base config", () => { const policy = makePolicy({ config: { - hold_duration_ms: 5 * MINUTE, + hold: { duration_ms: 5 * MINUTE }, }, }); @@ -1684,20 +1684,20 @@ describe("Edge cases", () => { const result = evaluatePolicy(policy, request, makeContext()); expectAllowed(result); - expect(result.resolvedConfig.hold_duration_ms).toBe(5 * MINUTE); + expect(result.resolvedConfig.hold).toEqual({ duration_ms: 5 * MINUTE }); }); - it("resolvedConfig hold_duration_ms is overridden by matching rule config", () => { + it("resolvedConfig hold is overridden by matching rule config", () => { const policy = makePolicy({ default: "open", config: { - hold_duration_ms: 5 * MINUTE, + hold: { duration_ms: 5 * MINUTE }, }, rules: [ { match: { type: "weekly", days: ["monday"] }, config: { - hold_duration_ms: 10 * MINUTE, + hold: { duration_ms: 10 * MINUTE }, }, }, ], @@ -1708,16 +1708,16 @@ describe("Edge cases", () => { const result = evaluatePolicy(policy, request, makeContext()); expectAllowed(result); - expect(result.resolvedConfig.hold_duration_ms).toBe(10 * MINUTE); + expect(result.resolvedConfig.hold).toEqual({ duration_ms: 10 * MINUTE }); }); - it("resolvedConfig hold_duration_ms is undefined when not configured", () => { + 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_duration_ms).toBeUndefined(); + 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 index 7ac8ac1..a4d8744 100644 --- a/apps/server/test/unit/domain/scheduling/availability.spec.ts +++ b/apps/server/test/unit/domain/scheduling/availability.spec.ts @@ -344,9 +344,9 @@ describe("generateSlots", () => { // 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(slots[4]!.startAt).toBe("2026-03-16T11:00:00.000Z"); - expect(slots[4]!.endAt).toBe("2026-03-16T12:00:00.000Z"); + 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("no grid → step = durationMs (non-overlapping)", () => { @@ -354,8 +354,8 @@ describe("generateSlots", () => { const slots = generateSlots([day], [], HOUR, PAST, "UTC", false, Q.start, Q.end); expect(slots).toHaveLength(4); - expect(slots[0]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(slots[3]!.startAt).toBe("2026-03-16T12:00:00.000Z"); + 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", () => { @@ -382,12 +382,12 @@ describe("generateSlots", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T10:00:00Z"), - endAt: new Date("2026-03-16T11:00:00Z"), + 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.startAt); + 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"); @@ -402,12 +402,12 @@ describe("generateSlots", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T10:00:00Z"), - endAt: new Date("2026-03-16T11:00:00Z"), + 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.startAt); + 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"); @@ -423,12 +423,12 @@ describe("generateSlots", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T09:50:00Z"), - endAt: new Date("2026-03-16T10:00:00Z"), + 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.startAt); + 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"); @@ -438,7 +438,7 @@ describe("generateSlots", () => { it("lead time filtering excludes too-close slots", () => { const day = makeDay("2026-03-16", [[9 * HOUR, 12 * HOUR]], { - booking_window: { min_lead_time_ms: 2 * 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); @@ -449,11 +449,11 @@ describe("generateSlots", () => { it("horizon filtering excludes too-far slots", () => { const day = makeDay("2026-03-16", [[9 * HOUR, 13 * HOUR]], { - booking_window: { max_lead_time_ms: 2 * 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.startAt); + 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 @@ -466,8 +466,8 @@ describe("generateSlots", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T10:00:00Z"), - endAt: new Date("2026-03-16T11:00:00Z"), + 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); @@ -483,8 +483,8 @@ describe("generateSlots", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T10:00:00Z"), - endAt: new Date("2026-03-16T11:00:00Z"), + 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); @@ -502,8 +502,8 @@ describe("generateSlots", () => { // Only 10:00-11:00 and 11:00-12:00 fit expect(slots).toHaveLength(2); - expect(slots[0]!.startAt).toBe("2026-03-16T10:00:00.000Z"); - expect(slots[1]!.startAt).toBe("2026-03-16T11:00:00.000Z"); + 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", () => { @@ -515,8 +515,8 @@ describe("generateSlots", () => { const slots = generateSlots(days, [], HOUR, PAST, "UTC", false, Q.start, qEnd); expect(slots).toHaveLength(4); - expect(slots[0]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(slots[2]!.startAt).toBe("2026-03-17T09:00:00.000Z"); + 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", () => { @@ -527,8 +527,8 @@ describe("generateSlots", () => { 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.startAt.startsWith("2026-03-16")); - const day2 = slots.filter((s) => s.startAt.startsWith("2026-03-17")); + 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); @@ -549,8 +549,8 @@ describe("computeWindows", () => { const windows = computeWindows([day], [], PAST, "UTC", false, Q.start, Q.end); expect(windows).toHaveLength(1); - expect(windows[0]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + 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", () => { @@ -558,17 +558,17 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T12:00:00Z"), - endAt: new Date("2026-03-16T13:00:00Z"), + 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T12:00:00.000Z"); - expect(windows[1]!.startAt).toBe("2026-03-16T13:00:00.000Z"); - expect(windows[1]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + 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("asymmetric buffer shrinkage at allocation boundaries", () => { @@ -578,8 +578,8 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T09:45:00Z"), - endAt: new Date("2026-03-16T11:10:00Z"), + 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); @@ -587,10 +587,10 @@ describe("computeWindows", () => { // 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T09:35:00.000Z"); - expect(windows[1]!.startAt).toBe("2026-03-16T11:25:00.000Z"); - expect(windows[1]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + 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", () => { @@ -601,8 +601,8 @@ describe("computeWindows", () => { // No allocations → no adjacent allocations → no shrinkage expect(windows).toHaveLength(1); - expect(windows[0]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + 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", () => { @@ -612,13 +612,13 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T09:00:00Z"), - endAt: new Date("2026-03-16T11:00:00Z"), + startTime: new Date("2026-03-16T09:00:00Z"), + endTime: new Date("2026-03-16T11:00:00Z"), }, { resourceId: "r", - startAt: new Date("2026-03-16T14:00:00Z"), - endAt: new Date("2026-03-16T17:00:00Z"), + 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); @@ -627,8 +627,8 @@ describe("computeWindows", () => { // 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]!.startAt).toBe("2026-03-16T11:15:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T13:50:00.000Z"); + 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", () => { @@ -638,13 +638,13 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T09:00:00Z"), - endAt: new Date("2026-03-16T12:00:00Z"), + startTime: new Date("2026-03-16T09:00:00Z"), + endTime: new Date("2026-03-16T12:00:00Z"), }, { resourceId: "r", - startAt: new Date("2026-03-16T12:30:00Z"), - endAt: new Date("2026-03-16T17:00:00Z"), + 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); @@ -658,13 +658,13 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T09:00:00Z"), - endAt: new Date("2026-03-16T12:00:00Z"), + startTime: new Date("2026-03-16T09:00:00Z"), + endTime: new Date("2026-03-16T12:00:00Z"), }, { resourceId: "r", - startAt: new Date("2026-03-16T13:00:00Z"), - endAt: new Date("2026-03-16T17:00:00Z"), + 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); @@ -679,8 +679,8 @@ describe("computeWindows", () => { const windows = computeWindows(days, [], PAST, "UTC", false, Q.start, qEnd); expect(windows).toHaveLength(1); - expect(windows[0]!.startAt).toBe("2026-03-16T00:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-18T00:00:00.000Z"); + 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", () => { @@ -688,8 +688,8 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T12:00:00Z"), - endAt: new Date("2026-03-16T13:00:00Z"), + 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); @@ -699,8 +699,8 @@ describe("computeWindows", () => { expect(available).toHaveLength(2); expect(unavailable).toHaveLength(1); - expect(unavailable[0]!.startAt).toBe("2026-03-16T12:00:00.000Z"); - expect(unavailable[0]!.endAt).toBe("2026-03-16T13:00:00.000Z"); + 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)$/); @@ -722,8 +722,8 @@ describe("computeWindows", () => { const windows = computeWindows([day], [], PAST, "UTC", false, qStart, qEnd); expect(windows).toHaveLength(1); - expect(windows[0]!.startAt).toBe("2026-03-16T10:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T14:00:00.000Z"); + 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", () => { @@ -734,10 +734,10 @@ describe("computeWindows", () => { const windows = computeWindows([day], [], PAST, "UTC", false, Q.start, Q.end); expect(windows).toHaveLength(2); - expect(windows[0]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T12:00:00.000Z"); - expect(windows[1]!.startAt).toBe("2026-03-16T14:00:00.000Z"); - expect(windows[1]!.endAt).toBe("2026-03-16T18:00:00.000Z"); + 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", () => { @@ -745,15 +745,15 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T07:00:00Z"), - endAt: new Date("2026-03-16T08:00:00Z"), + 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]!.startAt).toBe("2026-03-16T09:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + 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", () => { @@ -761,14 +761,14 @@ describe("computeWindows", () => { const allocs: BlockingAllocation[] = [ { resourceId: "r", - startAt: new Date("2026-03-16T08:00:00Z"), - endAt: new Date("2026-03-16T10:00:00Z"), + 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]!.startAt).toBe("2026-03-16T10:00:00.000Z"); - expect(windows[0]!.endAt).toBe("2026-03-16T17:00:00.000Z"); + 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/packages/schema/inputs/allocation.ts b/packages/schema/inputs/allocation.ts index 762264f..3dddbd4 100644 --- a/packages/schema/inputs/allocation.ts +++ b/packages/schema/inputs/allocation.ts @@ -5,14 +5,14 @@ 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" }), - startAt: z.coerce.date(), - endAt: z.coerce.date(), + 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.endAt > data.startAt, { - message: "endAt must be after startAt", - path: ["endAt"], + .refine((data) => data.endTime > data.startTime, { + message: "endTime must be after startTime", + path: ["endTime"], }); export const get = z.object({ diff --git a/packages/schema/inputs/availability.ts b/packages/schema/inputs/availability.ts index 9add7d9..7aeb1b8 100644 --- a/packages/schema/inputs/availability.ts +++ b/packages/schema/inputs/availability.ts @@ -6,8 +6,8 @@ export const query = z.object({ resourceIds: z.array( z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), ), - startAt: z.coerce.date(), - endAt: z.coerce.date(), + startTime: z.coerce.date(), + endTime: z.coerce.date(), }); // ─── Service Availability ──────────────────────────────────────────────────── @@ -15,8 +15,8 @@ export const query = z.object({ const serviceAvailabilityBase = { ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), serviceId: z.string().refine((id) => isValidId(id, "svc"), { message: "Invalid service ID" }), - startAt: z.coerce.date(), - endAt: z.coerce.date(), + startTime: z.coerce.date(), + endTime: z.coerce.date(), resourceIds: z .array(z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" })) .optional(), diff --git a/packages/schema/inputs/booking.ts b/packages/schema/inputs/booking.ts index a929799..d88ef15 100644 --- a/packages/schema/inputs/booking.ts +++ b/packages/schema/inputs/booking.ts @@ -7,14 +7,14 @@ export const create = z 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" }), - startAt: z.coerce.date(), - endAt: z.coerce.date(), + 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.endAt > data.startAt, { - message: "endAt must be after startAt", - path: ["endAt"], + .refine((data) => data.endTime > data.startTime, { + message: "endTime must be after startTime", + path: ["endTime"], }); export const get = z.object({ diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts index 64ecc46..4194952 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -64,17 +64,17 @@ const gridAuthoringSchema = z }) .passthrough(); -// Booking window section (authoring) -const bookingWindowAuthoringSchema = z +// Lead time section (authoring) +const leadTimeAuthoringSchema = z .object({ - min_lead_time_ms: z.number().int().nonnegative().optional(), - max_lead_time_ms: z.number().int().nonnegative().optional(), - min_lead_time_minutes: z.number().nonnegative().optional(), - max_lead_time_minutes: z.number().nonnegative().optional(), - min_lead_time_hours: z.number().nonnegative().optional(), - max_lead_time_hours: z.number().nonnegative().optional(), - min_lead_time_days: z.number().nonnegative().optional(), - max_lead_time_days: z.number().nonnegative().optional(), + 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(); @@ -93,7 +93,7 @@ const configAuthoringSchema = z .object({ duration: durationAuthoringSchema.optional(), grid: gridAuthoringSchema.optional(), - booking_window: bookingWindowAuthoringSchema.optional(), + lead_time: leadTimeAuthoringSchema.optional(), buffers: buffersAuthoringSchema.optional(), }) .passthrough(); diff --git a/packages/schema/outputs/allocation.ts b/packages/schema/outputs/allocation.ts index 2b0d0a5..b008167 100644 --- a/packages/schema/outputs/allocation.ts +++ b/packages/schema/outputs/allocation.ts @@ -6,10 +6,12 @@ export const base = z.object({ resourceId: z.string(), bookingId: z.string().nullable(), active: z.boolean(), - startAt: z.string(), - endAt: z.string(), - bufferBeforeMs: z.number(), - bufferAfterMs: z.number(), + 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(), diff --git a/packages/schema/outputs/availability.ts b/packages/schema/outputs/availability.ts index c256411..a876325 100644 --- a/packages/schema/outputs/availability.ts +++ b/packages/schema/outputs/availability.ts @@ -1,8 +1,8 @@ import { z } from "./zod"; export const timelineBlock = z.object({ - startAt: z.string(), - endAt: z.string(), + startTime: z.string(), + endTime: z.string(), status: z.enum(["free", "busy"]), }); @@ -18,8 +18,8 @@ export const query = z.object({ // ─── Service Availability ──────────────────────────────────────────────────── export const slot = z.object({ - startAt: z.string(), - endAt: z.string(), + startTime: z.string(), + endTime: z.string(), status: z.enum(["available", "unavailable"]).optional(), }); @@ -35,8 +35,8 @@ export const slotsResponse = z.object({ }); export const window = z.object({ - startAt: z.string(), - endAt: z.string(), + startTime: z.string(), + endTime: z.string(), status: z.enum(["available", "unavailable"]).optional(), }); diff --git a/packages/schema/outputs/booking.ts b/packages/schema/outputs/booking.ts index 6eb87ec..110df44 100644 --- a/packages/schema/outputs/booking.ts +++ b/packages/schema/outputs/booking.ts @@ -4,10 +4,12 @@ import { BookingStatus } from "../constants"; const bookingAllocationSchema = z.object({ id: z.string(), resourceId: z.string(), - startAt: z.string(), - endAt: z.string(), - bufferBeforeMs: z.number(), - bufferAfterMs: z.number(), + startTime: z.string(), + endTime: z.string(), + buffer: z.object({ + beforeMs: z.number(), + afterMs: z.number(), + }), active: z.boolean(), }); From 4d74df5aa250053b92aeb3fbcac7d7d6ee37f885 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 03:49:18 +0300 Subject: [PATCH 22/31] Update docs --- docs/allocations.md | 50 ++++++++++++++---------------- docs/availability.md | 74 ++++++++++++++++++++++---------------------- docs/bookings.md | 42 +++++++++++++------------ docs/idempotency.md | 18 +++++------ docs/policies.md | 18 +++++------ docs/quickstart.md | 12 +++---- docs/time-format.md | 6 ++-- docs/webhooks.md | 11 ++++--- 8 files changed, 116 insertions(+), 115 deletions(-) diff --git a/docs/allocations.md b/docs/allocations.md index 4966746..7a5cd7d 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -23,8 +23,8 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ -H "Idempotency-Key: unique-request-id" \ -d '{ "resourceId": "rsc_01abc123...", - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T11:00:00Z", + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T11:00:00Z", "metadata": { "reason": "maintenance" } }' ``` @@ -39,10 +39,9 @@ Response: "resourceId": "rsc_01abc123...", "bookingId": null, "active": true, - "startAt": "2026-01-04T10:00:00.000Z", - "endAt": "2026-01-04T11:00:00.000Z", - "bufferBeforeMs": 0, - "bufferAfterMs": 0, + "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", @@ -65,8 +64,8 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ -H "Content-Type: application/json" \ -d '{ "resourceId": "rsc_...", - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T11:00:00Z", + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T11:00:00Z", "expiresAt": "2026-01-04T10:30:00Z" }' ``` @@ -109,29 +108,28 @@ If two requests try to create overlapping allocations on the same resource at th ## Time overlap semantics -Floyd uses **half-open intervals**: **[startAt, endAt)** +Floyd uses **half-open intervals**: **[startTime, endTime)** - 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` +- `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 | -| `startAt` | string | Start of the blocked time window (ISO 8601). Includes buffer if from a booking with a buffer policy. | -| `endAt` | string | End of the blocked time window (ISO 8601). Includes buffer if from a booking with a buffer policy. | -| `bufferBeforeMs` | number | Buffer time before the customer appointment (ms). `0` for raw allocations. | -| `bufferAfterMs` | number | Buffer time after the customer appointment (ms). `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 | +| 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 9ce23a8..50b1d9e 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -16,8 +16,8 @@ Returns discrete, grid-aligned time positions where a booking of the given durat curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID/availability/slots" \ -H "Content-Type: application/json" \ -d '{ - "startAt": "2026-03-16T00:00:00Z", - "endAt": "2026-03-16T23:59:59Z", + "startTime": "2026-03-16T00:00:00Z", + "endTime": "2026-03-16T23:59:59Z", "durationMs": 3600000 }' ``` @@ -31,9 +31,9 @@ Response: "resourceId": "rsc_01abc123def456ghi789jkl012", "timezone": "America/New_York", "slots": [ - { "startAt": "2026-03-16T13:00:00.000Z", "endAt": "2026-03-16T14:00:00.000Z" }, - { "startAt": "2026-03-16T13:30:00.000Z", "endAt": "2026-03-16T14:30:00.000Z" }, - { "startAt": "2026-03-16T14:00:00.000Z", "endAt": "2026-03-16T15:00:00.000Z" } + { "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" } ] } ], @@ -47,8 +47,8 @@ Response: | Field | Type | Required | Description | | -------------------- | -------- | -------- | ----------------------------------------------------------------------------- | -| `startAt` | string | Yes | Start of query window (ISO 8601) | -| `endAt` | string | Yes | End of query window (ISO 8601). Max 7 days from `startAt`. | +| `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`. | @@ -59,7 +59,7 @@ Response: 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_lead_time_ms`) or too far out (beyond `max_lead_time_ms`) 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 @@ -78,9 +78,9 @@ By default, only available slots are returned (no `status` field). With `include ```json { "slots": [ - { "startAt": "...", "endAt": "...", "status": "available" }, - { "startAt": "...", "endAt": "...", "status": "unavailable" }, - { "startAt": "...", "endAt": "...", "status": "available" } + { "startTime": "...", "endTime": "...", "status": "available" }, + { "startTime": "...", "endTime": "...", "status": "unavailable" }, + { "startTime": "...", "endTime": "...", "status": "available" } ] } ``` @@ -95,8 +95,8 @@ Returns continuous available time ranges after subtracting existing allocations curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID/availability/windows" \ -H "Content-Type: application/json" \ -d '{ - "startAt": "2026-03-16T00:00:00Z", - "endAt": "2026-03-20T00:00:00Z" + "startTime": "2026-03-16T00:00:00Z", + "endTime": "2026-03-20T00:00:00Z" }' ``` @@ -109,9 +109,9 @@ Response: "resourceId": "rsc_01abc123def456ghi789jkl012", "timezone": "America/New_York", "windows": [ - { "startAt": "2026-03-16T13:00:00.000Z", "endAt": "2026-03-16T22:00:00.000Z" }, - { "startAt": "2026-03-17T13:00:00.000Z", "endAt": "2026-03-17T16:00:00.000Z" }, - { "startAt": "2026-03-17T18:00:00.000Z", "endAt": "2026-03-17T22:00:00.000Z" } + { "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" } ] } ], @@ -125,8 +125,8 @@ Response: | Field | Type | Required | Description | | -------------------- | -------- | -------- | --------------------------------------------------------------------------------------- | -| `startAt` | string | Yes | Start of query window (ISO 8601) | -| `endAt` | string | Yes | End of query window (ISO 8601). Max 31 days from `startAt`. | +| `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`. | @@ -140,7 +140,7 @@ Response: - 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 booking window are filtered. +6. **Lead time / horizon**: Windows outside the lead time range are filtered. 7. **Merge**: Adjacent windows are merged into single ranges. ### Buffer shrinkage example @@ -173,9 +173,9 @@ With `includeUnavailable: true`, allocation times within schedule hours are also ```json { "windows": [ - { "startAt": "...", "endAt": "...", "status": "available" }, - { "startAt": "...", "endAt": "...", "status": "unavailable" }, - { "startAt": "...", "endAt": "...", "status": "available" } + { "startTime": "...", "endTime": "...", "status": "available" }, + { "startTime": "...", "endTime": "...", "status": "unavailable" }, + { "startTime": "...", "endTime": "...", "status": "available" } ] } ``` @@ -188,8 +188,8 @@ Both slots and windows accept an optional `resourceIds` array to query specific curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID/availability/slots" \ -H "Content-Type: application/json" \ -d '{ - "startAt": "2026-03-16T00:00:00Z", - "endAt": "2026-03-16T23:59:59Z", + "startTime": "2026-03-16T00:00:00Z", + "endTime": "2026-03-16T23:59:59Z", "durationMs": 3600000, "resourceIds": ["rsc_01abc123def456ghi789jkl012"] }' @@ -221,8 +221,8 @@ 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" }' ``` @@ -235,18 +235,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" } ] @@ -285,7 +285,7 @@ const { data, meta } = await fetch( { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ startAt, endAt, durationMs: 3600000 }), + body: JSON.stringify({ startTime, endTime, durationMs: 3600000 }), }, ).then((r) => r.json()); @@ -293,7 +293,7 @@ const { data, meta } = await fetch( for (const resource of data) { if (resource.slots.length > 0) { const slot = resource.slots[0]; - console.log(`Book ${resource.resourceId} at ${slot.startAt}`); + console.log(`Book ${resource.resourceId} at ${slot.startTime}`); break; } } @@ -307,7 +307,7 @@ const { data } = await fetch( { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ startAt, endAt }), + body: JSON.stringify({ startTime, endTime }), }, ).then((r) => r.json()); @@ -315,11 +315,11 @@ const { data } = await fetch( const minDuration = 4 * 60 * 60 * 1000; // 4 hours for (const resource of data) { const suitable = resource.windows.find((w) => { - const duration = new Date(w.endAt) - new Date(w.startAt); + const duration = new Date(w.endTime) - new Date(w.startTime); return duration >= minDuration; }); if (suitable) { - console.log(`Rent ${resource.resourceId}: ${suitable.startAt} to ${suitable.endAt}`); + console.log(`Rent ${resource.resourceId}: ${suitable.startTime} to ${suitable.endTime}`); break; } } diff --git a/docs/bookings.md b/docs/bookings.md index 086cb65..3e61100 100644 --- a/docs/bookings.md +++ b/docs/bookings.md @@ -18,7 +18,7 @@ A booking moves through these states: - `hold` — temporary reservation with `expiresAt`. Blocks time. - `confirmed` — committed reservation. Blocks time. -- `cancelled` — released. Does **not** block time. +- `canceled` — released. Does **not** block time. - `expired` — expiration time elapsed. Does **not** block time. ### State transitions @@ -28,9 +28,9 @@ A booking moves through these states: | (none) | `hold` | `POST /bookings` | | (none) | `confirmed` | `POST /bookings` with `status: "confirmed"` | | `hold` | `confirmed` | `POST /bookings/:id/confirm` | -| `hold` | `cancelled` | `POST /bookings/:id/cancel` | +| `hold` | `canceled` | `POST /bookings/:id/cancel` | | `hold` | `expired` | `expiresAt` elapsed (automatic) | -| `confirmed` | `cancelled` | `POST /bookings/:id/cancel` | +| `confirmed` | `canceled` | `POST /bookings/:id/cancel` | ## Creating a booking @@ -41,8 +41,8 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ -d '{ "serviceId": "svc_...", "resourceId": "rsc_...", - "startAt": "2026-03-01T10:00:00Z", - "endAt": "2026-03-01T11:00:00Z", + "startTime": "2026-03-01T10:00:00Z", + "endTime": "2026-03-01T11:00:00Z", "metadata": { "customerName": "Alice" } }' ``` @@ -66,8 +66,8 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ -d '{ "serviceId": "svc_...", "resourceId": "rsc_...", - "startAt": "2026-03-01T10:00:00Z", - "endAt": "2026-03-01T11:00:00Z", + "startTime": "2026-03-01T10:00:00Z", + "endTime": "2026-03-01T11:00:00Z", "status": "confirmed" }' ``` @@ -101,10 +101,10 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/cancel" This: -- Sets `status = cancelled` +- Sets `status = canceled` - Deactivates allocations (`active = false`) -Cancel works on both `hold` and `confirmed` bookings. It's safe to retry — cancelling an already cancelled booking returns the booking. +Cancel works on both `hold` and `confirmed` bookings. It's safe to retry — canceling an already canceled booking returns the booking. ## Expiration @@ -131,10 +131,12 @@ After expiration, the time slot is available for new bookings. { "id": "alc_...", "resourceId": "rsc_...", - "startAt": "2026-03-01T09:45:00.000Z", - "endAt": "2026-03-01T11:10:00.000Z", - "bufferBeforeMs": 900000, - "bufferAfterMs": 600000, + "startTime": "2026-03-01T09:45:00.000Z", + "endTime": "2026-03-01T11:10:00.000Z", + "buffer": { + "beforeMs": 900000, + "afterMs": 600000 + }, "active": true } ], @@ -152,14 +154,14 @@ Mutating endpoints (`create`, `confirm`, `cancel`) return `meta.serverTime`. ## Buffers -When a service's policy defines buffers (`before_ms` / `after_ms`), the allocation's `startAt` and `endAt` are expanded to include the buffer time. This is the actual blocked window on the resource. +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 `bufferBeforeMs` and `bufferAfterMs` so the original customer time is derivable: +The allocation includes `buffer.beforeMs` and `buffer.afterMs` so the original customer time is derivable: -- Customer start = `allocation.startAt` + `bufferBeforeMs` -- Customer end = `allocation.endAt` - `bufferAfterMs` +- 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. @@ -167,9 +169,9 @@ Conflict detection uses the buffer-expanded times, so adjacent customer appointm ## History -Cancelled 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. +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 cancelled their 3pm — rebook at 4pm"). +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) @@ -180,6 +182,6 @@ For a single resource: ## Time overlap semantics -Floyd uses **half-open intervals**: **[startAt, endAt)** +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/idempotency.md b/docs/idempotency.md index 8ba4325..9cab1be 100644 --- a/docs/idempotency.md +++ b/docs/idempotency.md @@ -14,12 +14,12 @@ The key is scoped to your request. If you send the same key with the same signif ## Supported endpoints -| Endpoint | Significant fields | -| ---------------------------- | ------------------------------------------------------- | -| `POST /allocations` | `resourceId`, `startAt`, `endAt`, `expiresAt` | -| `POST /bookings` | `serviceId`, `resourceId`, `startAt`, `endAt`, `status` | -| `POST /bookings/:id/confirm` | (none — scoped to booking ID) | -| `POST /bookings/:id/cancel` | (none — scoped to booking ID) | +| 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 @@ -49,8 +49,8 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ -d '{ "serviceId": "svc_01abc123...", "resourceId": "rsc_01abc123...", - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T10:30:00Z" + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T10:30:00Z" }' ``` @@ -72,6 +72,6 @@ Examples: The `confirm` and `cancel` booking endpoints are idempotent by default: - Confirming an already confirmed booking returns the confirmed booking -- Cancelling an already cancelled booking returns the cancelled 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/policies.md b/docs/policies.md index 5e66f11..5e49497 100644 --- a/docs/policies.md +++ b/docs/policies.md @@ -33,9 +33,9 @@ curl -X POST https://api.floyd.run/v1/ledgers/{ledgerId}/policies \ "grid": { "interval_minutes": 30 }, - "booking_window": { - "min_lead_time_hours": 1, - "max_lead_time_days": 30 + "lead_time": { + "min_hours": 1, + "max_days": 30 }, "buffers": { "before_minutes": 5, @@ -88,14 +88,14 @@ Constrains start times to fixed intervals. | ------------- | ------------------------------------- | | `interval_ms` | Start times must be multiples of this | -### Booking window +### Lead time Controls how far in advance bookings can be made. -| Field | Description | -| ------------------ | ------------------------------------------ | -| `min_lead_time_ms` | Minimum time between now and booking start | -| `max_lead_time_ms` | Maximum time between now and booking start | +| Field | Description | +| -------- | -------------------------------------- | +| `min_ms` | Minimum lead time before booking start | +| `max_ms` | Maximum lead time before booking start | ### Buffers @@ -218,7 +218,7 @@ Each policy config is canonicalized and hashed (SHA-256). The `configHash` field "config": { "duration": { "allowed_minutes": [15, 30] }, "grid": { "interval_minutes": 15 }, - "booking_window": { "min_lead_time_hours": 2 } + "lead_time": { "min_hours": 2 } }, "rules": [ { diff --git a/docs/quickstart.md b/docs/quickstart.md index f5f9d82..ed62cec 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -166,8 +166,8 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ -d '{ "serviceId": "svc_01abc123def456ghi789jkl012", "resourceId": "rsc_01abc123def456ghi789jkl012", - "startAt": "2026-01-04T10:00:00Z", - "endAt": "2026-01-04T10:30:00Z", + "startTime": "2026-01-04T10:00:00Z", + "endTime": "2026-01-04T10:30:00Z", "metadata": { "source": "quickstart" } }' ``` @@ -186,8 +186,8 @@ Response: { "id": "alc_01abc123def456ghi789jkl012", "resourceId": "rsc_01abc123def456ghi789jkl012", - "startAt": "2026-01-04T10:00:00.000Z", - "endAt": "2026-01-04T10:30:00.000Z", + "startTime": "2026-01-04T10:00:00.000Z", + "endTime": "2026-01-04T10:30:00.000Z", "active": true } ], @@ -229,8 +229,8 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/cancel" Expected: -- `200 OK` with status `cancelled` and allocations deactivated (`active: false`) -- Safe to retry (cancelling an already cancelled booking returns the booking) +- `200 OK` with status `canceled` and allocations deactivated (`active: false`) +- Safe to retry (canceling an already canceled booking returns the booking) ## 6) Get a booking 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 d3ad143..1dc4926 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -10,7 +10,7 @@ Floyd sends webhook notifications when booking and allocation events occur. Subs | ------------------- | ---------------------------- | | `booking.created` | A new booking was created | | `booking.confirmed` | A hold booking was confirmed | -| `booking.cancelled` | A booking was cancelled | +| `booking.canceled` | A booking was canceled | | `booking.expired` | A hold booking expired | ### Allocation events @@ -41,8 +41,8 @@ Floyd sends webhook notifications when booking and allocation events occur. Subs { "id": "alc_01jkl012...", "resourceId": "rsc_01mno345...", - "startAt": "2026-01-15T14:00:00Z", - "endAt": "2026-01-15T15:00:00Z", + "startTime": "2026-01-15T14:00:00Z", + "endTime": "2026-01-15T15:00:00Z", "active": true } ], @@ -69,8 +69,9 @@ Floyd sends webhook notifications when booking and allocation events occur. Subs "resourceId": "rsc_01ghi789...", "bookingId": null, "active": true, - "startAt": "2026-01-15T14:00:00Z", - "endAt": "2026-01-15T15:00:00Z", + "startTime": "2026-01-15T14:00:00Z", + "endTime": "2026-01-15T15:00:00Z", + "buffer": { "beforeMs": 0, "afterMs": 0 }, "expiresAt": null, "metadata": null, "createdAt": "2026-01-15T10:00:00Z", From 9b0486a4092a7f4eb0ad1841302ad6571deb9e67 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 03:58:11 +0300 Subject: [PATCH 23/31] Make timezone required --- apps/server/openapi.json | 5 +++-- apps/server/src/database/schema.ts | 2 +- .../20260212153727_create-bookings-services-tables.ts | 7 +++++-- apps/server/src/operations/availability/slots.ts | 2 +- apps/server/src/operations/availability/windows.ts | 2 +- apps/server/src/operations/booking/create.ts | 2 +- apps/server/src/operations/resource/create.ts | 2 +- apps/server/src/scripts/generate-openapi.ts | 2 +- .../test/integration/setup/factories/resource.factory.ts | 4 ++-- apps/server/test/integration/v1/resources/create.spec.ts | 4 +++- docs/availability.md | 4 +--- docs/quickstart.md | 4 ++-- docs/services.md | 2 +- packages/schema/inputs/resource.ts | 4 +--- 14 files changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 32a584c..5393a08 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -697,11 +697,12 @@ "type": "object", "properties": { "timezone": { - "type": ["string", "null"], + "type": "string", "description": "IANA timezone for the resource (e.g. America/New_York)", "example": "America/New_York" } - } + }, + "required": ["timezone"] } } } diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index 063c62f..91cd242 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -14,7 +14,7 @@ export interface LedgersTable { export interface ResourcesTable { id: string; ledgerId: string; - timezone: string | null; + timezone: string; createdAt: Generated; updatedAt: Generated; } diff --git a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts index d5319ec..8e4c3f5 100644 --- a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts +++ b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts @@ -3,8 +3,11 @@ import { Kysely, sql } from "kysely"; import { addUpdatedAtTrigger } from "./utils"; export async function up(db: Kysely): Promise { - // 1. Add timezone to resources - await db.schema.alterTable("resources").addColumn("timezone", "varchar(64)").execute(); + // 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 diff --git a/apps/server/src/operations/availability/slots.ts b/apps/server/src/operations/availability/slots.ts index d6054cb..f0a7ab6 100644 --- a/apps/server/src/operations/availability/slots.ts +++ b/apps/server/src/operations/availability/slots.ts @@ -124,7 +124,7 @@ export default createOperation({ // 7. Generate slots per resource const data = targetResourceIds.map((resourceId) => { const resource = resourceMap.get(resourceId); - const timezone = resource?.timezone ?? "UTC"; + const timezone = resource!.timezone; const allocs = allocByResource.get(resourceId) ?? []; const resolvedDays = resolveServiceDays(policy, startTime, endTime, timezone); diff --git a/apps/server/src/operations/availability/windows.ts b/apps/server/src/operations/availability/windows.ts index 5822ef6..df084cf 100644 --- a/apps/server/src/operations/availability/windows.ts +++ b/apps/server/src/operations/availability/windows.ts @@ -124,7 +124,7 @@ export default createOperation({ // 7. Compute windows per resource const data = targetResourceIds.map((resourceId) => { const resource = resourceMap.get(resourceId); - const timezone = resource?.timezone ?? "UTC"; + const timezone = resource!.timezone; const allocs = allocByResource.get(resourceId) ?? []; const resolvedDays = resolveServiceDays(policy, startTime, endTime, timezone); diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 97177d2..8b3d2f8 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -71,7 +71,7 @@ export default createOperation({ .executeTakeFirst(); if (policy) { - const timezone = resource.timezone ?? "UTC"; + const timezone = resource.timezone; const result = evaluatePolicy( policy.config as unknown as PolicyConfig, { startTime: input.startTime, endTime: input.endTime }, diff --git a/apps/server/src/operations/resource/create.ts b/apps/server/src/operations/resource/create.ts index 82ccf03..60f8c7b 100644 --- a/apps/server/src/operations/resource/create.ts +++ b/apps/server/src/operations/resource/create.ts @@ -11,7 +11,7 @@ export default createOperation({ .values({ id: generateId("rsc"), ledgerId: input.ledgerId, - timezone: input.timezone ?? null, + timezone: input.timezone, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 66b9a7b..0d01d27 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -132,7 +132,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - timezone: z.string().nullable().optional().openapi({ + timezone: z.string().openapi({ description: "IANA timezone for the resource (e.g. America/New_York)", example: "America/New_York", }), diff --git a/apps/server/test/integration/setup/factories/resource.factory.ts b/apps/server/test/integration/setup/factories/resource.factory.ts index bb5fe9c..893bb1f 100644 --- a/apps/server/test/integration/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; timezone?: string | null }) { +export async function createResource(overrides?: { ledgerId?: string; timezone?: string }) { let ledgerId = overrides?.ledgerId; if (!ledgerId) { const { ledger } = await createLedger(); @@ -14,7 +14,7 @@ export async function createResource(overrides?: { ledgerId?: string; timezone?: .values({ id: generateId("rsc"), ledgerId, - timezone: overrides?.timezone ?? null, + timezone: overrides?.timezone ?? "UTC", }) .returningAll() .executeTakeFirst(); diff --git a/apps/server/test/integration/v1/resources/create.spec.ts b/apps/server/test/integration/v1/resources/create.spec.ts index 4d31882..66c2012 100644 --- a/apps/server/test/integration/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; diff --git a/docs/availability.md b/docs/availability.md index 50b1d9e..65b593c 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -208,9 +208,7 @@ If the service has no policy attached: ## Timezone handling -Each resource has an optional `timezone` field. When set, schedule windows (e.g., "09:00–17:00") are interpreted in that timezone, including DST transitions. The resolved `timezone` is included in each resource's response entry. - -If a resource has no timezone set, UTC is used. +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 diff --git a/docs/quickstart.md b/docs/quickstart.md index ed62cec..ab0029f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -110,7 +110,7 @@ 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,7 +120,7 @@ Response: "data": { "id": "rsc_01abc123def456ghi789jkl012", "ledgerId": "ldg_01abc123def456ghi789jkl012", - "timezone": null, + "timezone": "America/New_York", "createdAt": "2026-01-04T10:00:00.000Z", "updatedAt": "2026-01-04T10:00:00.000Z" } diff --git a/docs/services.md b/docs/services.md index b078c9e..a398bad 100644 --- a/docs/services.md +++ b/docs/services.md @@ -88,7 +88,7 @@ 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 (or UTC if no timezone is set) +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. diff --git a/packages/schema/inputs/resource.ts b/packages/schema/inputs/resource.ts index 4360ebc..ba59efd 100644 --- a/packages/schema/inputs/resource.ts +++ b/packages/schema/inputs/resource.ts @@ -16,9 +16,7 @@ export const create = z.object({ } }, { message: "Invalid IANA timezone" }, - ) - .nullable() - .optional(), + ), }); export const get = z.object({ From a358c011d57fb7f60fa9e448e6dbbca950392e35 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 04:12:23 +0300 Subject: [PATCH 24/31] Fix some small issues --- apps/server/src/app.ts | 4 +- apps/server/src/domain/policy/evaluate.ts | 10 ++-- apps/server/src/domain/policy/normalize.ts | 6 +- .../src/domain/scheduling/availability.ts | 29 +++------- apps/server/src/operations/booking/cancel.ts | 2 +- apps/server/src/operations/policy/update.ts | 55 ++++++++++--------- apps/server/src/operations/service/remove.ts | 2 +- apps/server/src/routes/v1/resources.ts | 2 +- .../integration/v1/services/delete.spec.ts | 4 +- docs/errors.md | 4 +- docs/services.md | 4 +- packages/schema/inputs/availability.ts | 21 ++++--- packages/schema/inputs/policy.ts | 9 +++ 13 files changed, 79 insertions(+), 73 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a25eff9..e634e98 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -26,8 +26,8 @@ app.onError((err, c) => { if (err instanceof NotFoundError) { const details: Record = {}; - if (err.resourceType) details.resourceType = err.resourceType; - if (err.resourceId) details.resourceId = err.resourceId; + if (err["resourceType"]) details["resourceType"] = err["resourceType"]; + if (err["resourceId"]) details["resourceId"] = err["resourceId"]; return c.json( { error: { diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index 2c779d0..8039127 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -34,27 +34,27 @@ export interface EvaluationContext { skipPolicy?: boolean; } -interface DurationConfig { +export interface DurationConfig { min_ms?: number; max_ms?: number; allowed_ms?: number[]; } -interface GridConfig { +export interface GridConfig { interval_ms: number; } -interface LeadTimeConfig { +export interface LeadTimeConfig { min_ms?: number; max_ms?: number; } -interface BuffersConfig { +export interface BuffersConfig { before_ms?: number; after_ms?: number; } -interface HoldConfig { +export interface HoldConfig { duration_ms?: number; } diff --git a/apps/server/src/domain/policy/normalize.ts b/apps/server/src/domain/policy/normalize.ts index 89cebe7..57bb80d 100644 --- a/apps/server/src/domain/policy/normalize.ts +++ b/apps/server/src/domain/policy/normalize.ts @@ -93,7 +93,11 @@ function normalizeConfigSection(config: Record): Record { - // 1. Verify policy exists - const existing = await db - .selectFrom("policies") - .selectAll() - .where("id", "=", input.id) - .where("ledgerId", "=", input.ledgerId) - .executeTakeFirst(); + 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"); - } + if (!existing) { + throw new NotFoundError("Policy not found"); + } - // 2. Normalize, validate, canonicalize, hash - const { normalized, configHash, warnings } = preparePolicyConfig( - input.config as unknown as Record, - ); + // 2. Normalize, validate, canonicalize, hash + const { normalized, configHash, warnings } = preparePolicyConfig( + input.config as unknown as Record, + ); - // 3. Update - const row = await db - .updateTable("policies") - .set({ - config: normalized, - configHash, - }) - .where("id", "=", input.id) - .where("ledgerId", "=", input.ledgerId) - .returningAll() - .executeTakeFirstOrThrow(); + // 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 }; + return { policy: row, warnings }; + }); }, }); diff --git a/apps/server/src/operations/service/remove.ts b/apps/server/src/operations/service/remove.ts index 2f28c50..6468bde 100644 --- a/apps/server/src/operations/service/remove.ts +++ b/apps/server/src/operations/service/remove.ts @@ -30,7 +30,7 @@ export default createOperation({ .executeTakeFirst(); if (activeBooking) { - throw new ConflictError("resource.active_bookings"); + throw new ConflictError("service.active_bookings"); } // 3. Clean up non-active bookings (canceled/expired) and their allocations diff --git a/apps/server/src/routes/v1/resources.ts b/apps/server/src/routes/v1/resources.ts index d661746..2e64f7b 100644 --- a/apps/server/src/routes/v1/resources.ts +++ b/apps/server/src/routes/v1/resources.ts @@ -25,7 +25,7 @@ export const resources = new Hono() const body = await c.req.json(); const { resource } = await operations.resource.create({ ...body, - ledgerId: c.req.param("ledgerId"), + ledgerId: c.req.param("ledgerId")!, }); return c.json({ data: serializeResource(resource) }, 201); }) diff --git a/apps/server/test/integration/v1/services/delete.spec.ts b/apps/server/test/integration/v1/services/delete.spec.ts index 1669a55..8228838 100644 --- a/apps/server/test/integration/v1/services/delete.spec.ts +++ b/apps/server/test/integration/v1/services/delete.spec.ts @@ -40,7 +40,7 @@ describe("DELETE /v1/ledgers/:ledgerId/services/:id", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("resource.active_bookings"); + expect(body.error.code).toBe("service.active_bookings"); }); it("returns 409 when service has confirmed bookings", async () => { @@ -51,7 +51,7 @@ describe("DELETE /v1/ledgers/:ledgerId/services/:id", () => { expect(response.status).toBe(409); const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("resource.active_bookings"); + expect(body.error.code).toBe("service.active_bookings"); }); it("allows deletion when all bookings are canceled", async () => { diff --git a/docs/errors.md b/docs/errors.md index f3380ed..3279ecc 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -100,8 +100,8 @@ Trying to delete a service that has bookings in `hold` or `confirmed` status. ```json { "error": { - "code": "active_bookings_exist", - "message": "Service has active bookings" + "code": "service.active_bookings", + "message": "Conflict" } } ``` diff --git a/docs/services.md b/docs/services.md index a398bad..f456f0e 100644 --- a/docs/services.md +++ b/docs/services.md @@ -67,8 +67,8 @@ curl -X DELETE "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services/$SERVICE_ID" ``` - Returns `204 No Content` on success -- Returns `409 Conflict` with code `active_bookings_exist` if the service has bookings in `hold` or `confirmed` status -- Cancelled and expired bookings do not block deletion +- 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 diff --git a/packages/schema/inputs/availability.ts b/packages/schema/inputs/availability.ts index 7aeb1b8..975feaa 100644 --- a/packages/schema/inputs/availability.ts +++ b/packages/schema/inputs/availability.ts @@ -1,14 +1,19 @@ import z from "zod"; import { isValidId } from "@floyd-run/utils"; -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(), -}); +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 ──────────────────────────────────────────────────── diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts index 4194952..1797a2a 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -88,6 +88,14 @@ const buffersAuthoringSchema = z }) .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({ @@ -95,6 +103,7 @@ const configAuthoringSchema = z grid: gridAuthoringSchema.optional(), lead_time: leadTimeAuthoringSchema.optional(), buffers: buffersAuthoringSchema.optional(), + hold: holdAuthoringSchema.optional(), }) .passthrough(); From 1f40074ee628954a27fb302d418a11d94d4f1ab4 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 04:16:58 +0300 Subject: [PATCH 25/31] Fix typo in docs --- docs/allocations.md | 2 +- docs/availability.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/allocations.md b/docs/allocations.md index 7a5cd7d..f450a17 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -101,7 +101,7 @@ Both bookings and raw allocations share the same conflict detection. Only `activ - `active = true` and `expiresAt` is null → always blocks - `active = true` and `expiresAt > now()` → blocks until expiry -- `active = false` → does not block (cancelled/expired booking allocations) +- `active = false` → does not block (canceled/expired booking allocations) - `active = true` and `expiresAt <= now()` → does not block (expired) If two requests try to create overlapping allocations on the same resource at the same moment, **exactly one** will succeed. diff --git a/docs/availability.md b/docs/availability.md index 65b593c..8cd54ba 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -264,7 +264,7 @@ A time slot is marked `busy` if it overlaps with an active, non-expired allocati These do **not** block time: -- `active = false` (cancelled/expired booking allocations) +- `active = false` (canceled/expired booking allocations) - Expired allocations (`expiresAt <= now()`) Both booking-owned and raw allocations are considered. From d96d35270835b0dec9f579401fa48dbf5addc513 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 04:27:57 +0300 Subject: [PATCH 26/31] Optimize expiration worker --- apps/server/src/workers/expiration-worker.ts | 59 +++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/apps/server/src/workers/expiration-worker.ts b/apps/server/src/workers/expiration-worker.ts index 2d0a9bf..8e61fa7 100644 --- a/apps/server/src/workers/expiration-worker.ts +++ b/apps/server/src/workers/expiration-worker.ts @@ -27,7 +27,7 @@ async function processExpiredBookings(): Promise { return 0; } - const ids = expiredBookings.map((b) => b.id); + const bookingIds = expiredBookings.map((booking) => booking.id); // Update bookings to expired await trx @@ -37,7 +37,7 @@ async function processExpiredBookings(): Promise { expiresAt: null, updatedAt: serverTime, }) - .where("id", "in", ids) + .where("id", "in", bookingIds) .execute(); // Deactivate associated allocations @@ -48,25 +48,30 @@ async function processExpiredBookings(): Promise { expiresAt: null, updatedAt: serverTime, }) - .where("bookingId", "in", ids) + .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) { - const allocations = await trx - .selectFrom("allocations") - .selectAll() - .where("bookingId", "=", booking.id) - .execute(); - - const expiredBooking = { - ...booking, - status: "expired" as const, - expiresAt: null, - updatedAt: serverTime, - }; await enqueueWebhookEvent(trx, "booking.expired", booking.ledgerId, { - booking: serializeBooking(expiredBooking, allocations), + booking: serializeBooking( + { ...booking, status: "expired" as const, expiresAt: null, updatedAt: serverTime }, + allocationsByBookingId.get(booking.id) ?? [], + ), }); } @@ -79,7 +84,7 @@ async function cleanupExpiredRawAllocations(): Promise { const serverTime = await getServerTime(trx); // Find expired raw allocations (no booking) and hard delete - const expired = await trx + const expiredAllocations = await trx .selectFrom("allocations") .select("id") .where("bookingId", "is", null) @@ -90,14 +95,14 @@ async function cleanupExpiredRawAllocations(): Promise { .skipLocked() .execute(); - if (expired.length === 0) { + if (expiredAllocations.length === 0) { return 0; } - const ids = expired.map((a) => a.id); - await trx.deleteFrom("allocations").where("id", "in", ids).execute(); + const allocationIds = expiredAllocations.map((allocation) => allocation.id); + await trx.deleteFrom("allocations").where("id", "in", allocationIds).execute(); - return expired.length; + return expiredAllocations.length; }); } @@ -109,14 +114,14 @@ async function runWorker(): Promise { while (isRunning) { try { - const expiredBookings = await processExpiredBookings(); - if (expiredBookings > 0) { - logger.info(`[expiration-worker] Expired ${expiredBookings} booking holds`); + const expiredCount = await processExpiredBookings(); + if (expiredCount > 0) { + logger.info(`[expiration-worker] Expired ${expiredCount} booking holds`); } - const cleanedAllocations = await cleanupExpiredRawAllocations(); - if (cleanedAllocations > 0) { - logger.info(`[expiration-worker] Cleaned up ${cleanedAllocations} expired raw allocations`); + 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"); From 54ea64b8b89f00d856cc4e89d745d8180f1b1628 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 22:54:31 +0300 Subject: [PATCH 27/31] Fix some small issues --- .../src/domain/scheduling/availability.ts | 20 +++++++++-- apps/server/src/infra/idempotency.ts | 10 +++++- .../integration/v1/bookings/cancel.spec.ts | 33 +++++++++++++++++++ .../integration/v1/bookings/confirm.spec.ts | 33 +++++++++++++++++++ .../domain/scheduling/availability.spec.ts | 8 +++++ 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts index 1d04815..1394a4f 100644 --- a/apps/server/src/domain/scheduling/availability.ts +++ b/apps/server/src/domain/scheduling/availability.ts @@ -188,10 +188,11 @@ export function localToAbsolute(dateStr: string, msFromMidnight: number, timezon // Build an ISO-like string in the target timezone // Use Intl.DateTimeFormat to resolve the UTC offset for this local time - const localStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(ms).padStart(3, "0")}`; + // 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(`${localStr}Z`); + const roughUtc = new Date(roughUtcStr); // Get the timezone offset at this rough time const formatter = new Intl.DateTimeFormat("en-US", { @@ -528,8 +529,21 @@ export function computeWindows( 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 as DurationConfig | undefined)?.min_ms; + return minMs === undefined || durationMs >= minMs; + }); + // Step 7: Merge contiguous windows - const sortedFiltered = [...leadFiltered].sort((a, b) => a.start.getTime() - b.start.getTime()); + const sortedFiltered = [...validWindows].sort((a, b) => a.start.getTime() - b.start.getTime()); const finalWindows = mergeIntervals(sortedFiltered); // Build results diff --git a/apps/server/src/infra/idempotency.ts b/apps/server/src/infra/idempotency.ts index 9378734..0746084 100644 --- a/apps/server/src/infra/idempotency.ts +++ b/apps/server/src/infra/idempotency.ts @@ -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 = {}; + const contentType = c.req.header("content-type"); + if (contentType?.includes("application/json")) { + const text = await c.req.text(); + if (text && text.trim()) { + body = JSON.parse(text); + } + } const payloadHash = computePayloadHash(body, significantFields); // Store body for later use by handler diff --git a/apps/server/test/integration/v1/bookings/cancel.spec.ts b/apps/server/test/integration/v1/bookings/cancel.spec.ts index 084ed2f..21f0b1a 100644 --- a/apps/server/test/integration/v1/bookings/cancel.spec.ts +++ b/apps/server/test/integration/v1/bookings/cancel.spec.ts @@ -79,6 +79,39 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/cancel", () => { 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(); diff --git a/apps/server/test/integration/v1/bookings/confirm.spec.ts b/apps/server/test/integration/v1/bookings/confirm.spec.ts index f068ca7..e5fdedb 100644 --- a/apps/server/test/integration/v1/bookings/confirm.spec.ts +++ b/apps/server/test/integration/v1/bookings/confirm.spec.ts @@ -57,6 +57,39 @@ describe("POST /v1/ledgers/:ledgerId/bookings/:id/confirm", () => { 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(); diff --git a/apps/server/test/unit/domain/scheduling/availability.spec.ts b/apps/server/test/unit/domain/scheduling/availability.spec.ts index a4d8744..f117ed6 100644 --- a/apps/server/test/unit/domain/scheduling/availability.spec.ts +++ b/apps/server/test/unit/domain/scheduling/availability.spec.ts @@ -52,6 +52,14 @@ describe("localToAbsolute", () => { ); }); + 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", From 69196467fba94e840237140873084c6340024932 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 23:41:39 +0300 Subject: [PATCH 28/31] Fix some lint issues --- apps/server/src/app.ts | 4 +- apps/server/src/domain/policy/canonicalize.ts | 4 +- apps/server/src/domain/policy/evaluate.ts | 12 +++--- apps/server/src/domain/policy/validate.ts | 2 +- .../src/domain/scheduling/availability.ts | 32 ++++++++-------- apps/server/src/index.ts | 4 +- apps/server/src/infra/auth.ts | 2 +- apps/server/src/infra/idempotency.ts | 2 +- apps/server/src/lib/errors.ts | 4 +- apps/server/src/lib/operation.ts | 2 +- .../20260121211622_create-resources-table.ts | 2 +- ...20260121215945_create-allocations-table.ts | 2 +- ...122142829_create-idempotency-keys-table.ts | 2 +- .../20260123215648_create-webhooks-tables.ts | 2 +- .../20260206235234_remove-timezone.ts | 2 +- .../20260212000920_create-policies-table.ts | 2 +- ...2153727_create-bookings-services-tables.ts | 2 +- .../src/operations/allocation/remove.ts | 2 +- .../src/operations/availability/query.ts | 2 +- apps/server/src/operations/booking/list.ts | 2 +- apps/server/src/operations/service/list.ts | 2 +- apps/server/src/routes/v1/allocations.ts | 6 +-- apps/server/src/routes/v1/bookings.ts | 10 ++--- apps/server/src/routes/v1/policies.ts | 2 +- apps/server/src/routes/v1/serializers.ts | 13 +++++-- apps/server/src/routes/v1/webhooks.ts | 6 +-- apps/server/src/scripts/generate-openapi.ts | 38 +++++++++---------- apps/server/src/workers/expiration-worker.ts | 2 +- apps/server/src/workers/webhook-worker.ts | 2 +- apps/server/test/integration/setup/client.ts | 4 +- apps/server/test/integration/setup/global.ts | 2 +- .../integration/v1/allocations/create.spec.ts | 2 +- .../integration/v1/allocations/get.spec.ts | 2 +- .../integration/v1/allocations/list.spec.ts | 2 +- .../integration/v1/availability/slots.spec.ts | 14 +++---- .../v1/availability/windows.spec.ts | 12 +++--- .../test/integration/v1/bookings/get.spec.ts | 2 +- .../integration/v1/policies/create.spec.ts | 8 ++-- .../integration/v1/policies/update.spec.ts | 2 +- .../integration/v1/webhooks/create.spec.ts | 2 +- .../test/integration/v1/webhooks/list.spec.ts | 2 +- .../v1/webhooks/rotate-secret.spec.ts | 2 +- .../integration/v1/webhooks/update.spec.ts | 2 +- 43 files changed, 117 insertions(+), 110 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index e634e98..31d08bc 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -26,8 +26,8 @@ app.onError((err, c) => { if (err instanceof NotFoundError) { const details: Record = {}; - if (err["resourceType"]) details["resourceType"] = err["resourceType"]; - if (err["resourceId"]) details["resourceId"] = err["resourceId"]; + if (err.resourceType) details["resourceType"] = err.resourceType; + if (err.resourceId) details["resourceId"] = err.resourceId; return c.json( { error: { diff --git a/apps/server/src/domain/policy/canonicalize.ts b/apps/server/src/domain/policy/canonicalize.ts index b6967a6..4674f9f 100644 --- a/apps/server/src/domain/policy/canonicalize.ts +++ b/apps/server/src/domain/policy/canonicalize.ts @@ -26,7 +26,7 @@ function sortDays(days: string[]): string[] { return [...days].sort((a, b) => (CANONICAL_DAY_ORDER[a] ?? 99) - (CANONICAL_DAY_ORDER[b] ?? 99)); } -function sortWindows(windows: Array<{ start: string; end: string }>): typeof windows { +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; @@ -47,7 +47,7 @@ function canonicalizeValue(key: string, value: unknown): unknown { return sortDays(value as string[]); } if (key === "windows") { - return sortWindows(value as Array<{ start: string; end: string }>).map((w) => + return sortWindows(value as { start: string; end: string }[]).map((w) => canonicalizeObject(w), ); } diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index 8039127..76f66b2 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -169,8 +169,8 @@ export function getDayOfWeek( ): "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()]! as ReturnType; + const d = new Date(Date.UTC(year!, month! - 1, day)); + return DAY_NAMES[d.getUTCDay()]!; } /** @@ -180,8 +180,8 @@ 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!)); + 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( @@ -358,8 +358,8 @@ export function evaluatePolicy( // ─── Step 4: Config resolution ───────────────────────────────────────── - const baseConfig = (policy.config ?? {}) as Record; - const ruleConfig = (matchedRule?.config ?? {}) as Record; + const baseConfig = policy.config ?? {}; + const ruleConfig = matchedRule?.config ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const resolved = { duration: resolvedRaw["duration"] as DurationConfig | undefined, diff --git a/apps/server/src/domain/policy/validate.ts b/apps/server/src/domain/policy/validate.ts index 3249f92..fa0e1ef 100644 --- a/apps/server/src/domain/policy/validate.ts +++ b/apps/server/src/domain/policy/validate.ts @@ -22,7 +22,7 @@ interface RuleMatch { interface Rule { match: RuleMatch; closed?: true; - windows?: Array<{ start: string; end: string }>; + windows?: { start: string; end: string }[]; config?: Record; } diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts index 1394a4f..a294633 100644 --- a/apps/server/src/domain/scheduling/availability.ts +++ b/apps/server/src/domain/scheduling/availability.ts @@ -99,8 +99,8 @@ export function resolveDay( })); // Step 4: Config resolution - const baseConfig = (policy.config ?? {}) as Record; - const ruleConfig = (matchedRule.config ?? {}) as Record; + const baseConfig = policy.config ?? {}; + const ruleConfig = matchedRule.config ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const config: ResolvedConfig = { duration: resolvedRaw["duration"] as DurationConfig | undefined, @@ -122,8 +122,8 @@ export function resolveDay( } // Open 24h (matched rule without windows, or default "open" with no matching rule) - const baseConfig = (policy.config ?? {}) as Record; - const ruleConfig = (matchedRule?.config ?? {}) as Record; + const baseConfig = policy.config ?? {}; + const ruleConfig = matchedRule?.config ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const config: ResolvedConfig = { duration: resolvedRaw["duration"] as DurationConfig | undefined, @@ -222,7 +222,7 @@ export function localToAbsolute(dateStr: string, msFromMidnight: number, timezon 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 wantedDate = new Date(Date.UTC(year!, month! - 1, day)); const gotDate = new Date(Date.UTC(roughUtc.getUTCFullYear(), localMonth - 1, localDay)); dayDiffMs = wantedDate.getTime() - gotDate.getTime(); } @@ -234,7 +234,7 @@ export function localToAbsolute(dateStr: string, msFromMidnight: number, timezon // ─── Slot Generation ───────────────────────────────────────────────────────── function isDurationValid(durationMs: number, config: ResolvedConfig): boolean { - const dur = config.duration as DurationConfig | undefined; + const dur = config.duration; if (!dur) return true; if (dur.allowed_ms && dur.allowed_ms.length > 0) { @@ -286,11 +286,11 @@ export function generateSlots( // Skip day if duration is invalid for this day's config if (!isDurationValid(durationMs, day.config)) continue; - const gridInterval = (day.config.grid as GridConfig | undefined)?.interval_ms ?? durationMs; - const beforeMs = (day.config.buffers as BuffersConfig | undefined)?.before_ms ?? 0; - const afterMs = (day.config.buffers as BuffersConfig | undefined)?.after_ms ?? 0; - const minLeadMs = (day.config.lead_time as LeadTimeConfig | undefined)?.min_ms; - const maxLeadMs = (day.config.lead_time as LeadTimeConfig | undefined)?.max_ms; + 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) { // Generate candidates at grid steps within this window @@ -465,8 +465,8 @@ export function computeWindows( const availableWindows: Interval[] = []; for (const gap of gapIntervals) { const config = getConfigForTime(gap.start.getTime()); - const beforeMs = (config.buffers as BuffersConfig | undefined)?.before_ms ?? 0; - const afterMs = (config.buffers as BuffersConfig | undefined)?.after_ms ?? 0; + const beforeMs = config.buffers?.before_ms ?? 0; + const afterMs = config.buffers?.after_ms ?? 0; let shrunkStart = gap.start.getTime(); let shrunkEnd = gap.end.getTime(); @@ -494,7 +494,7 @@ export function computeWindows( // Step 5: Discard windows shorter than min_ms const filteredWindows = availableWindows.filter((w) => { const config = getConfigForTime(w.start.getTime()); - const minMs = (config.duration as DurationConfig | undefined)?.min_ms; + const minMs = config.duration?.min_ms; if (minMs === undefined) return true; return w.end.getTime() - w.start.getTime() >= minMs; }); @@ -502,7 +502,7 @@ export function computeWindows( // Step 6: Filter by lead time and horizon const leadFiltered = filteredWindows.filter((w) => { const config = getConfigForTime(w.start.getTime()); - const ltConfig = config.lead_time as LeadTimeConfig | undefined; + 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; @@ -538,7 +538,7 @@ export function computeWindows( // Re-check duration constraints after trimming const config = getConfigForTime(w.start.getTime()); - const minMs = (config.duration as DurationConfig | undefined)?.min_ms; + const minMs = config.duration?.min_ms; return minMs === undefined || durationMs >= minMs; }); 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 0746084..78952ea 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"; diff --git a/apps/server/src/lib/errors.ts b/apps/server/src/lib/errors.ts index 689d5ca..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); diff --git a/apps/server/src/lib/operation.ts b/apps/server/src/lib/operation.ts index 4d1396f..5b92549 100644 --- a/apps/server/src/lib/operation.ts +++ b/apps/server/src/lib/operation.ts @@ -1,4 +1,4 @@ -import { z, ZodError } from "zod"; +import { type z, ZodError } from "zod"; import { InputError } from "./errors"; export function createOperation(config: { 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 index c6fbcb6..89cb172 100644 --- a/apps/server/src/migrations/20260212000920_create-policies-table.ts +++ b/apps/server/src/migrations/20260212000920_create-policies-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/20260212153727_create-bookings-services-tables.ts b/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts index 8e4c3f5..7dd7de5 100644 --- a/apps/server/src/migrations/20260212153727_create-bookings-services-tables.ts +++ b/apps/server/src/migrations/20260212153727_create-bookings-services-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/operations/allocation/remove.ts b/apps/server/src/operations/allocation/remove.ts index 7c3310b..91d6b18 100644 --- a/apps/server/src/operations/allocation/remove.ts +++ b/apps/server/src/operations/allocation/remove.ts @@ -1,4 +1,4 @@ -import { db, getServerTime } from "database"; +import { db } from "database"; import { createOperation } from "lib/operation"; import { allocationInput } from "@floyd-run/schema/inputs"; import { ConflictError, NotFoundError } from "lib/errors"; diff --git a/apps/server/src/operations/availability/query.ts b/apps/server/src/operations/availability/query.ts index 6e9740c..064afd4 100644 --- a/apps/server/src/operations/availability/query.ts +++ b/apps/server/src/operations/availability/query.ts @@ -43,7 +43,7 @@ export default createOperation({ // 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) => diff --git a/apps/server/src/operations/booking/list.ts b/apps/server/src/operations/booking/list.ts index 1b0ee96..826f03e 100644 --- a/apps/server/src/operations/booking/list.ts +++ b/apps/server/src/operations/booking/list.ts @@ -42,7 +42,7 @@ export default createOperation({ return { bookings: bookings.map((booking) => ({ booking, - allocations: allocationsByBooking.get(booking.id) || [], + allocations: allocationsByBooking.get(booking.id) ?? [], })), }; }, diff --git a/apps/server/src/operations/service/list.ts b/apps/server/src/operations/service/list.ts index 8edcf76..39e74be 100644 --- a/apps/server/src/operations/service/list.ts +++ b/apps/server/src/operations/service/list.ts @@ -39,7 +39,7 @@ export default createOperation({ return { services: services.map((s) => ({ service: s, - resourceIds: resourceIdsByService.get(s.id) || [], + resourceIds: resourceIdsByService.get(s.id) ?? [], })), }; }, diff --git a/apps/server/src/routes/v1/allocations.ts b/apps/server/src/routes/v1/allocations.ts index 536b611..4149a37 100644 --- a/apps/server/src/routes/v1/allocations.ts +++ b/apps/server/src/routes/v1/allocations.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; 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 @@ -26,10 +26,10 @@ export const allocations = new Hono<{ Variables: IdempotencyVariables }>() }) .post("/", idempotent({ significantFields: ALLOCATION_SIGNIFICANT_FIELDS }), async (c) => { - const body = c.get("parsedBody") || (await c.req.json()); + const body = c.get("parsedBody") ?? (await c.req.json()); const { allocation, serverTime } = await operations.allocation.create({ ...(body as object), - ledgerId: c.req.param("ledgerId")!, + ledgerId: c.req.param("ledgerId"), } as Parameters[0]); const responseBody = { data: serializeAllocation(allocation), meta: { serverTime } }; await storeIdempotencyResponse(c, responseBody, 201); diff --git a/apps/server/src/routes/v1/bookings.ts b/apps/server/src/routes/v1/bookings.ts index bbe072e..edcdb90 100644 --- a/apps/server/src/routes/v1/bookings.ts +++ b/apps/server/src/routes/v1/bookings.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; 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 { serializeBooking } from "./serializers"; // Significant fields for booking create idempotency hash @@ -28,10 +28,10 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>() }) .post("/", idempotent({ significantFields: BOOKING_SIGNIFICANT_FIELDS }), async (c) => { - const body = c.get("parsedBody") || (await c.req.json()); + 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")!, + ledgerId: c.req.param("ledgerId"), } as Parameters[0]); const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } }; await storeIdempotencyResponse(c, responseBody, 201); @@ -41,7 +41,7 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>() .post("/:id/confirm", idempotent(), async (c) => { const { booking, allocations, serverTime } = await operations.booking.confirm({ id: c.req.param("id"), - ledgerId: c.req.param("ledgerId")!, + ledgerId: c.req.param("ledgerId"), }); const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } }; await storeIdempotencyResponse(c, responseBody, 200); @@ -51,7 +51,7 @@ export const bookings = new Hono<{ Variables: IdempotencyVariables }>() .post("/:id/cancel", idempotent(), async (c) => { const { booking, allocations, serverTime } = await operations.booking.cancel({ id: c.req.param("id"), - ledgerId: c.req.param("ledgerId")!, + ledgerId: c.req.param("ledgerId"), }); const responseBody = { data: serializeBooking(booking, allocations), meta: { serverTime } }; await storeIdempotencyResponse(c, responseBody, 200); diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts index b5af94d..c0e9d15 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -39,7 +39,7 @@ export const policies = new Hono() const body = await c.req.json(); const { policy, warnings } = await operations.policy.update({ ...(body as object), - id: c.req.param("id")!, + id: c.req.param("id"), ledgerId: c.req.param("ledgerId")!, } as Parameters[0]); diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index d63381e..caadfdc 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -1,4 +1,4 @@ -import { +import type { AllocationRow, ResourceRow, LedgerRow, @@ -7,7 +7,14 @@ import { ServiceRow, BookingRow, } from "database/schema"; -import { Allocation, Resource, Ledger, Policy, Service, Booking } from "@floyd-run/schema/types"; +import type { + Allocation, + Resource, + Ledger, + Policy, + Service, + Booking, +} from "@floyd-run/schema/types"; export function serializeResource(resource: ResourceRow): Resource { return { @@ -69,7 +76,7 @@ export function serializePolicy(policy: PolicyRow): Policy { return { id: policy.id, ledgerId: policy.ledgerId, - config: policy.config as Record, + config: policy.config, configHash: policy.configHash, createdAt: policy.createdAt.toISOString(), updatedAt: policy.updatedAt.toISOString(), diff --git a/apps/server/src/routes/v1/webhooks.ts b/apps/server/src/routes/v1/webhooks.ts index c5a8aaf..abf0f72 100644 --- a/apps/server/src/routes/v1/webhooks.ts +++ b/apps/server/src/routes/v1/webhooks.ts @@ -38,7 +38,7 @@ export const webhooks = new Hono() const body = await c.req.json(); const { subscription } = await operations.webhook.update({ ...body, - id: c.req.param("id")!, + id: c.req.param("id"), ledgerId: c.req.param("ledgerId")!, }); @@ -52,7 +52,7 @@ export const webhooks = new Hono() // Delete subscription .delete("/:id", async (c) => { const { deleted } = await operations.webhook.remove({ - id: c.req.param("id")!, + id: c.req.param("id"), ledgerId: c.req.param("ledgerId")!, }); @@ -66,7 +66,7 @@ export const webhooks = new Hono() // Rotate secret .post("/:id/rotate-secret", async (c) => { const { subscription, secret } = await operations.webhook.rotateSecret({ - id: c.req.param("id")!, + id: c.req.param("id"), ledgerId: c.req.param("ledgerId")!, }); diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 0d01d27..d45ebea 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -185,11 +185,11 @@ registry.registerPath({ description: "Resource IDs to query", example: ["rsc_01abc123def456ghi789jkl012"], }), - startTime: z.string().datetime().openapi({ + startTime: z.iso.datetime().openapi({ description: "Start of the time window (ISO 8601)", example: "2026-01-04T10:00:00Z", }), - endTime: z.string().datetime().openapi({ + endTime: z.iso.datetime().openapi({ description: "End of the time window (ISO 8601)", example: "2026-01-04T18:00:00Z", }), @@ -255,9 +255,9 @@ registry.registerPath({ "application/json": { schema: z.object({ resourceId: z.string().openapi({ example: "rsc_01abc123def456ghi789jkl012" }), - startTime: z.string().datetime(), - endTime: z.string().datetime(), - expiresAt: z.string().datetime().nullable().optional().openapi({ + 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(), @@ -461,11 +461,11 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - startTime: z.string().datetime().openapi({ + startTime: z.iso.datetime().openapi({ description: "Start of the query window (ISO 8601)", example: "2026-03-02T00:00:00Z", }), - endTime: z.string().datetime().openapi({ + endTime: z.iso.datetime().openapi({ description: "End of the query window (ISO 8601). Max 7 days from startTime.", example: "2026-03-07T00:00:00Z", }), @@ -516,11 +516,11 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - startTime: z.string().datetime().openapi({ + startTime: z.iso.datetime().openapi({ description: "Start of the query window (ISO 8601)", example: "2026-03-02T00:00:00Z", }), - endTime: z.string().datetime().openapi({ + endTime: z.iso.datetime().openapi({ description: "End of the query window (ISO 8601). Max 31 days from startTime.", example: "2026-03-07T00:00:00Z", }), @@ -606,8 +606,8 @@ registry.registerPath({ schema: z.object({ serviceId: z.string().openapi({ example: "svc_01abc123def456ghi789jkl012" }), resourceId: z.string().openapi({ example: "rsc_01abc123def456ghi789jkl012" }), - startTime: z.string().datetime().openapi({ example: "2026-01-15T10:00:00Z" }), - endTime: z.string().datetime().openapi({ example: "2026-01-15T11:00:00Z" }), + 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") @@ -714,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" }), }), }, }, @@ -739,7 +739,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - url: z.string().url().optional(), + url: z.url().optional(), }), }, }, @@ -848,10 +848,10 @@ registry.registerPath({ .object({ schema_version: z.literal(1), default: z.enum(["open", "closed"]), - config: z.object({}).passthrough(), - rules: z.array(z.object({}).passthrough()).optional(), + config: z.object({}).loose(), + rules: z.array(z.object({}).loose()).optional(), }) - .passthrough() + .loose() .openapi({ description: "Policy configuration in authoring format" }), }), }, @@ -883,10 +883,10 @@ registry.registerPath({ .object({ schema_version: z.literal(1), default: z.enum(["open", "closed"]), - config: z.object({}).passthrough(), - rules: z.array(z.object({}).passthrough()).optional(), + config: z.object({}).loose(), + rules: z.array(z.object({}).loose()).optional(), }) - .passthrough() + .loose() .openapi({ description: "Policy configuration in authoring format" }), }), }, diff --git a/apps/server/src/workers/expiration-worker.ts b/apps/server/src/workers/expiration-worker.ts index 8e61fa7..76cbe3b 100644 --- a/apps/server/src/workers/expiration-worker.ts +++ b/apps/server/src/workers/expiration-worker.ts @@ -137,7 +137,7 @@ export function stopExpirationWorker(): void { } 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..801e464 100644 --- a/apps/server/src/workers/webhook-worker.ts +++ b/apps/server/src/workers/webhook-worker.ts @@ -32,7 +32,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/integration/setup/client.ts b/apps/server/test/integration/setup/client.ts index b97b3e7..0ef16a7 100644 --- a/apps/server/test/integration/setup/client.ts +++ b/apps/server/test/integration/setup/client.ts @@ -1,5 +1,3 @@ -import type { Hono } from "hono"; - type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RequestOptions { @@ -35,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 { diff --git a/apps/server/test/integration/setup/global.ts b/apps/server/test/integration/setup/global.ts index ca2d90d..e05bc48 100644 --- a/apps/server/test/integration/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/integration/v1/allocations/create.spec.ts b/apps/server/test/integration/v1/allocations/create.spec.ts index a973df5..088f2c7 100644 --- a/apps/server/test/integration/v1/allocations/create.spec.ts +++ b/apps/server/test/integration/v1/allocations/create.spec.ts @@ -1,7 +1,7 @@ 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 allocation", async () => { diff --git a/apps/server/test/integration/v1/allocations/get.spec.ts b/apps/server/test/integration/v1/allocations/get.spec.ts index 6c934c0..b076224 100644 --- a/apps/server/test/integration/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 () => { diff --git a/apps/server/test/integration/v1/allocations/list.spec.ts b/apps/server/test/integration/v1/allocations/list.spec.ts index 49cd5a0..03bfda8 100644 --- a/apps/server/test/integration/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/integration/v1/availability/slots.spec.ts b/apps/server/test/integration/v1/availability/slots.spec.ts index ef9ce09..e3143fc 100644 --- a/apps/server/test/integration/v1/availability/slots.spec.ts +++ b/apps/server/test/integration/v1/availability/slots.spec.ts @@ -44,10 +44,10 @@ async function setupSalon(overrides?: { policyConfig?: Record } }); const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); - return { ledger, resource, policy: policy!, service }; + return { ledger, resource, policy: policy, service }; } describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { @@ -136,7 +136,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -195,7 +195,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -310,7 +310,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const { policy } = await createPolicy({ ledgerId: ledger.id, config: SALON_POLICY }); const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [r1.id, r2.id], }); @@ -351,7 +351,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const { policy } = await createPolicy({ ledgerId: ledger.id, config: SALON_POLICY }); const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [r1.id, r2.id], }); @@ -460,7 +460,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { const { policy } = await createPolicy({ ledgerId: ledger.id, config: SALON_POLICY }); const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); diff --git a/apps/server/test/integration/v1/availability/windows.spec.ts b/apps/server/test/integration/v1/availability/windows.spec.ts index 73dc1a7..50dd5b1 100644 --- a/apps/server/test/integration/v1/availability/windows.spec.ts +++ b/apps/server/test/integration/v1/availability/windows.spec.ts @@ -42,10 +42,10 @@ async function setupKayak(overrides?: { policyConfig?: Record } }); const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); - return { ledger, resource, policy: policy!, service }; + return { ledger, resource, policy: policy, service }; } describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { @@ -140,7 +140,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -234,7 +234,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -300,7 +300,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const { policy } = await createPolicy({ ledgerId: ledger.id, config: KAYAK_POLICY }); const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [r1.id, r2.id], }); @@ -413,7 +413,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { const { policy } = await createPolicy({ ledgerId: ledger.id, config: KAYAK_POLICY }); const { service } = await createService({ ledgerId: ledger.id, - policyId: policy!.id, + policyId: policy.id, resourceIds: [resource.id], }); diff --git a/apps/server/test/integration/v1/bookings/get.spec.ts b/apps/server/test/integration/v1/bookings/get.spec.ts index c062cd3..1f688ce 100644 --- a/apps/server/test/integration/v1/bookings/get.spec.ts +++ b/apps/server/test/integration/v1/bookings/get.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; -import { createLedger, createResource, createService, createBooking } from "../../setup/factories"; +import { createLedger, createBooking } from "../../setup/factories"; import type { Booking } from "@floyd-run/schema/types"; describe("GET /v1/ledgers/:ledgerId/bookings/:id", () => { diff --git a/apps/server/test/integration/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts index 15dd63a..e0e42e8 100644 --- a/apps/server/test/integration/v1/policies/create.spec.ts +++ b/apps/server/test/integration/v1/policies/create.spec.ts @@ -43,7 +43,7 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { expect(response.status).toBe(201); const { data } = (await response.json()) as { data: Policy }; - const config = data.config as Record; + const config = data.config; const duration = config["config"] as Record>; // allowed_minutes: [30, 60] -> allowed_ms: [1800000, 3600000] @@ -55,9 +55,9 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { expect(duration["grid"]!["interval_minutes"]).toBeUndefined(); // weekdays -> expanded day names - const rules = config["rules"] as Array<{ + const rules = config["rules"] as { match: { days: string[] }; - }>; + }[]; expect(rules[0]!.match.days).toEqual(["monday", "tuesday", "wednesday", "thursday", "friday"]); }); @@ -175,7 +175,7 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { expect(response.status).toBe(201); const body = (await response.json()) as { data: Policy; - meta: { warnings: Array<{ code: string; message: string }> }; + meta: { warnings: { code: string; message: string }[] }; }; expect(body.meta.warnings).toBeDefined(); expect(body.meta.warnings.length).toBeGreaterThan(0); diff --git a/apps/server/test/integration/v1/policies/update.spec.ts b/apps/server/test/integration/v1/policies/update.spec.ts index acc5efc..07e66a0 100644 --- a/apps/server/test/integration/v1/policies/update.spec.ts +++ b/apps/server/test/integration/v1/policies/update.spec.ts @@ -61,7 +61,7 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { const { data } = (await response.json()) as { data: Policy }; // Config should reflect the updated values (normalized) - const config = data.config as Record; + const config = data.config; expect(config["default"] as string).toBe("open"); // Hash should be recalculated and differ from original diff --git a/apps/server/test/integration/v1/webhooks/create.spec.ts b/apps/server/test/integration/v1/webhooks/create.spec.ts index 7f1ca71..fc5255a 100644 --- a/apps/server/test/integration/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 "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/integration/v1/webhooks/list.spec.ts b/apps/server/test/integration/v1/webhooks/list.spec.ts index fae908f..6110650 100644 --- a/apps/server/test/integration/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 "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/integration/v1/webhooks/rotate-secret.spec.ts b/apps/server/test/integration/v1/webhooks/rotate-secret.spec.ts index 1fc25ba..5b85944 100644 --- a/apps/server/test/integration/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 "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/integration/v1/webhooks/update.spec.ts b/apps/server/test/integration/v1/webhooks/update.spec.ts index f31d384..684fc57 100644 --- a/apps/server/test/integration/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 "routes/v1/serializers"; +import type { WebhookSubscription } from "routes/v1/serializers"; describe("PATCH /v1/ledgers/:ledgerId/webhooks/:subscriptionId", () => { it("returns 200 when updating url", async () => { From 56b3440e2f22f5175c7bac9c95d5d4be442869d0 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Fri, 13 Feb 2026 23:59:22 +0300 Subject: [PATCH 29/31] Fix some lint errors --- apps/server/src/domain/policy/canonicalize.ts | 8 ++++---- apps/server/src/domain/policy/evaluate.ts | 2 +- apps/server/src/domain/scheduling/availability.ts | 4 ++-- apps/server/src/infra/idempotency.ts | 6 +++--- apps/server/src/routes/v1/availability.ts | 3 +++ apps/server/src/routes/v1/policies.ts | 1 + apps/server/src/routes/v1/resources.ts | 2 ++ apps/server/src/routes/v1/services.ts | 2 ++ apps/server/src/routes/v1/webhooks.ts | 2 ++ apps/server/src/workers/expiration-worker.ts | 1 + apps/server/src/workers/webhook-worker.ts | 1 + 11 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/server/src/domain/policy/canonicalize.ts b/apps/server/src/domain/policy/canonicalize.ts index 4674f9f..a5df252 100644 --- a/apps/server/src/domain/policy/canonicalize.ts +++ b/apps/server/src/domain/policy/canonicalize.ts @@ -56,18 +56,18 @@ function canonicalizeValue(key: string, value: unknown): unknown { } if (key === "rules") { // Preserve order! Rules are semantic (first-match-wins) - return value.map((item) => { + return value.map((item): unknown => { if (typeof item === "object" && item !== null) { return canonicalizeObject(item as Record); } - return item; + return item as unknown; }); } - return value.map((item) => { + return value.map((item): unknown => { if (typeof item === "object" && item !== null) { return canonicalizeObject(item as Record); } - return item; + return item as unknown; }); } diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index 76f66b2..807c0c2 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -358,7 +358,7 @@ export function evaluatePolicy( // ─── Step 4: Config resolution ───────────────────────────────────────── - const baseConfig = policy.config ?? {}; + const baseConfig = policy.config; const ruleConfig = matchedRule?.config ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const resolved = { diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts index a294633..f950ece 100644 --- a/apps/server/src/domain/scheduling/availability.ts +++ b/apps/server/src/domain/scheduling/availability.ts @@ -99,7 +99,7 @@ export function resolveDay( })); // Step 4: Config resolution - const baseConfig = policy.config ?? {}; + const baseConfig = policy.config; const ruleConfig = matchedRule.config ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const config: ResolvedConfig = { @@ -122,7 +122,7 @@ export function resolveDay( } // Open 24h (matched rule without windows, or default "open" with no matching rule) - const baseConfig = policy.config ?? {}; + const baseConfig = policy.config; const ruleConfig = matchedRule?.config ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const config: ResolvedConfig = { diff --git a/apps/server/src/infra/idempotency.ts b/apps/server/src/infra/idempotency.ts index 78952ea..b7f96be 100644 --- a/apps/server/src/infra/idempotency.ts +++ b/apps/server/src/infra/idempotency.ts @@ -102,12 +102,12 @@ export function idempotent(options: IdempotencyOptions = {}) { // Clone body for hashing (body can only be read once) // Handle empty bodies gracefully for body-less POST endpoints - let body = {}; + let body: Record = {}; const contentType = c.req.header("content-type"); if (contentType?.includes("application/json")) { const text = await c.req.text(); - if (text && text.trim()) { - body = JSON.parse(text); + if (text.trim()) { + body = JSON.parse(text) as Record; } } const payloadHash = computePayloadHash(body, significantFields); diff --git a/apps/server/src/routes/v1/availability.ts b/apps/server/src/routes/v1/availability.ts index c0610ac..941b32d 100644 --- a/apps/server/src/routes/v1/availability.ts +++ b/apps/server/src/routes/v1/availability.ts @@ -1,3 +1,6 @@ +/* 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 { operations } from "operations"; diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts index c0e9d15..6d7523c 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Hono } from "hono"; import { operations } from "operations"; import { NotFoundError } from "lib/errors"; diff --git a/apps/server/src/routes/v1/resources.ts b/apps/server/src/routes/v1/resources.ts index 2e64f7b..0b97fbf 100644 --- a/apps/server/src/routes/v1/resources.ts +++ b/apps/server/src/routes/v1/resources.ts @@ -1,3 +1,5 @@ +/* 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"; diff --git a/apps/server/src/routes/v1/services.ts b/apps/server/src/routes/v1/services.ts index 442d621..8774b04 100644 --- a/apps/server/src/routes/v1/services.ts +++ b/apps/server/src/routes/v1/services.ts @@ -1,3 +1,5 @@ +/* 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"; diff --git a/apps/server/src/routes/v1/webhooks.ts b/apps/server/src/routes/v1/webhooks.ts index abf0f72..b9c9121 100644 --- a/apps/server/src/routes/v1/webhooks.ts +++ b/apps/server/src/routes/v1/webhooks.ts @@ -1,3 +1,5 @@ +/* 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"; diff --git a/apps/server/src/workers/expiration-worker.ts b/apps/server/src/workers/expiration-worker.ts index 76cbe3b..43205e0 100644 --- a/apps/server/src/workers/expiration-worker.ts +++ b/apps/server/src/workers/expiration-worker.ts @@ -112,6 +112,7 @@ async function runWorker(): Promise { logger.info("[expiration-worker] Starting expiration worker..."); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (isRunning) { try { const expiredCount = await processExpiredBookings(); diff --git a/apps/server/src/workers/webhook-worker.ts b/apps/server/src/workers/webhook-worker.ts index 801e464..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); From 30af7992aad0df23ece5a658e85a217755d3c527 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Sat, 14 Feb 2026 00:32:06 +0300 Subject: [PATCH 30/31] Fix some edge cases --- .../src/domain/scheduling/availability.ts | 49 ++++++++++++-- .../domain/scheduling/availability.spec.ts | 67 +++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts index f950ece..584aef8 100644 --- a/apps/server/src/domain/scheduling/availability.ts +++ b/apps/server/src/domain/scheduling/availability.ts @@ -213,6 +213,7 @@ export function localToAbsolute(dateStr: string, msFromMidnight: number, timezon 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; @@ -223,7 +224,7 @@ export function localToAbsolute(dateStr: string, msFromMidnight: number, timezon 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(roughUtc.getUTCFullYear(), localMonth - 1, localDay)); + const gotDate = new Date(Date.UTC(localYear, localMonth - 1, localDay)); dayDiffMs = wantedDate.getTime() - gotDate.getTime(); } @@ -293,9 +294,12 @@ export function generateSlots( 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 = window.startMs; + let startMs = firstAlignedMs; startMs + durationMs <= window.endMs; startMs += gridInterval ) { @@ -437,11 +441,48 @@ export function computeWindows( return []; } - // Sort and merge schedule intervals (handles contiguous cross-day windows) + // Sort schedule intervals const sortedSchedule = [...scheduleIntervals].sort( (a, b) => a.start.getTime() - b.start.getTime(), ); - const mergedSchedule = mergeIntervals(sortedSchedule); + + // 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()); diff --git a/apps/server/test/unit/domain/scheduling/availability.spec.ts b/apps/server/test/unit/domain/scheduling/availability.spec.ts index f117ed6..6993b69 100644 --- a/apps/server/test/unit/domain/scheduling/availability.spec.ts +++ b/apps/server/test/unit/domain/scheduling/availability.spec.ts @@ -104,6 +104,15 @@ describe("localToAbsolute", () => { "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", + ); + }); }); // ═════════════════════════════════════════════════════════════════════════════ @@ -357,6 +366,22 @@ describe("generateSlots", () => { 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); @@ -579,6 +604,48 @@ describe("computeWindows", () => { 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 }, From 72487626ff97d0263f41fcf3fea5d2b6991f361d Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Sat, 14 Feb 2026 01:31:20 +0300 Subject: [PATCH 31/31] Remove scalar --- .../src/domain/scheduling/availability.ts | 5 +- scalar.config.json | 47 ------------------- 2 files changed, 1 insertion(+), 51 deletions(-) delete mode 100644 scalar.config.json diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts index 584aef8..23cbd87 100644 --- a/apps/server/src/domain/scheduling/availability.ts +++ b/apps/server/src/domain/scheduling/availability.ts @@ -474,10 +474,7 @@ export function computeWindows( const last = mergedSchedule[mergedSchedule.length - 1]!; // Merge if contiguous and same config - if ( - last.end.getTime() === current.start.getTime() && - haveSameConfig(last, current) - ) { + if (last.end.getTime() === current.start.getTime() && haveSameConfig(last, current)) { last.end = current.end; // Extend the last interval } else { mergedSchedule.push(current); diff --git a/scalar.config.json b/scalar.config.json deleted file mode 100644 index 0b46012..0000000 --- a/scalar.config.json +++ /dev/null @@ -1,47 +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": "Services", "path": "docs/services.md" }, - { "type": "page", "name": "Bookings", "path": "docs/bookings.md" }, - { "type": "page", "name": "Allocations", "path": "docs/allocations.md" }, - { "type": "page", "name": "Policies", "path": "docs/policies.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" - } - ] -}