From 6073cab17cb4d5c8a0095fed4c28277a321ca728 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 21:00:47 +0300 Subject: [PATCH 1/8] Add policy versions --- .gitignore | 3 +- apps/server/src/database/schema.ts | 18 +- apps/server/src/domain/policy/evaluate.ts | 21 +-- apps/server/src/domain/policy/normalize.ts | 4 +- apps/server/src/domain/policy/validate.ts | 14 +- .../src/domain/scheduling/availability.ts | 10 +- ...0260217165155_add-policy-versions-table.ts | 129 +++++++++++++ .../src/operations/availability/slots.ts | 13 +- .../src/operations/availability/windows.ts | 13 +- apps/server/src/operations/booking/create.ts | 83 +++++---- apps/server/src/operations/policy/create.ts | 54 ++++-- apps/server/src/operations/policy/get.ts | 14 +- apps/server/src/operations/policy/list.ts | 20 ++- apps/server/src/operations/policy/remove.ts | 33 +++- apps/server/src/operations/policy/update.ts | 26 ++- apps/server/src/routes/v1/policies.ts | 18 +- apps/server/src/routes/v1/serializers.ts | 13 +- .../setup/factories/booking.factory.ts | 10 ++ .../setup/factories/policy.factory.ts | 38 +++- .../integration/v1/availability/slots.spec.ts | 14 +- .../v1/availability/windows.spec.ts | 12 +- .../integration/v1/bookings/cancel.spec.ts | 12 +- .../integration/v1/bookings/confirm.spec.ts | 12 +- .../integration/v1/bookings/create.spec.ts | 40 ++++- .../integration/v1/policies/create.spec.ts | 29 +-- .../integration/v1/policies/update.spec.ts | 18 +- .../test/unit/domain/policy/evaluate.spec.ts | 170 +++++++++--------- .../domain/scheduling/availability.spec.ts | 34 ++-- packages/schema/inputs/policy.ts | 18 +- packages/schema/outputs/booking.ts | 2 +- packages/schema/outputs/policy.ts | 4 + packages/utils/id.ts | 4 +- 32 files changed, 627 insertions(+), 276 deletions(-) create mode 100644 apps/server/src/migrations/20260217165155_add-policy-versions-table.ts diff --git a/.gitignore b/.gitignore index 6907863..8550faa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .turbo .env -.DS_Store \ No newline at end of file +.DS_Store +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index 9834eb2..9f5e2cd 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -50,7 +50,7 @@ export interface BookingsTable { id: string; ledgerId: string; serviceId: string; - policyId: string | null; + policyVersionId: string; status: BookingStatus; expiresAt: Date | null; metadata: Record | null; @@ -87,10 +87,20 @@ export interface OutboxEventsTable { export interface PoliciesTable { id: string; ledgerId: string; + name: string | null; + description: string | null; + currentVersionId: string | null; + createdAt: Generated; + updatedAt: Generated; +} + +export interface PolicyVersionsTable { + id: string; + policyId: string; config: Record; + configSource: Record; configHash: string; createdAt: Generated; - updatedAt: Generated; } export interface Database { @@ -102,6 +112,7 @@ export interface Database { resources: ResourcesTable; ledgers: LedgersTable; policies: PoliciesTable; + policyVersions: PolicyVersionsTable; outboxEvents: OutboxEventsTable; } @@ -137,3 +148,6 @@ export type OutboxEventUpdate = Updateable; export type PolicyRow = Selectable; export type NewPolicy = Insertable; export type PolicyUpdate = Updateable; + +export type PolicyVersionRow = Selectable; +export type NewPolicyVersion = Insertable; diff --git a/apps/server/src/domain/policy/evaluate.ts b/apps/server/src/domain/policy/evaluate.ts index 807c0c2..712ba09 100644 --- a/apps/server/src/domain/policy/evaluate.ts +++ b/apps/server/src/domain/policy/evaluate.ts @@ -83,13 +83,13 @@ interface Rule { match: RuleMatch; closed?: true; windows?: TimeWindow[]; - config?: Record; + overrides?: Record; } export interface PolicyConfig { schema_version: number; - default: "open" | "closed"; - config: Record; + default_availability: "open" | "closed"; + constraints: Record; rules?: Rule[]; metadata?: Record; } @@ -268,12 +268,13 @@ export function evaluatePolicy( // 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; + (policy.constraints["buffers"] as BuffersConfig | undefined)?.before_ms ?? 0; + const bufferAfterMs = + (policy.constraints["buffers"] as BuffersConfig | undefined)?.after_ms ?? 0; return { allowed: true, - resolvedConfig: policy.config as unknown as ResolvedConfig, + resolvedConfig: policy.constraints as unknown as ResolvedConfig, effectiveStartTime: new Date(request.startTime.getTime() - bufferBeforeMs), effectiveEndTime: new Date(request.endTime.getTime() + bufferAfterMs), bufferBeforeMs, @@ -346,7 +347,7 @@ export function evaluatePolicy( // No windows on matched rule → day is open 24h } else { // No rule matched → use default - if (policy.default === "closed") { + if (policy.default_availability === "closed") { return { allowed: false, code: REASON_CODES.CLOSED_BY_SCHEDULE, @@ -358,9 +359,9 @@ export function evaluatePolicy( // ─── Step 4: Config resolution ───────────────────────────────────────── - const baseConfig = policy.config; - const ruleConfig = matchedRule?.config ?? {}; - const resolvedRaw = { ...baseConfig, ...ruleConfig }; + const baseConstraints = policy.constraints; + const ruleOverrides = matchedRule?.overrides ?? {}; + const resolvedRaw = { ...baseConstraints, ...ruleOverrides }; const resolved = { duration: resolvedRaw["duration"] as DurationConfig | undefined, grid: resolvedRaw["grid"] as GridConfig | undefined, diff --git a/apps/server/src/domain/policy/normalize.ts b/apps/server/src/domain/policy/normalize.ts index 57bb80d..b3af0d7 100644 --- a/apps/server/src/domain/policy/normalize.ts +++ b/apps/server/src/domain/policy/normalize.ts @@ -124,7 +124,7 @@ function normalizeRule(rule: Record): 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) { + } else if (key === "overrides" && typeof value === "object" && value !== null) { result[key] = normalizeConfigSection(value as Record); } else { result[key] = value; @@ -138,7 +138,7 @@ export function normalizePolicyConfig(authoring: Record): Recor const result: Record = {}; for (const [key, value] of Object.entries(authoring)) { - if (key === "config" && typeof value === "object" && value !== null) { + if (key === "constraints" && 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)); diff --git a/apps/server/src/domain/policy/validate.ts b/apps/server/src/domain/policy/validate.ts index fa0e1ef..f68b1fe 100644 --- a/apps/server/src/domain/policy/validate.ts +++ b/apps/server/src/domain/policy/validate.ts @@ -23,7 +23,7 @@ interface Rule { match: RuleMatch; closed?: true; windows?: { start: string; end: string }[]; - config?: Record; + overrides?: Record; } export function validatePolicyConfig(config: Record): { @@ -31,7 +31,7 @@ export function validatePolicyConfig(config: Record): { } { const warnings: PolicyWarning[] = []; const rules = (config["rules"] as Rule[] | undefined) ?? []; - const defaultValue = config["default"] as string; + const defaultValue = config["default_availability"] as string; // Track which days have been seen in weekly rules (for unreachable detection) const seenWeeklyDays = new Set(); @@ -74,11 +74,11 @@ export function validatePolicyConfig(config: Record): { hasWindowedRule = true; } - // Config-only rule (no windows, no closed) with default: "closed" - if (!rule.closed && !rule.windows && rule.config && defaultValue === "closed") { + // Overrides-only rule (no windows, no closed) with default_availability: "closed" + if (!rule.closed && !rule.windows && rule.overrides && 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"`, + code: "overrides_only_with_closed_default", + message: `Rule ${i}: has overrides but no windows. Matched dates will be open 24h, which may be unintentional with default_availability:"closed"`, details: { ruleIndex: i }, }); } @@ -89,7 +89,7 @@ export function validatePolicyConfig(config: Record): { 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', + 'default_availability is "open" but some rules have windows. Matched dates follow rule windows; unmatched dates are open 24h', }); } diff --git a/apps/server/src/domain/scheduling/availability.ts b/apps/server/src/domain/scheduling/availability.ts index 23cbd87..a2dce19 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; - const ruleConfig = matchedRule.config ?? {}; + const baseConfig = policy.constraints; + const ruleConfig = matchedRule.overrides ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const config: ResolvedConfig = { duration: resolvedRaw["duration"] as DurationConfig | undefined, @@ -115,15 +115,15 @@ export function resolveDay( // No windows on matched rule → day is open 24h } else { // No rule matched → use default - if (policy.default === "closed") { + if (policy.default_availability === "closed") { return null; } // default: "open" → proceed with 24h } // Open 24h (matched rule without windows, or default "open" with no matching rule) - const baseConfig = policy.config; - const ruleConfig = matchedRule?.config ?? {}; + const baseConfig = policy.constraints; + const ruleConfig = matchedRule?.overrides ?? {}; const resolvedRaw = { ...baseConfig, ...ruleConfig }; const config: ResolvedConfig = { duration: resolvedRaw["duration"] as DurationConfig | undefined, diff --git a/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts new file mode 100644 index 0000000..65b90fa --- /dev/null +++ b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts @@ -0,0 +1,129 @@ +import type { Database } from "database/schema"; +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // 1. Create policy_versions table + await db.schema + .createTable("policy_versions") + .addColumn("id", "varchar(32)", (col) => col.primaryKey().notNull()) + .addColumn("policy_id", "varchar(32)", (col) => col.notNull().references("policies.id")) + .addColumn("config", "jsonb", (col) => col.notNull()) + .addColumn("config_source", "jsonb", (col) => col.notNull()) + .addColumn("config_hash", "varchar(64)", (col) => col.notNull()) + .addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)) + .execute(); + + await db.schema + .createIndex("idx_policy_versions_policy") + .on("policy_versions") + .column("policy_id") + .execute(); + + // 2. Add name, description, current_version_id to policies + await db.schema.alterTable("policies").addColumn("name", "varchar(100)").execute(); + + await db.schema.alterTable("policies").addColumn("description", "varchar(500)").execute(); + + await db.schema.alterTable("policies").addColumn("current_version_id", "varchar(32)").execute(); + + // 3. Migrate existing policies into policy_versions (pure SQL to avoid CamelCasePlugin) + await sql` + INSERT INTO policy_versions (id, policy_id, config, config_source, config_hash, created_at) + SELECT + 'pvr_' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 26), + id, config, config, config_hash, NOW() + FROM policies + `.execute(db); + + await sql` + UPDATE policies p + SET current_version_id = pv.id + FROM policy_versions pv + WHERE pv.policy_id = p.id + `.execute(db); + + // 4. Add FK constraint for current_version_id (after data migration) + await sql` + ALTER TABLE policies + ADD CONSTRAINT fk_policies_current_version + FOREIGN KEY (current_version_id) REFERENCES policy_versions(id) + `.execute(db); + + // 5. Drop old columns from policies + await db.schema.dropIndex("idx_policies_config_hash").ifExists().execute(); + await db.schema.alterTable("policies").dropColumn("config").execute(); + await db.schema.alterTable("policies").dropColumn("config_hash").execute(); + + // 6. Replace policy_id with policy_version_id on bookings + await db.schema + .alterTable("bookings") + .addColumn("policy_version_id", "varchar(32)", (col) => col.references("policy_versions.id")) + .execute(); + + // Migrate existing bookings that have a policy_id + await sql` + UPDATE bookings b + SET policy_version_id = p.current_version_id + FROM policies p + WHERE b.policy_id = p.id + `.execute(db); + + // Make it NOT NULL after data migration + await sql` + ALTER TABLE bookings ALTER COLUMN policy_version_id SET NOT NULL + `.execute(db); + + await db.schema + .createIndex("idx_bookings_policy_version") + .on("bookings") + .column("policy_version_id") + .execute(); + + // Drop old policy_id column from bookings + await db.schema.alterTable("bookings").dropColumn("policy_id").execute(); +} + +export async function down(db: Kysely): Promise { + // Restore policy_id on bookings + await db.schema + .alterTable("bookings") + .addColumn("policy_id", "varchar(32)", (col) => col.references("policies.id")) + .execute(); + + await sql` + UPDATE bookings b + SET policy_id = pv.policy_id + FROM policy_versions pv + WHERE b.policy_version_id = pv.id + `.execute(db); + + await db.schema.dropIndex("idx_bookings_policy_version").ifExists().execute(); + await db.schema.alterTable("bookings").dropColumn("policy_version_id").execute(); + + // Restore config/config_hash on policies from current version + await db.schema.alterTable("policies").addColumn("config", "jsonb").execute(); + await db.schema.alterTable("policies").addColumn("config_hash", "varchar(64)").execute(); + + await sql` + UPDATE policies p + SET config = pv.config, config_hash = pv.config_hash + FROM policy_versions pv + WHERE p.current_version_id = pv.id + `.execute(db); + + await sql`ALTER TABLE policies ALTER COLUMN config SET NOT NULL`.execute(db); + await sql`ALTER TABLE policies ALTER COLUMN config_hash SET NOT NULL`.execute(db); + + await db.schema + .createIndex("idx_policies_config_hash") + .on("policies") + .columns(["ledger_id", "config_hash"]) + .execute(); + + // Drop FK constraint, new columns, and policy_versions table + await sql`ALTER TABLE policies DROP CONSTRAINT IF EXISTS fk_policies_current_version`.execute(db); + await db.schema.alterTable("policies").dropColumn("current_version_id").execute(); + await db.schema.alterTable("policies").dropColumn("description").execute(); + await db.schema.alterTable("policies").dropColumn("name").execute(); + await db.schema.dropTable("policy_versions").execute(); +} diff --git a/apps/server/src/operations/availability/slots.ts b/apps/server/src/operations/availability/slots.ts index f0a7ab6..7899757 100644 --- a/apps/server/src/operations/availability/slots.ts +++ b/apps/server/src/operations/availability/slots.ts @@ -81,17 +81,22 @@ export default createOperation({ // 4. Capture serverTime const serverTime = await getServerTime(db); - // 5. Load policy + // 5. Load policy config from current version let policy: PolicyConfig | null = null; if (service.policyId) { const policyRow = await db .selectFrom("policies") - .selectAll() + .select("currentVersionId") .where("id", "=", service.policyId) .executeTakeFirst(); - if (policyRow) { - policy = policyRow.config as unknown as PolicyConfig; + if (policyRow?.currentVersionId) { + const version = await db + .selectFrom("policyVersions") + .select("config") + .where("id", "=", policyRow.currentVersionId) + .executeTakeFirstOrThrow(); + policy = version.config as unknown as PolicyConfig; } } diff --git a/apps/server/src/operations/availability/windows.ts b/apps/server/src/operations/availability/windows.ts index df084cf..eb8ee70 100644 --- a/apps/server/src/operations/availability/windows.ts +++ b/apps/server/src/operations/availability/windows.ts @@ -81,17 +81,22 @@ export default createOperation({ // 4. Capture serverTime const serverTime = await getServerTime(db); - // 5. Load policy + // 5. Load policy config from current version let policy: PolicyConfig | null = null; if (service.policyId) { const policyRow = await db .selectFrom("policies") - .selectAll() + .select("currentVersionId") .where("id", "=", service.policyId) .executeTakeFirst(); - if (policyRow) { - policy = policyRow.config as unknown as PolicyConfig; + if (policyRow?.currentVersionId) { + const version = await db + .selectFrom("policyVersions") + .select("config") + .where("id", "=", policyRow.currentVersionId) + .executeTakeFirstOrThrow(); + policy = version.config as unknown as PolicyConfig; } } diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index 77e89a3..bf47305 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -57,46 +57,57 @@ export default createOperation({ }); } - // 5. Policy evaluation (if service has a policy) + // 5. Load policy version + if (!service.policyId) { + throw new ConflictError("service.no_policy", { + message: "Service must have a policy to create bookings", + }); + } + + const policyRow = await trx + .selectFrom("policies") + .select("currentVersionId") + .where("id", "=", service.policyId) + .executeTakeFirstOrThrow(); + + const version = await trx + .selectFrom("policyVersions") + .selectAll() + .where("id", "=", policyRow.currentVersionId!) + .executeTakeFirstOrThrow(); + + const policyVersionId = version.id; + let holdDurationMs = DEFAULT_HOLD_DURATION_MS; let startTime = input.startTime; let endTime = input.endTime; let bufferBeforeMs = 0; let bufferAfterMs = 0; - if (service.policyId) { - const policy = await trx - .selectFrom("policies") - .selectAll() - .where("id", "=", service.policyId) - .executeTakeFirst(); - - if (policy) { - const timezone = resource.timezone; - const result = evaluatePolicy( - policy.config as unknown as PolicyConfig, - { startTime: input.startTime, endTime: input.endTime }, - { decisionTime: serverTime, timezone }, - ); - - if (!result.allowed) { - throw new ConflictError("policy.rejected", { - code: result.code, - message: result.message, - ...("details" in result ? { details: result.details } : {}), - }); - } - - // Use buffer-expanded times as the allocation's blocked window - startTime = result.effectiveStartTime; - endTime = result.effectiveEndTime; - bufferBeforeMs = result.bufferBeforeMs; - bufferAfterMs = result.bufferAfterMs; - - // Use resolved hold_duration (respects per-rule overrides) - if (result.resolvedConfig.hold?.duration_ms !== undefined) { - holdDurationMs = result.resolvedConfig.hold.duration_ms; - } - } + + const timezone = resource.timezone; + const result = evaluatePolicy( + version.config as unknown as PolicyConfig, + { startTime: input.startTime, endTime: input.endTime }, + { decisionTime: serverTime, timezone }, + ); + + if (!result.allowed) { + throw new ConflictError("policy.rejected", { + code: result.code, + message: result.message, + ...("details" in result ? { details: result.details } : {}), + }); + } + + // Use buffer-expanded times as the allocation's blocked window + startTime = result.effectiveStartTime; + endTime = result.effectiveEndTime; + bufferBeforeMs = result.bufferBeforeMs; + bufferAfterMs = result.bufferAfterMs; + + // Use resolved hold_duration (respects per-rule overrides) + if (result.resolvedConfig.hold?.duration_ms !== undefined) { + holdDurationMs = result.resolvedConfig.hold.duration_ms; } // 6. Compute expiresAt @@ -110,7 +121,7 @@ export default createOperation({ id: generateId("bkg"), ledgerId: input.ledgerId, serviceId: input.serviceId, - policyId: service.policyId, + policyVersionId, status: input.status, expiresAt, metadata: input.metadata ?? null, diff --git a/apps/server/src/operations/policy/create.ts b/apps/server/src/operations/policy/create.ts index cc92428..cf7b941 100644 --- a/apps/server/src/operations/policy/create.ts +++ b/apps/server/src/operations/policy/create.ts @@ -7,21 +7,47 @@ import { preparePolicyConfig } from "domain/policy"; export default createOperation({ input: policyInput.create, execute: async (input) => { - const { normalized, configHash, warnings } = preparePolicyConfig( - input.config as unknown as Record, - ); + return await db.transaction().execute(async (trx) => { + const { normalized, configHash, warnings } = preparePolicyConfig( + input.config as unknown as Record, + ); - const row = await db - .insertInto("policies") - .values({ - id: generateId("pol"), - ledgerId: input.ledgerId, - config: normalized, - configHash, - }) - .returningAll() - .executeTakeFirstOrThrow(); + const policyId = generateId("pol"); + const versionId = generateId("pvr"); - return { policy: row, warnings }; + // 1. Insert policy (without currentVersionId — circular FK) + await trx + .insertInto("policies") + .values({ + id: policyId, + ledgerId: input.ledgerId, + name: input.name ?? null, + description: input.description ?? null, + }) + .execute(); + + // 2. Insert version + const version = await trx + .insertInto("policyVersions") + .values({ + id: versionId, + policyId, + config: normalized, + configSource: input.config as unknown as Record, + configHash, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + // 3. Set currentVersionId + const policy = await trx + .updateTable("policies") + .set({ currentVersionId: versionId }) + .where("id", "=", policyId) + .returningAll() + .executeTakeFirstOrThrow(); + + return { policy, version, warnings }; + }); }, }); diff --git a/apps/server/src/operations/policy/get.ts b/apps/server/src/operations/policy/get.ts index 7c6563f..d8c3e06 100644 --- a/apps/server/src/operations/policy/get.ts +++ b/apps/server/src/operations/policy/get.ts @@ -5,13 +5,23 @@ import { policyInput } from "@floyd-run/schema/inputs"; export default createOperation({ input: policyInput.get, execute: async (input) => { - const row = await db + const policy = await db .selectFrom("policies") .selectAll() .where("id", "=", input.id) .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); - return { policy: row ?? null }; + if (!policy || !policy.currentVersionId) { + return { policy: null, version: null }; + } + + const version = await db + .selectFrom("policyVersions") + .selectAll() + .where("id", "=", policy.currentVersionId) + .executeTakeFirstOrThrow(); + + return { policy, version }; }, }); diff --git a/apps/server/src/operations/policy/list.ts b/apps/server/src/operations/policy/list.ts index b2524fb..db59d86 100644 --- a/apps/server/src/operations/policy/list.ts +++ b/apps/server/src/operations/policy/list.ts @@ -11,6 +11,24 @@ export default createOperation({ .where("ledgerId", "=", input.ledgerId) .execute(); - return { policies }; + const versionIds = policies + .map((p) => p.currentVersionId) + .filter((id): id is string => id !== null); + + const versions = + versionIds.length > 0 + ? await db.selectFrom("policyVersions").selectAll().where("id", "in", versionIds).execute() + : []; + + const versionMap = new Map(versions.map((v) => [v.id, v])); + + return { + policies: policies + .filter((p) => p.currentVersionId !== null) + .map((p) => ({ + policy: p, + version: versionMap.get(p.currentVersionId!)!, + })), + }; }, }); diff --git a/apps/server/src/operations/policy/remove.ts b/apps/server/src/operations/policy/remove.ts index fa9b04e..6146935 100644 --- a/apps/server/src/operations/policy/remove.ts +++ b/apps/server/src/operations/policy/remove.ts @@ -7,17 +7,36 @@ export default createOperation({ input: policyInput.remove, execute: async (input) => { try { - const result = await db - .deleteFrom("policies") - .where("id", "=", input.id) - .where("ledgerId", "=", input.ledgerId) - .executeTakeFirst(); + return await db.transaction().execute(async (trx) => { + // Check policy exists + const policy = await trx + .selectFrom("policies") + .select("id") + .where("id", "=", input.id) + .where("ledgerId", "=", input.ledgerId) + .executeTakeFirst(); - return { deleted: result.numDeletedRows > 0n }; + if (!policy) return { deleted: false }; + + // Clear current_version_id FK before deleting versions + await trx + .updateTable("policies") + .set({ currentVersionId: null }) + .where("id", "=", input.id) + .execute(); + + // Delete associated versions + await trx.deleteFrom("policyVersions").where("policyId", "=", input.id).execute(); + + // Delete the policy + await trx.deleteFrom("policies").where("id", "=", input.id).execute(); + + return { deleted: true }; + }); } 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", + message: "Policy is referenced by one or more services or bookings", }); } throw err; diff --git a/apps/server/src/operations/policy/update.ts b/apps/server/src/operations/policy/update.ts index 321a628..20edfc3 100644 --- a/apps/server/src/operations/policy/update.ts +++ b/apps/server/src/operations/policy/update.ts @@ -1,5 +1,6 @@ import { db } from "database"; import { createOperation } from "lib/operation"; +import { generateId } from "@floyd-run/utils"; import { policyInput } from "@floyd-run/schema/inputs"; import { NotFoundError } from "lib/errors"; import { preparePolicyConfig } from "domain/policy"; @@ -26,19 +27,34 @@ export default createOperation({ input.config as unknown as Record, ); - // 3. Update - const row = await trx - .updateTable("policies") - .set({ + // 3. Insert new version + const versionId = generateId("pvr"); + const version = await trx + .insertInto("policyVersions") + .values({ + id: versionId, + policyId: input.id, config: normalized, + configSource: input.config as unknown as Record, configHash, }) + .returningAll() + .executeTakeFirstOrThrow(); + + // 4. Update policy metadata + point to new version + const policy = await trx + .updateTable("policies") + .set({ + currentVersionId: versionId, + name: input.name ?? existing.name, + description: input.description ?? existing.description, + }) .where("id", "=", input.id) .where("ledgerId", "=", input.ledgerId) .returningAll() .executeTakeFirstOrThrow(); - return { policy: row, warnings }; + return { policy, version, warnings }; }); }, }); diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts index 6d7523c..a0ac87b 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -10,26 +10,28 @@ export const policies = new Hono() const { policies } = await operations.policy.list({ ledgerId: c.req.param("ledgerId")!, }); - return c.json({ data: policies.map(serializePolicy) }); + return c.json({ + data: policies.map(({ policy, version }) => serializePolicy(policy, version)), + }); }) .get("/:id", async (c) => { - const { policy } = await operations.policy.get({ + const { policy, version } = 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) }); + if (!policy || !version) throw new NotFoundError("Policy not found"); + return c.json({ data: serializePolicy(policy, version) }); }) .post("/", async (c) => { const body = await c.req.json(); - const { policy, warnings } = await operations.policy.create({ + const { policy, version, warnings } = await operations.policy.create({ ...(body as object), ledgerId: c.req.param("ledgerId")!, } as Parameters[0]); - const responseBody: Record = { data: serializePolicy(policy) }; + const responseBody: Record = { data: serializePolicy(policy, version) }; if (warnings.length > 0) { responseBody["meta"] = { warnings }; } @@ -38,13 +40,13 @@ export const policies = new Hono() .put("/:id", async (c) => { const body = await c.req.json(); - const { policy, warnings } = await operations.policy.update({ + const { policy, version, 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) }; + const responseBody: Record = { data: serializePolicy(policy, version) }; if (warnings.length > 0) { responseBody["meta"] = { warnings }; } diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index 4c8171a..2ca8cea 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -3,6 +3,7 @@ import type { ResourceRow, LedgerRow, PolicyRow, + PolicyVersionRow, ServiceRow, BookingRow, } from "database/schema"; @@ -53,12 +54,16 @@ export function serializeAllocation(allocation: AllocationRow): Allocation { }; } -export function serializePolicy(policy: PolicyRow): Policy { +export function serializePolicy(policy: PolicyRow, version: PolicyVersionRow): Policy { return { id: policy.id, ledgerId: policy.ledgerId, - config: policy.config, - configHash: policy.configHash, + name: policy.name, + description: policy.description, + currentVersionId: version.id, + config: version.config, + configSource: version.configSource, + configHash: version.configHash, createdAt: policy.createdAt.toISOString(), updatedAt: policy.updatedAt.toISOString(), }; @@ -82,7 +87,7 @@ export function serializeBooking(booking: BookingRow, allocations: AllocationRow id: booking.id, ledgerId: booking.ledgerId, serviceId: booking.serviceId, - policyId: booking.policyId, + policyVersionId: booking.policyVersionId, status: booking.status, expiresAt: booking.expiresAt?.toISOString() ?? null, allocations: allocations.map((a) => ({ diff --git a/apps/server/test/integration/setup/factories/booking.factory.ts b/apps/server/test/integration/setup/factories/booking.factory.ts index fc4ded7..50be44d 100644 --- a/apps/server/test/integration/setup/factories/booking.factory.ts +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -3,11 +3,13 @@ import { generateId } from "@floyd-run/utils"; import { faker } from "@faker-js/faker"; import { createService } from "./service.factory"; import { createResource } from "./resource.factory"; +import { createPolicy } from "./policy.factory"; export async function createBooking(overrides?: { ledgerId?: string; serviceId?: string; resourceId?: string; + policyVersionId?: string; status?: "hold" | "confirmed" | "canceled" | "expired"; expiresAt?: Date | null; metadata?: Record | null; @@ -17,6 +19,7 @@ export async function createBooking(overrides?: { let ledgerId = overrides?.ledgerId; let serviceId = overrides?.serviceId; let resourceId = overrides?.resourceId; + let policyVersionId = overrides?.policyVersionId; // If no service or resource provided, create the full chain if (!serviceId || !resourceId) { @@ -46,6 +49,12 @@ export async function createBooking(overrides?: { ledgerId = service.ledgerId; } + // Create a default policy version if not provided + if (!policyVersionId) { + const { version } = await createPolicy({ ledgerId }); + policyVersionId = version.id; + } + const startTime = overrides?.startTime ?? faker.date.future(); const endTime = overrides?.endTime ?? new Date(startTime.getTime() + 60 * 60 * 1000); const status = overrides?.status ?? "hold"; @@ -64,6 +73,7 @@ export async function createBooking(overrides?: { id: generateId("bkg"), ledgerId, serviceId, + policyVersionId, status, expiresAt, metadata: overrides?.metadata ?? null, diff --git a/apps/server/test/integration/setup/factories/policy.factory.ts b/apps/server/test/integration/setup/factories/policy.factory.ts index 8fdb2d9..1493d4c 100644 --- a/apps/server/test/integration/setup/factories/policy.factory.ts +++ b/apps/server/test/integration/setup/factories/policy.factory.ts @@ -5,8 +5,8 @@ import { preparePolicyConfig } from "domain/policy"; const DEFAULT_CONFIG = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_ms: [1800000, 3600000] }, grid: { interval_ms: 1800000 }, }, @@ -20,6 +20,8 @@ const DEFAULT_CONFIG = { export async function createPolicy(overrides?: { ledgerId?: string; + name?: string | null; + description?: string | null; config?: Record; }) { let ledgerId = overrides?.ledgerId; @@ -31,16 +33,40 @@ export async function createPolicy(overrides?: { const rawConfig = overrides?.config ?? DEFAULT_CONFIG; const { normalized, configHash } = preparePolicyConfig(rawConfig); - const policy = await db + const policyId = generateId("pol"); + const versionId = generateId("pvr"); + + // 1. Insert policy (without currentVersionId) + await db .insertInto("policies") .values({ - id: generateId("pol"), + id: policyId, ledgerId, + name: overrides?.name ?? null, + description: overrides?.description ?? null, + }) + .execute(); + + // 2. Insert version + const version = await db + .insertInto("policyVersions") + .values({ + id: versionId, + policyId, config: normalized, + configSource: rawConfig, configHash, }) .returningAll() - .executeTakeFirst(); + .executeTakeFirstOrThrow(); + + // 3. Set currentVersionId + const policy = await db + .updateTable("policies") + .set({ currentVersionId: versionId }) + .where("id", "=", policyId) + .returningAll() + .executeTakeFirstOrThrow(); - return { policy: policy!, ledgerId }; + return { policy, version, ledgerId }; } diff --git a/apps/server/test/integration/v1/availability/slots.spec.ts b/apps/server/test/integration/v1/availability/slots.spec.ts index e3143fc..aae4081 100644 --- a/apps/server/test/integration/v1/availability/slots.spec.ts +++ b/apps/server/test/integration/v1/availability/slots.spec.ts @@ -20,8 +20,8 @@ interface SlotsResponse { const SALON_POLICY = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_minutes: [30, 60, 90] }, grid: { interval_minutes: 30 }, buffers: { after_minutes: 10 }, @@ -121,8 +121,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { ledgerId: ledger.id, config: { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { min_minutes: 60, max_minutes: 120 }, }, rules: [ @@ -172,8 +172,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { ledgerId: ledger.id, config: { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_minutes: [30, 60, 90] }, grid: { interval_minutes: 30 }, }, @@ -185,7 +185,7 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/slots", () => { { match: { type: "weekly", days: ["saturday"] }, windows: [{ start: "10:00", end: "14:00" }], - config: { + overrides: { duration: { allowed_minutes: [30, 60] }, }, }, diff --git a/apps/server/test/integration/v1/availability/windows.spec.ts b/apps/server/test/integration/v1/availability/windows.spec.ts index 50dd5b1..969a952 100644 --- a/apps/server/test/integration/v1/availability/windows.spec.ts +++ b/apps/server/test/integration/v1/availability/windows.spec.ts @@ -20,8 +20,8 @@ interface WindowsResponse { const KAYAK_POLICY = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { min_hours: 2, max_hours: 8 }, buffers: { after_minutes: 30 }, }, @@ -125,8 +125,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, config: { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { buffers: { before_minutes: 15, after_minutes: 10 }, }, rules: [ @@ -226,8 +226,8 @@ describe("POST /v1/ledgers/:ledgerId/services/:id/availability/windows", () => { ledgerId: ledger.id, config: { schema_version: 1, - default: "open", - config: {}, + default_availability: "open", + constraints: {}, rules: [], }, }); diff --git a/apps/server/test/integration/v1/bookings/cancel.spec.ts b/apps/server/test/integration/v1/bookings/cancel.spec.ts index b295f07..b4a4eea 100644 --- a/apps/server/test/integration/v1/bookings/cancel.spec.ts +++ b/apps/server/test/integration/v1/bookings/cancel.spec.ts @@ -1,13 +1,21 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; -import { createLedger, createResource, createService } from "../../setup/factories"; +import { createLedger, createResource, createService, createPolicy } from "../../setup/factories"; import type { Booking } from "@floyd-run/schema/types"; import { db } from "database"; 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 { policy } = await createPolicy({ + ledgerId, + config: { schema_version: 1, default_availability: "open", constraints: {} }, + }); + const { service } = await createService({ + ledgerId, + policyId: policy.id, + resourceIds: [resource.id], + }); const response = await client.post(`/v1/ledgers/${ledgerId}/bookings`, { serviceId: service.id, diff --git a/apps/server/test/integration/v1/bookings/confirm.spec.ts b/apps/server/test/integration/v1/bookings/confirm.spec.ts index ecdda45..dcc66a9 100644 --- a/apps/server/test/integration/v1/bookings/confirm.spec.ts +++ b/apps/server/test/integration/v1/bookings/confirm.spec.ts @@ -1,13 +1,21 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; -import { createLedger, createResource, createService } from "../../setup/factories"; +import { createLedger, createResource, createService, createPolicy } from "../../setup/factories"; import type { Booking } from "@floyd-run/schema/types"; import { db } from "database"; 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 { policy } = await createPolicy({ + ledgerId, + config: { schema_version: 1, default_availability: "open", constraints: {} }, + }); + const { service } = await createService({ + ledgerId, + policyId: policy.id, + resourceIds: [resource.id], + }); const response = await client.post(`/v1/ledgers/${ledgerId}/bookings`, { serviceId: service.id, diff --git a/apps/server/test/integration/v1/bookings/create.spec.ts b/apps/server/test/integration/v1/bookings/create.spec.ts index 9f61b6b..d92926b 100644 --- a/apps/server/test/integration/v1/bookings/create.spec.ts +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -10,12 +10,20 @@ import { import type { Booking } from "@floyd-run/schema/types"; import { db } from "database"; +const OPEN_POLICY_CONFIG = { + schema_version: 1, + default_availability: "open", + constraints: {}, +}; + 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 { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -52,8 +60,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { it("returns 201 for confirmed booking", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -74,8 +84,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { it("returns 201 with metadata", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -97,8 +109,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { it("creates booking.created event in outbox", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -231,8 +245,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { it("returns 409 when overlapping with existing active allocation", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -261,8 +277,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { it("returns 409 when overlapping with existing booking", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -292,8 +310,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { it("allows adjacent bookings (no overlap)", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -320,8 +340,10 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { it("ignores inactive allocations for conflict detection", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -354,8 +376,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { ledgerId: ledger.id, config: { schema_version: 1, - default: "open", - config: { + default_availability: "open", + constraints: { buffers: { before_ms: 900_000, after_ms: 600_000 }, // 15min before, 10min after }, }, @@ -387,11 +409,13 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(allocation.buffer.afterMs).toBe(600_000); }); - it("stores zero buffers when no policy", async () => { + it("stores zero buffers when policy has no buffer config", async () => { const { ledger } = await createLedger(); const { resource } = await createResource({ ledgerId: ledger.id }); + const { policy } = await createPolicy({ ledgerId: ledger.id, config: OPEN_POLICY_CONFIG }); const { service } = await createService({ ledgerId: ledger.id, + policyId: policy.id, resourceIds: [resource.id], }); @@ -410,7 +434,7 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { const { data } = (await response.json()) as { data: Booking }; const allocation = data.allocations[0]!; - // Without policy, allocation times = input times (no buffers) + // Without buffer config, allocation times = input times (no buffers) expect(allocation.startTime).toBe(startTime); expect(allocation.endTime).toBe(endTime); expect(allocation.buffer.beforeMs).toBe(0); @@ -424,8 +448,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { ledgerId: ledger.id, config: { schema_version: 1, - default: "open", - config: { + default_availability: "open", + constraints: { buffers: { before_ms: 0, after_ms: 1_800_000 }, // 30min after-buffer }, }, @@ -468,8 +492,8 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { ledgerId: ledger.id, config: { schema_version: 1, - default: "open", - config: { + default_availability: "open", + constraints: { buffers: { before_ms: 900_000, after_ms: 900_000 }, // 15min each side }, }, diff --git a/apps/server/test/integration/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts index e0e42e8..002eba6 100644 --- a/apps/server/test/integration/v1/policies/create.spec.ts +++ b/apps/server/test/integration/v1/policies/create.spec.ts @@ -5,8 +5,8 @@ import type { Policy } from "@floyd-run/schema/types"; const validAuthoringConfig = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_minutes: [30, 60] }, grid: { interval_minutes: 15 }, }, @@ -30,6 +30,7 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { const { data } = (await response.json()) as { data: Policy }; expect(data.id).toMatch(/^pol_/); expect(data.ledgerId).toBe(ledger.id); + expect(data.currentVersionId).toMatch(/^pvr_/); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); @@ -44,15 +45,15 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { expect(response.status).toBe(201); const { data } = (await response.json()) as { data: Policy }; const config = data.config; - const duration = config["config"] as Record>; + const constraints = config["constraints"] 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(constraints["duration"]!["allowed_ms"]).toEqual([1800000, 3600000]); + expect(constraints["duration"]!["allowed_minutes"]).toBeUndefined(); // interval_minutes: 15 -> interval_ms: 900000 - expect(duration["grid"]!["interval_ms"]).toBe(900000); - expect(duration["grid"]!["interval_minutes"]).toBeUndefined(); + expect(constraints["grid"]!["interval_ms"]).toBe(900000); + expect(constraints["grid"]!["interval_minutes"]).toBeUndefined(); // weekdays -> expanded day names const rules = config["rules"] as { @@ -94,7 +95,7 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { config: { schema_version: 1, - // missing default and config + // missing default_availability and constraints }, }); @@ -107,8 +108,8 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { config: { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_minutes: [30] }, grid: { interval_minutes: 15 }, }, @@ -130,8 +131,8 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { config: { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_minutes: [30] }, grid: { interval_minutes: 15 }, }, @@ -154,8 +155,8 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { config: { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_minutes: [30] }, grid: { interval_minutes: 15 }, }, diff --git a/apps/server/test/integration/v1/policies/update.spec.ts b/apps/server/test/integration/v1/policies/update.spec.ts index 07e66a0..9d7cef2 100644 --- a/apps/server/test/integration/v1/policies/update.spec.ts +++ b/apps/server/test/integration/v1/policies/update.spec.ts @@ -5,8 +5,8 @@ import type { Policy } from "@floyd-run/schema/types"; const validConfig = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_minutes: [30, 60] }, grid: { interval_minutes: 15 }, }, @@ -20,8 +20,8 @@ const validConfig = { const updatedConfig = { schema_version: 1, - default: "open", - config: { + default_availability: "open", + constraints: { duration: { allowed_minutes: [45, 90] }, grid: { interval_minutes: 30 }, }, @@ -50,8 +50,8 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { }); it("updates config and recalculates hash", async () => { - const { policy, ledgerId } = await createPolicy(); - const originalHash = policy.configHash; + const { version, ledgerId, policy } = await createPolicy(); + const originalHash = version.configHash; const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { config: updatedConfig, @@ -62,7 +62,7 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { // Config should reflect the updated values (normalized) const config = data.config; - expect(config["default"] as string).toBe("open"); + expect(config["default_availability"] as string).toBe("open"); // Hash should be recalculated and differ from original expect(data.configHash).toBeDefined(); @@ -86,8 +86,8 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { config: { schema_version: 2, - default: "closed", - config: {}, + default_availability: "closed", + constraints: {}, }, }); diff --git a/apps/server/test/unit/domain/policy/evaluate.spec.ts b/apps/server/test/unit/domain/policy/evaluate.spec.ts index 37d0278..6e31b34 100644 --- a/apps/server/test/unit/domain/policy/evaluate.spec.ts +++ b/apps/server/test/unit/domain/policy/evaluate.spec.ts @@ -23,8 +23,8 @@ const MINUTE = 60_000; function makePolicy(overrides: Partial = {}): PolicyConfig { return { schema_version: 1, - default: "open", - config: {}, + default_availability: "open", + constraints: {}, ...overrides, }; } @@ -295,7 +295,7 @@ describe("Step 1: Blackout pre-pass", () => { describe("Steps 2-3: Rule resolution + open/closed", () => { it("first-match-wins: earlier weekly rule takes precedence", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -316,7 +316,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { }); it("default 'open' with no rules allows all times", () => { - const policy = makePolicy({ default: "open" }); + const policy = makePolicy({ default_availability: "open" }); const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); const context = makeContext(); @@ -325,7 +325,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { }); it("default 'closed' with no rules rejects", () => { - const policy = makePolicy({ default: "closed" }); + const policy = makePolicy({ default_availability: "closed" }); const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); const context = makeContext(); @@ -336,7 +336,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("windowed rule allows booking inside window", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -353,7 +353,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("windowed rule rejects booking outside window", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -371,7 +371,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("windowed rule rejects booking that starts inside but ends outside window", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -389,7 +389,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("no windows on matched rule means open 24h", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -407,7 +407,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("matched rule with empty windows array means open 24h", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -424,7 +424,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("midnight normalization: Mon 22:00-Tue 00:00 treated as same-day ending at 24:00", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -442,7 +442,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("overnight rejection: booking spans midnight with windowed rule", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -460,7 +460,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("booking within one of multiple windows is allowed", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -481,7 +481,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("booking that falls between two windows is rejected", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -502,7 +502,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("date rule matches by exact date", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "date", date: "2026-03-16" }, @@ -519,7 +519,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("date_range rule with day filter matches correctly", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { @@ -542,7 +542,7 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { it("date_range rule with day filter does not match excluded day", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { @@ -571,14 +571,14 @@ describe("Steps 2-3: Rule resolution + open/closed", () => { describe("Step 4: Config merge", () => { it("rule config overrides base config (section-level replace)", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, grid: { interval_ms: 30 * MINUTE }, }, rules: [ { match: { type: "weekly", days: ["monday"] }, - config: { + overrides: { duration: { min_ms: 60 * MINUTE, max_ms: 60 * MINUTE }, }, }, @@ -601,7 +601,7 @@ describe("Step 4: Config merge", () => { it("base config preserved when rule has no config", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, buffers: { before_ms: 5 * MINUTE, after_ms: 5 * MINUTE }, }, @@ -630,13 +630,13 @@ describe("Step 4: Config merge", () => { it("rule config fully replaces a section (not deep merge)", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, }, rules: [ { match: { type: "weekly", days: ["monday"] }, - config: { + overrides: { // Override duration with only allowed_ms -- min_ms/max_ms from base should NOT survive duration: { allowed_ms: [60 * MINUTE] }, }, @@ -663,7 +663,7 @@ describe("Step 4: Config merge", () => { describe("Step 5: Duration", () => { it("allowed_ms takes precedence over min/max -- duration in list is allowed", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE, @@ -681,7 +681,7 @@ describe("Step 5: Duration", () => { it("allowed_ms takes precedence -- duration NOT in list is rejected even if within min/max", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE, @@ -700,7 +700,7 @@ describe("Step 5: Duration", () => { it("duration below min_ms is rejected", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 60 * MINUTE, max_ms: 120 * MINUTE }, }, }); @@ -715,7 +715,7 @@ describe("Step 5: Duration", () => { it("duration above max_ms is rejected", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 60 * MINUTE }, }, }); @@ -730,7 +730,7 @@ describe("Step 5: Duration", () => { it("duration within min/max is allowed", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, }, }); @@ -744,7 +744,7 @@ describe("Step 5: Duration", () => { it("duration exactly at min_ms boundary is allowed", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, }, }); @@ -758,7 +758,7 @@ describe("Step 5: Duration", () => { it("duration exactly at max_ms boundary is allowed", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 120 * MINUTE }, }, }); @@ -772,7 +772,7 @@ describe("Step 5: Duration", () => { it("no duration config means no duration enforcement", () => { const policy = makePolicy({ - config: {}, + constraints: {}, }); // Very long booking, no duration limits const request = makeRequest("2026-03-16T00:00:00Z", "2026-03-16T23:00:00Z"); @@ -790,7 +790,7 @@ describe("Step 5: Duration", () => { describe("Step 6: Grid alignment", () => { it("aligned start time is allowed (30-min grid)", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 30 * MINUTE }, }, }); @@ -804,7 +804,7 @@ describe("Step 6: Grid alignment", () => { it("aligned start time at 09:30 on 30-min grid is allowed", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 30 * MINUTE }, }, }); @@ -817,7 +817,7 @@ describe("Step 6: Grid alignment", () => { it("misaligned start time is rejected (30-min grid)", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 30 * MINUTE }, }, }); @@ -833,7 +833,7 @@ describe("Step 6: Grid alignment", () => { it("15-minute grid: start at :45 is aligned", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 15 * MINUTE }, }, }); @@ -846,7 +846,7 @@ describe("Step 6: Grid alignment", () => { it("1-hour grid: start at :30 is misaligned", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 60 * MINUTE }, }, }); @@ -859,7 +859,7 @@ describe("Step 6: Grid alignment", () => { it("no grid config means no alignment enforcement", () => { const policy = makePolicy({ - config: {}, + constraints: {}, }); const request = makeRequest("2026-03-16T09:17:00Z", "2026-03-16T10:00:00Z"); const context = makeContext(); @@ -876,7 +876,7 @@ describe("Step 6: Grid alignment", () => { describe("Steps 7-8: Lead time + horizon", () => { it("booking within lead time is rejected", () => { const policy = makePolicy({ - config: { + constraints: { lead_time: { min_ms: 2 * HOUR }, }, }); @@ -892,7 +892,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking beyond horizon is rejected", () => { const policy = makePolicy({ - config: { + constraints: { lead_time: { max_ms: 7 * 24 * HOUR }, }, }); @@ -906,7 +906,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking within both lead time and horizon is allowed", () => { const policy = makePolicy({ - config: { + constraints: { lead_time: { min_ms: 1 * HOUR, max_ms: 30 * 24 * HOUR, @@ -923,7 +923,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking exactly at min_lead_time boundary is allowed", () => { const policy = makePolicy({ - config: { + constraints: { lead_time: { min_ms: 2 * HOUR }, }, }); @@ -937,7 +937,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("booking exactly at max_lead_time boundary is allowed", () => { const policy = makePolicy({ - config: { + constraints: { lead_time: { max_ms: 7 * 24 * HOUR }, }, }); @@ -950,7 +950,7 @@ describe("Steps 7-8: Lead time + horizon", () => { }); it("no lead_time config means no lead time or horizon enforcement", () => { - const policy = makePolicy({ config: {} }); + const policy = makePolicy({ constraints: {} }); // 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") }); @@ -961,7 +961,7 @@ describe("Steps 7-8: Lead time + horizon", () => { it("lead time check runs before horizon check (correct order)", () => { const policy = makePolicy({ - config: { + constraints: { lead_time: { min_ms: 2 * HOUR, max_ms: 7 * 24 * HOUR, @@ -984,7 +984,7 @@ describe("Steps 7-8: Lead time + horizon", () => { describe("Step 9: Buffers", () => { it("buffer before/after computed correctly", () => { const policy = makePolicy({ - config: { + constraints: { buffers: { before_ms: 10 * MINUTE, after_ms: 15 * MINUTE }, }, }); @@ -1001,7 +1001,7 @@ describe("Step 9: Buffers", () => { }); it("no buffers means effective equals original", () => { - const policy = makePolicy({ config: {} }); + const policy = makePolicy({ constraints: {} }); const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); const context = makeContext(); @@ -1016,7 +1016,7 @@ describe("Step 9: Buffers", () => { it("only buffer_before_ms is set", () => { const policy = makePolicy({ - config: { + constraints: { buffers: { before_ms: 5 * MINUTE }, }, }); @@ -1034,7 +1034,7 @@ describe("Step 9: Buffers", () => { it("only buffer_after_ms is set", () => { const policy = makePolicy({ - config: { + constraints: { buffers: { after_ms: 10 * MINUTE }, }, }); @@ -1059,8 +1059,8 @@ describe("Common patterns", () => { describe("Simple salon (Mon-Fri 9-5, 30/60 min)", () => { const salonPolicy: PolicyConfig = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_ms: [30 * MINUTE, 60 * MINUTE] }, grid: { interval_ms: 30 * MINUTE }, lead_time: { @@ -1133,8 +1133,8 @@ describe("Common patterns", () => { describe("24/7 resource (default open, no rules)", () => { const openPolicy: PolicyConfig = { schema_version: 1, - default: "open", - config: { + default_availability: "open", + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 8 * HOUR }, }, }; @@ -1169,8 +1169,8 @@ describe("Common patterns", () => { describe("Holiday closure (closed date rule before weekly)", () => { const holidayPolicy: PolicyConfig = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { min_ms: 30 * MINUTE, max_ms: 2 * HOUR }, }, rules: [ @@ -1208,8 +1208,8 @@ describe("Common patterns", () => { describe("Multi-day rental (no windows, closed blackout)", () => { const rentalPolicy: PolicyConfig = { schema_version: 1, - default: "open", - config: { + default_availability: "open", + constraints: { duration: { min_ms: 24 * HOUR, max_ms: 7 * 24 * HOUR }, lead_time: { min_ms: 24 * HOUR }, }, @@ -1249,8 +1249,8 @@ describe("Common patterns", () => { describe("Escape room (fixed duration, grid, buffer)", () => { const escapeRoomPolicy: PolicyConfig = { schema_version: 1, - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_ms: [60 * MINUTE] }, grid: { interval_ms: 90 * MINUTE }, buffers: { before_ms: 15 * MINUTE, after_ms: 15 * MINUTE }, @@ -1323,8 +1323,8 @@ describe("Common patterns", () => { describe("Admin override (skipPolicy)", () => { it("skipPolicy: true bypasses all checks (Steps 1-8)", () => { const policy = makePolicy({ - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_ms: [60 * MINUTE] }, grid: { interval_ms: 30 * MINUTE }, lead_time: { min_ms: 24 * HOUR }, @@ -1350,7 +1350,7 @@ describe("Admin override (skipPolicy)", () => { it("skipPolicy: true still computes buffers from base config", () => { const policy = makePolicy({ - config: { + constraints: { buffers: { before_ms: 10 * MINUTE, after_ms: 5 * MINUTE }, }, }); @@ -1367,7 +1367,7 @@ describe("Admin override (skipPolicy)", () => { }); it("skipPolicy: true with no buffers returns zero buffers", () => { - const policy = makePolicy({ config: {} }); + const policy = makePolicy({ constraints: {} }); const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); const context = makeContext({ skipPolicy: true }); @@ -1382,8 +1382,8 @@ describe("Admin override (skipPolicy)", () => { it("skipPolicy: false behaves normally (checks enforced)", () => { const policy = makePolicy({ - default: "closed", - config: {}, + default_availability: "closed", + constraints: {}, }); const request = makeRequest("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"); const context = makeContext({ skipPolicy: false }); @@ -1417,7 +1417,7 @@ describe("Edge cases", () => { }); it("empty rules + default open allows all times", () => { - const policy = makePolicy({ default: "open", rules: [] }); + const policy = makePolicy({ default_availability: "open", rules: [] }); const request = makeRequest("2026-03-16T03:00:00Z", "2026-03-16T04:00:00Z"); const context = makeContext(); @@ -1426,7 +1426,7 @@ describe("Edge cases", () => { }); it("undefined rules + default open allows all times", () => { - const policy = makePolicy({ default: "open" }); + const policy = makePolicy({ default_availability: "open" }); const request = makeRequest("2026-03-16T03:00:00Z", "2026-03-16T04:00:00Z"); const context = makeContext(); @@ -1436,8 +1436,8 @@ describe("Edge cases", () => { it("empty config means no enforcement beyond schedule", () => { const policy = makePolicy({ - default: "closed", - config: {}, + default_availability: "closed", + constraints: {}, rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -1455,7 +1455,7 @@ describe("Edge cases", () => { it("midnight normalization does NOT apply when end is after midnight (not exactly midnight)", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -1473,7 +1473,7 @@ describe("Edge cases", () => { it("booking that starts and ends at the exact same time with duration config is rejected", () => { const policy = makePolicy({ - config: { + constraints: { duration: { min_ms: 30 * MINUTE }, }, }); @@ -1487,7 +1487,7 @@ describe("Edge cases", () => { it("evaluation order: blackout checked before schedule windows", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "date", date: "2026-03-16" }, @@ -1509,8 +1509,8 @@ describe("Edge cases", () => { it("evaluation order: schedule checked before duration", () => { const policy = makePolicy({ - default: "closed", - config: { + default_availability: "closed", + constraints: { duration: { allowed_ms: [60 * MINUTE] }, }, }); @@ -1524,7 +1524,7 @@ describe("Edge cases", () => { it("evaluation order: duration checked before grid", () => { const policy = makePolicy({ - config: { + constraints: { duration: { allowed_ms: [60 * MINUTE] }, grid: { interval_ms: 30 * MINUTE }, }, @@ -1539,7 +1539,7 @@ describe("Edge cases", () => { it("evaluation order: grid checked before lead time", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 60 * MINUTE }, lead_time: { min_ms: 24 * HOUR }, }, @@ -1554,13 +1554,13 @@ describe("Edge cases", () => { it("rule config with buffers overrides base buffers", () => { const policy = makePolicy({ - config: { + constraints: { buffers: { before_ms: 10 * MINUTE, after_ms: 10 * MINUTE }, }, rules: [ { match: { type: "weekly", days: ["monday"] }, - config: { + overrides: { buffers: { before_ms: 30 * MINUTE, after_ms: 0 }, }, }, @@ -1580,7 +1580,7 @@ describe("Edge cases", () => { it("closed rule is skipped in Step 2 (rule resolution)", () => { const policy = makePolicy({ - default: "closed", + default_availability: "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 @@ -1614,7 +1614,7 @@ describe("Edge cases", () => { // 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", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["sunday"] }, @@ -1640,7 +1640,7 @@ describe("Edge cases", () => { it("grid alignment is timezone-aware", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 60 * MINUTE }, }, }); @@ -1657,7 +1657,7 @@ describe("Edge cases", () => { it("grid alignment passes when local time is aligned but UTC is not", () => { const policy = makePolicy({ - config: { + constraints: { grid: { interval_ms: 60 * MINUTE }, }, }); @@ -1675,7 +1675,7 @@ describe("Edge cases", () => { it("resolvedConfig includes hold from base config", () => { const policy = makePolicy({ - config: { + constraints: { hold: { duration_ms: 5 * MINUTE }, }, }); @@ -1689,14 +1689,14 @@ describe("Edge cases", () => { it("resolvedConfig hold is overridden by matching rule config", () => { const policy = makePolicy({ - default: "open", - config: { + default_availability: "open", + constraints: { hold: { duration_ms: 5 * MINUTE }, }, rules: [ { match: { type: "weekly", days: ["monday"] }, - config: { + overrides: { hold: { duration_ms: 10 * MINUTE }, }, }, @@ -1712,7 +1712,7 @@ describe("Edge cases", () => { }); it("resolvedConfig hold is undefined when not configured", () => { - const policy = makePolicy({ config: {} }); + const policy = makePolicy({ constraints: {} }); const request = makeRequest("2026-03-16T10:00:00Z", "2026-03-16T11:00:00Z"); const result = evaluatePolicy(policy, request, makeContext()); diff --git a/apps/server/test/unit/domain/scheduling/availability.spec.ts b/apps/server/test/unit/domain/scheduling/availability.spec.ts index 6993b69..47ee1c7 100644 --- a/apps/server/test/unit/domain/scheduling/availability.spec.ts +++ b/apps/server/test/unit/domain/scheduling/availability.spec.ts @@ -22,7 +22,7 @@ const PAST = new Date("2020-01-01T00:00:00Z"); // ─── Helpers ──────────────────────────────────────────────────────────────── function makePolicy(overrides: Partial = {}): PolicyConfig { - return { schema_version: 1, default: "open", config: {}, ...overrides }; + return { schema_version: 1, default_availability: "open", constraints: {}, ...overrides }; } function makeDay( @@ -130,7 +130,7 @@ describe("resolveDay", () => { }); it("default open, no rules → 24h open with base config", () => { - const policy = makePolicy({ config: { duration: { min_ms: HOUR } } }); + const policy = makePolicy({ constraints: { duration: { min_ms: HOUR } } }); const result = resolveDay(policy, "2026-03-16", "monday"); expect(result).not.toBeNull(); @@ -139,7 +139,11 @@ describe("resolveDay", () => { }); it("default closed, no rules → null", () => { - const result = resolveDay(makePolicy({ default: "closed" }), "2026-03-16", "monday"); + const result = resolveDay( + makePolicy({ default_availability: "closed" }), + "2026-03-16", + "monday", + ); expect(result).toBeNull(); }); @@ -159,13 +163,13 @@ describe("resolveDay", () => { it("matching weekly rule with windows → returns windows + merged config", () => { const policy = makePolicy({ - default: "closed", - config: { grid: { interval_ms: 30 * MINUTE } }, + default_availability: "closed", + constraints: { grid: { interval_ms: 30 * MINUTE } }, rules: [ { match: { type: "weekly", days: ["monday"] }, windows: [{ start: "09:00", end: "17:00" }], - config: { duration: { allowed_ms: [HOUR] } }, + overrides: { duration: { allowed_ms: [HOUR] } }, }, ], }); @@ -178,7 +182,7 @@ describe("resolveDay", () => { it("matching rule without windows → 24h open", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [{ match: { type: "weekly", days: ["monday"] } }], }); const result = resolveDay(policy, "2026-03-16", "monday")!; @@ -187,7 +191,7 @@ describe("resolveDay", () => { it("first-match-wins: earlier rule takes precedence", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -205,7 +209,7 @@ describe("resolveDay", () => { it("closed rule is skipped in step 2 (rule resolution)", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "date", date: "2026-03-17" }, closed: true }, { @@ -220,7 +224,7 @@ describe("resolveDay", () => { it("multiple windows on matched rule", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday"] }, @@ -240,14 +244,14 @@ describe("resolveDay", () => { it("rule config overrides base config at section level", () => { const policy = makePolicy({ - config: { + constraints: { 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] } }, + overrides: { duration: { allowed_ms: [HOUR] } }, }, ], }); @@ -261,8 +265,8 @@ describe("resolveDay", () => { it("unmatched day with default open → 24h + base config", () => { const policy = makePolicy({ - default: "open", - config: { buffers: { after_ms: 5 * MINUTE } }, + default_availability: "open", + constraints: { buffers: { after_ms: 5 * MINUTE } }, rules: [ { match: { type: "weekly", days: ["tuesday"] }, @@ -294,7 +298,7 @@ describe("resolveServiceDays", () => { it("multi-day range with mixed open/closed", () => { const policy = makePolicy({ - default: "closed", + default_availability: "closed", rules: [ { match: { type: "weekly", days: ["monday", "wednesday"] }, diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts index 1797a2a..a68c275 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -96,8 +96,8 @@ const holdAuthoringSchema = z }) .passthrough(); -// Config section (authoring) -const configAuthoringSchema = z +// Constraints section (authoring) +const constraintsAuthoringSchema = z .object({ duration: durationAuthoringSchema.optional(), grid: gridAuthoringSchema.optional(), @@ -139,24 +139,24 @@ const ruleSchema = z match: matchSchema, closed: z.literal(true).optional(), windows: z.array(timeWindowSchema).nonempty().optional(), - config: configAuthoringSchema.optional(), + overrides: constraintsAuthoringSchema.optional(), }) .refine( (rule) => { if (rule.closed === true) { - return rule.windows === undefined && rule.config === undefined; + return rule.windows === undefined && rule.overrides === undefined; } return true; }, - { message: "closed rule must not have windows or config" }, + { message: "closed rule must not have windows or overrides" }, ); // Full policy config schema (authoring format) const policyConfigSchema = z .object({ schema_version: z.literal(1), - default: z.enum([ScheduleDefault.OPEN, ScheduleDefault.CLOSED]), - config: configAuthoringSchema, + default_availability: z.enum([ScheduleDefault.OPEN, ScheduleDefault.CLOSED]), + constraints: constraintsAuthoringSchema, rules: z.array(ruleSchema).default([]), metadata: z.record(z.string(), z.unknown()).optional(), }) @@ -164,12 +164,16 @@ const policyConfigSchema = z export const create = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + name: z.string().max(100).optional(), + description: z.string().max(500).optional(), config: policyConfigSchema, }); export const update = z.object({ id: z.string().refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }), ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + name: z.string().max(100).optional(), + description: z.string().max(500).optional(), config: policyConfigSchema, }); diff --git a/packages/schema/outputs/booking.ts b/packages/schema/outputs/booking.ts index 110df44..4c79935 100644 --- a/packages/schema/outputs/booking.ts +++ b/packages/schema/outputs/booking.ts @@ -17,7 +17,7 @@ export const base = z.object({ id: z.string(), ledgerId: z.string(), serviceId: z.string(), - policyId: z.string().nullable(), + policyVersionId: z.string(), status: z.enum([ BookingStatus.HOLD, BookingStatus.CONFIRMED, diff --git a/packages/schema/outputs/policy.ts b/packages/schema/outputs/policy.ts index 9d87f49..2384d32 100644 --- a/packages/schema/outputs/policy.ts +++ b/packages/schema/outputs/policy.ts @@ -3,7 +3,11 @@ import { z } from "./zod"; export const base = z.object({ id: z.string(), ledgerId: z.string(), + name: z.string().nullable(), + description: z.string().nullable(), + currentVersionId: z.string(), config: z.record(z.string(), z.unknown()), + configSource: z.record(z.string(), z.unknown()), configHash: z.string(), createdAt: z.string(), updatedAt: z.string(), diff --git a/packages/utils/id.ts b/packages/utils/id.ts index d016fb5..f9d86c3 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" | "pol" | "svc" | "bkg" | "evt"; +export type IdPrefix = "ldg" | "rsc" | "alc" | "pol" | "pvr" | "svc" | "bkg" | "evt"; 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|pol|svc|bkg|evt)_([a-z0-9]{26})$/); + const match = id.match(/^(ldg|rsc|alc|pol|pvr|svc|bkg|evt)_([a-z0-9]{26})$/); if (!match) return null; return { prefix: match[1] as IdPrefix, ulid: match[2]! }; } From d1236485bf237cc567e13ee3c85bd895f7ebdf01 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 21:11:48 +0300 Subject: [PATCH 2/8] Update docs --- apps/server/openapi.json | 200 +++++++++++++++++--- apps/server/src/scripts/generate-openapi.ts | 42 +++- docs/availability.md | 6 +- docs/bookings.md | 4 +- docs/errors.md | 19 +- docs/policies.md | 114 ++++++++--- docs/quickstart.md | 62 +++++- docs/services.md | 12 +- 8 files changed, 380 insertions(+), 79 deletions(-) diff --git a/apps/server/openapi.json b/apps/server/openapi.json index bc434ee..8459068 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -267,10 +267,23 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "currentVersionId": { + "type": "string" + }, "config": { "type": "object", "additionalProperties": {} }, + "configSource": { + "type": "object", + "additionalProperties": {} + }, "configHash": { "type": "string" }, @@ -281,7 +294,18 @@ "type": "string" } }, - "required": ["id", "ledgerId", "config", "configHash", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "name", + "description", + "currentVersionId", + "config", + "configSource", + "configHash", + "createdAt", + "updatedAt" + ] }, "Service": { "type": "object", @@ -338,8 +362,8 @@ "serviceId": { "type": "string" }, - "policyId": { - "type": ["string", "null"] + "policyVersionId": { + "type": "string" }, "status": { "type": "string", @@ -399,7 +423,7 @@ "id", "ledgerId", "serviceId", - "policyId", + "policyVersionId", "status", "expiresAt", "allocations", @@ -2463,8 +2487,8 @@ "serviceId": { "type": "string" }, - "policyId": { - "type": ["string", "null"] + "policyVersionId": { + "type": "string" }, "status": { "type": "string", @@ -2531,7 +2555,7 @@ "id", "ledgerId", "serviceId", - "policyId", + "policyVersionId", "status", "expiresAt", "allocations", @@ -2552,7 +2576,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 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.", + "description": "Creates a booking for a service. The service must have a policy attached. Evaluates the policy, checks for conflicts, and creates the underlying allocation. The booking stores the policyVersionId that was active at creation time for auditability. 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": { @@ -2623,8 +2647,8 @@ "serviceId": { "type": "string" }, - "policyId": { - "type": ["string", "null"] + "policyVersionId": { + "type": "string" }, "status": { "type": "string", @@ -2691,7 +2715,7 @@ "id", "ledgerId", "serviceId", - "policyId", + "policyVersionId", "status", "expiresAt", "allocations", @@ -2827,8 +2851,8 @@ "serviceId": { "type": "string" }, - "policyId": { - "type": ["string", "null"] + "policyVersionId": { + "type": "string" }, "status": { "type": "string", @@ -2895,7 +2919,7 @@ "id", "ledgerId", "serviceId", - "policyId", + "policyVersionId", "status", "expiresAt", "allocations", @@ -2998,8 +3022,8 @@ "serviceId": { "type": "string" }, - "policyId": { - "type": ["string", "null"] + "policyVersionId": { + "type": "string" }, "status": { "type": "string", @@ -3066,7 +3090,7 @@ "id", "ledgerId", "serviceId", - "policyId", + "policyVersionId", "status", "expiresAt", "allocations", @@ -3202,8 +3226,8 @@ "serviceId": { "type": "string" }, - "policyId": { - "type": ["string", "null"] + "policyVersionId": { + "type": "string" }, "status": { "type": "string", @@ -3270,7 +3294,7 @@ "id", "ledgerId", "serviceId", - "policyId", + "policyVersionId", "status", "expiresAt", "allocations", @@ -3396,10 +3420,23 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "currentVersionId": { + "type": "string" + }, "config": { "type": "object", "additionalProperties": {} }, + "configSource": { + "type": "object", + "additionalProperties": {} + }, "configHash": { "type": "string" }, @@ -3413,7 +3450,11 @@ "required": [ "id", "ledgerId", + "name", + "description", + "currentVersionId", "config", + "configSource", "configHash", "createdAt", "updatedAt" @@ -3431,7 +3472,7 @@ "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).", + "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). Both the normalized config and the original authoring input (configSource) are preserved on each version.", "parameters": [ { "schema": { @@ -3448,6 +3489,16 @@ "schema": { "type": "object", "properties": { + "name": { + "type": ["string", "null"], + "description": "Display name (max 100 chars)", + "example": "Weekday Hours" + }, + "description": { + "type": ["string", "null"], + "description": "Description (max 500 chars)", + "example": "Mon-Fri 9am-5pm" + }, "config": { "type": "object", "properties": { @@ -3455,11 +3506,11 @@ "type": "number", "enum": [1] }, - "default": { + "default_availability": { "type": "string", "enum": ["open", "closed"] }, - "config": { + "constraints": { "type": "object", "properties": {}, "additionalProperties": {} @@ -3473,7 +3524,7 @@ } } }, - "required": ["schema_version", "default", "config"], + "required": ["schema_version", "default_availability", "constraints"], "additionalProperties": {}, "description": "Policy configuration in authoring format" } @@ -3500,10 +3551,23 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "currentVersionId": { + "type": "string" + }, "config": { "type": "object", "additionalProperties": {} }, + "configSource": { + "type": "object", + "additionalProperties": {} + }, "configHash": { "type": "string" }, @@ -3517,7 +3581,11 @@ "required": [ "id", "ledgerId", + "name", + "description", + "currentVersionId", "config", + "configSource", "configHash", "createdAt", "updatedAt" @@ -3571,10 +3639,23 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "currentVersionId": { + "type": "string" + }, "config": { "type": "object", "additionalProperties": {} }, + "configSource": { + "type": "object", + "additionalProperties": {} + }, "configHash": { "type": "string" }, @@ -3588,7 +3669,11 @@ "required": [ "id", "ledgerId", + "name", + "description", + "currentVersionId", "config", + "configSource", "configHash", "createdAt", "updatedAt" @@ -3638,7 +3723,7 @@ "put": { "tags": ["Policies"], "summary": "Update a policy", - "description": "Replaces the full policy configuration. The config is re-normalized, re-validated, and re-hashed.", + "description": "Replaces the full policy configuration. Creates a new immutable version — the config is re-normalized, re-validated, and re-hashed.", "parameters": [ { "schema": { @@ -3663,6 +3748,14 @@ "schema": { "type": "object", "properties": { + "name": { + "type": ["string", "null"], + "description": "Display name (max 100 chars)" + }, + "description": { + "type": ["string", "null"], + "description": "Description (max 500 chars)" + }, "config": { "type": "object", "properties": { @@ -3670,11 +3763,11 @@ "type": "number", "enum": [1] }, - "default": { + "default_availability": { "type": "string", "enum": ["open", "closed"] }, - "config": { + "constraints": { "type": "object", "properties": {}, "additionalProperties": {} @@ -3688,7 +3781,7 @@ } } }, - "required": ["schema_version", "default", "config"], + "required": ["schema_version", "default_availability", "constraints"], "additionalProperties": {}, "description": "Policy configuration in authoring format" } @@ -3715,10 +3808,23 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "currentVersionId": { + "type": "string" + }, "config": { "type": "object", "additionalProperties": {} }, + "configSource": { + "type": "object", + "additionalProperties": {} + }, "configHash": { "type": "string" }, @@ -3732,7 +3838,11 @@ "required": [ "id", "ledgerId", + "name", + "description", + "currentVersionId", "config", + "configSource", "configHash", "createdAt", "updatedAt" @@ -3782,6 +3892,7 @@ "delete": { "tags": ["Policies"], "summary": "Delete a policy", + "description": "Deletes a policy and all its versions. Fails if the policy is referenced by services or bookings.", "parameters": [ { "schema": { @@ -3836,6 +3947,39 @@ } } } + }, + "409": { + "description": "Policy is referenced by services or bookings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": {} + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + } + } } } } diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index 6c1844e..dcf4221 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -592,7 +592,8 @@ 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. " + + "Creates a booking for a service. The service must have a policy attached. Evaluates the policy, checks for conflicts, and creates the underlying allocation. " + + "The booking stores the policyVersionId that was active at creation time for auditability. " + "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.", @@ -725,18 +726,29 @@ registry.registerPath({ 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).", + "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). " + + "Both the normalized config and the original authoring input (configSource) are preserved on each version.", request: { params: z.object({ ledgerId: z.string() }), body: { content: { "application/json": { schema: z.object({ + name: z + .string() + .nullable() + .optional() + .openapi({ description: "Display name (max 100 chars)", example: "Weekday Hours" }), + description: z + .string() + .nullable() + .optional() + .openapi({ description: "Description (max 500 chars)", example: "Mon-Fri 9am-5pm" }), config: z .object({ schema_version: z.literal(1), - default: z.enum(["open", "closed"]), - config: z.object({}).loose(), + default_availability: z.enum(["open", "closed"]), + constraints: z.object({}).loose(), rules: z.array(z.object({}).loose()).optional(), }) .loose() @@ -760,18 +772,28 @@ registry.registerPath({ tags: ["Policies"], summary: "Update a policy", description: - "Replaces the full policy configuration. The config is re-normalized, re-validated, and re-hashed.", + "Replaces the full policy configuration. Creates a new immutable version — 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({ + name: z + .string() + .nullable() + .optional() + .openapi({ description: "Display name (max 100 chars)" }), + description: z + .string() + .nullable() + .optional() + .openapi({ description: "Description (max 500 chars)" }), config: z .object({ schema_version: z.literal(1), - default: z.enum(["open", "closed"]), - config: z.object({}).loose(), + default_availability: z.enum(["open", "closed"]), + constraints: z.object({}).loose(), rules: z.array(z.object({}).loose()).optional(), }) .loose() @@ -798,6 +820,8 @@ registry.registerPath({ path: "/v1/ledgers/{ledgerId}/policies/{id}", tags: ["Policies"], summary: "Delete a policy", + description: + "Deletes a policy and all its versions. Fails if the policy is referenced by services or bookings.", request: { params: z.object({ ledgerId: z.string(), id: z.string() }), }, @@ -807,6 +831,10 @@ registry.registerPath({ description: "Policy not found", content: { "application/json": { schema: error.schema } }, }, + 409: { + description: "Policy is referenced by services or bookings", + content: { "application/json": { schema: error.schema } }, + }, }, }); diff --git a/docs/availability.md b/docs/availability.md index 8cd54ba..37fee53 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -56,8 +56,8 @@ Response: ### 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. +2. **Grid alignment**: Within each day's open windows, candidate positions are placed at grid intervals (from `constraints.grid.interval_ms`). If no grid is configured, the step defaults to `durationMs`. +3. **Duration validation**: If the day's constraints restrict durations (`allowed_ms`, `min_ms`, `max_ms`) and `durationMs` doesn't pass, the entire day is skipped. 4. **Conflict check**: Each candidate is expanded by buffer times (`before_ms` / `after_ms`) and checked against existing allocations. Overlapping candidates are excluded. 5. **Lead time / horizon**: Candidates too close to `serverTime` (below `min_ms`) or too far out (beyond `max_ms`) are excluded. @@ -139,7 +139,7 @@ Response: - 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. +5. **Minimum duration filter**: Windows shorter than `constraints.duration.min_ms` are discarded. 6. **Lead time / horizon**: Windows outside the lead time range are filtered. 7. **Merge**: Adjacent windows are merged into single ranges. diff --git a/docs/bookings.md b/docs/bookings.md index 2f0a7e3..df14963 100644 --- a/docs/bookings.md +++ b/docs/bookings.md @@ -50,11 +50,12 @@ 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, buffers, etc.) +2. Evaluates the service's policy (working hours, duration, grid, buffers, etc.) — the service must have a policy attached 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 +7. Stores the `policyVersionId` that was active at booking time (for auditability) ### Creating as confirmed @@ -125,6 +126,7 @@ After expiration, the time slot is available for new bookings. "id": "bkg_...", "ledgerId": "ldg_...", "serviceId": "svc_...", + "policyVersionId": "pvr_...", "status": "hold", "expiresAt": "2026-03-01T10:15:00.000Z", "allocations": [ diff --git a/docs/errors.md b/docs/errors.md index 3279ecc..fd82100 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -119,15 +119,28 @@ Trying to delete a resource that has allocations or service associations. } ``` +### Service has no policy + +Trying to create a booking against a service that has no policy attached. + +```json +{ + "error": { + "code": "service.no_policy", + "message": "Service must have a policy to create bookings" + } +} +``` + ### Policy in use -Trying to delete a policy that is referenced by one or more services. +Trying to delete a policy that is referenced by one or more services or bookings. ```json { "error": { - "code": "policy_in_use", - "message": "Policy is referenced by one or more services" + "code": "policy.in_use", + "message": "Policy is referenced by one or more services or bookings" } } ``` diff --git a/docs/policies.md b/docs/policies.md index 5e49497..077c3dd 100644 --- a/docs/policies.md +++ b/docs/policies.md @@ -1,30 +1,43 @@ # 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. +Policies define scheduling rules for a service — working hours, duration constraints, grid alignment, lead times, and buffers. When a policy is attached to a service, 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. +A policy has a **default_availability** 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 constraint 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 +4. Merges the rule's overrides with the base constraints 5. Validates duration, grid alignment, lead time, and horizon 6. Computes buffer times +## Versioning + +Every policy create or update creates an immutable **version**. The policy response includes: + +- `currentVersionId` — the active version (`pvr_...`) +- `config` — the normalized canonical config (all values in milliseconds) +- `configSource` — the original authoring input exactly as sent +- `configHash` — SHA-256 of the canonicalized config + +Bookings store the `policyVersionId` that was active at booking time. This enables full auditability — you can always determine what rules applied to a booking by joining on the version. + ## Creating a policy ```bash curl -X POST https://api.floyd.run/v1/ledgers/{ledgerId}/policies \ -H "Content-Type: application/json" \ -d '{ + "name": "Weekday Hours", + "description": "Mon-Fri 9am-5pm with 30-minute slots", "config": { "schema_version": 1, - "default": "closed", - "config": { + "default_availability": "closed", + "constraints": { "duration": { "min_minutes": 30, "max_minutes": 120, @@ -58,6 +71,14 @@ curl -X POST https://api.floyd.run/v1/ledgers/{ledgerId}/policies \ }' ``` +### Fields + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | -------------------------------- | +| `name` | string | No | Display name (max 100 chars) | +| `description` | string | No | Description (max 500 chars) | +| `config` | object | Yes | Policy configuration (see below) | + ## Config format Policies use an **authoring format** with friendly units. Floyd normalizes everything to milliseconds for storage. @@ -70,7 +91,13 @@ Policies use an **authoring format** with friendly units. Floyd normalizes every If both `*_ms` and a friendly unit are provided, `*_ms` takes precedence. -### Duration +The original authoring input is preserved in `configSource` on the response, so read-modify-write preserves the authoring shape. + +### Constraints + +Constraints are the base scheduling parameters. They live under `config.constraints`. + +#### Duration Controls allowed booking lengths. @@ -80,7 +107,7 @@ Controls allowed booking lengths. | `max_ms` | Maximum duration in milliseconds | | `allowed_ms` | Exact allowed durations (takes priority) | -### Grid +#### Grid Constrains start times to fixed intervals. @@ -88,7 +115,7 @@ Constrains start times to fixed intervals. | ------------- | ------------------------------------- | | `interval_ms` | Start times must be multiples of this | -### Lead time +#### Lead time Controls how far in advance bookings can be made. @@ -97,7 +124,7 @@ Controls how far in advance bookings can be made. | `min_ms` | Minimum lead time before booking start | | `max_ms` | Maximum lead time before booking start | -### Buffers +#### Buffers Adds padding around allocations for setup/cleanup time. @@ -108,7 +135,7 @@ Adds padding around allocations for setup/cleanup time. ## 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. +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 constraint overrides. ### Match types @@ -146,7 +173,7 @@ Mark dates as completely unavailable: { "match": { "type": "date", "date": "2026-12-25" }, "closed": true } ``` -Closed rules cannot have `windows` or `config`. +Closed rules cannot have `windows` or `overrides`. ### Windows @@ -162,30 +189,69 @@ Define open time ranges for a rule: } ``` -### Config overrides +### Constraint overrides -Rules can override the base config at the section level: +Rules can override the base constraints at the section level: ```json { "match": { "type": "weekly", "days": ["saturday"] }, "windows": [{ "start": "10:00", "end": "14:00" }], - "config": { + "overrides": { "duration": { "max_minutes": 60 } } } ``` -When a rule matches, its config sections **replace** (not merge) the corresponding base config sections. +When a rule matches, its override sections **replace** (not merge) the corresponding base constraint sections. If a rule overrides `duration`, the rule owns the entire `duration` section — base duration constraints do not apply for that match. ## Default behavior -- `"default": "open"` — all times are bookable unless restricted by rules -- `"default": "closed"` — nothing is bookable unless opened by rule windows +- `"default_availability": "open"` — all times are bookable unless restricted by rules +- `"default_availability": "closed"` — nothing is bookable unless opened by rule windows + +## Response format + +```json +{ + "data": { + "id": "pol_...", + "ledgerId": "ldg_...", + "name": "Weekday Hours", + "description": "Mon-Fri 9am-5pm with 30-minute slots", + "currentVersionId": "pvr_...", + "config": { + "schema_version": 1, + "default_availability": "closed", + "constraints": { + "duration": { "min_ms": 1800000, "max_ms": 7200000, "allowed_ms": [1800000, 3600000, 5400000, 7200000] }, + "grid": { "interval_ms": 1800000 }, + "lead_time": { "min_ms": 3600000, "max_ms": 2592000000 }, + "buffers": { "before_ms": 300000, "after_ms": 600000 } + }, + "rules": [...] + }, + "configSource": { + "schema_version": 1, + "default_availability": "closed", + "constraints": { + "duration": { "min_minutes": 30, "max_minutes": 120, "allowed_minutes": [30, 60, 90, 120] }, + "grid": { "interval_minutes": 30 }, + "lead_time": { "min_hours": 1, "max_days": 30 }, + "buffers": { "before_minutes": 5, "after_minutes": 10 } + }, + "rules": [...] + }, + "configHash": "sha256:...", + "createdAt": "2026-01-04T10:00:00.000Z", + "updatedAt": "2026-01-04T10:00:00.000Z" + } +} +``` ## Config hashing -Each policy config is canonicalized and hashed (SHA-256). The `configHash` field in the response can be used to detect duplicate configurations. +Each policy config is canonicalized and hashed (SHA-256). The `configHash` field in the response can be used to detect duplicate configurations. Rule IDs are excluded from the hash — two configs with identical evaluation semantics but different rule IDs produce the same hash. ## Common patterns @@ -194,8 +260,8 @@ Each policy config is canonicalized and hashed (SHA-256). The `configHash` field ```json { "schema_version": 1, - "default": "closed", - "config": { + "default_availability": "closed", + "constraints": { "duration": { "allowed_minutes": [30, 60, 90] }, "grid": { "interval_minutes": 30 }, "buffers": { "after_minutes": 10 } @@ -214,8 +280,8 @@ Each policy config is canonicalized and hashed (SHA-256). The `configHash` field ```json { "schema_version": 1, - "default": "closed", - "config": { + "default_availability": "closed", + "constraints": { "duration": { "allowed_minutes": [15, 30] }, "grid": { "interval_minutes": 15 }, "lead_time": { "min_hours": 2 } @@ -237,8 +303,8 @@ Each policy config is canonicalized and hashed (SHA-256). The `configHash` field ```json { "schema_version": 1, - "default": "open", - "config": { + "default_availability": "open", + "constraints": { "duration": { "min_minutes": 60, "max_hours": 4 }, "grid": { "interval_minutes": 60 } }, diff --git a/docs/quickstart.md b/docs/quickstart.md index 24c1b43..17dba0f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -127,15 +127,59 @@ Response: } ``` -## 2) Create a service +## 2) Create a policy -A service groups resources and optionally attaches a policy. +Policies define scheduling rules. For the quickstart, we'll create an open policy (no restrictions). + +```bash +curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/policies" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Open", + "config": { + "schema_version": 1, + "default_availability": "open", + "constraints": {} + } + }' +``` + +Response: + +```json +{ + "data": { + "id": "pol_01abc123def456ghi789jkl012", + "ledgerId": "ldg_01abc123def456ghi789jkl012", + "name": "Open", + "description": null, + "currentVersionId": "pvr_01abc123def456ghi789jkl012", + "config": { + "schema_version": 1, + "default_availability": "open", + "constraints": {}, + "rules": [] + }, + "configSource": { "schema_version": 1, "default_availability": "open", "constraints": {} }, + "configHash": "sha256:...", + "createdAt": "2026-01-04T10:00:00.000Z", + "updatedAt": "2026-01-04T10:00:00.000Z" + } +} +``` + +See [Policies](./policies.md) for working hours, durations, grid alignment, and more. + +## 3) Create a service + +A service groups resources and attaches a policy for booking rules. ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services" \ -H "Content-Type: application/json" \ -d '{ "name": "Haircut", + "policyId": "pol_01abc123def456ghi789jkl012", "resourceIds": ["rsc_01abc123def456ghi789jkl012"] }' ``` @@ -148,7 +192,7 @@ Response: "id": "svc_01abc123def456ghi789jkl012", "ledgerId": "ldg_01abc123def456ghi789jkl012", "name": "Haircut", - "policyId": null, + "policyId": "pol_01abc123def456ghi789jkl012", "resourceIds": ["rsc_01abc123def456ghi789jkl012"], "metadata": null, "createdAt": "2026-01-04T10:00:00.000Z", @@ -157,7 +201,7 @@ Response: } ``` -## 3) Create a hold +## 4) Create a hold ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" \ @@ -180,6 +224,7 @@ Response: "id": "bkg_01abc123def456ghi789jkl012", "ledgerId": "ldg_01abc123def456ghi789jkl012", "serviceId": "svc_01abc123def456ghi789jkl012", + "policyVersionId": "pvr_01abc123def456ghi789jkl012", "status": "hold", "expiresAt": "2026-01-04T10:15:00.000Z", "allocations": [ @@ -188,6 +233,7 @@ Response: "resourceId": "rsc_01abc123def456ghi789jkl012", "startTime": "2026-01-04T10:00:00.000Z", "endTime": "2026-01-04T10:30:00.000Z", + "buffer": { "beforeMs": 0, "afterMs": 0 }, "active": true } ], @@ -207,7 +253,7 @@ Error responses: - `409 Conflict` if the service's policy rejects the request - `422 Unprocessable Entity` if your input is invalid -## 4) Confirm the booking +## 5) Confirm the booking ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/confirm" \ @@ -220,7 +266,7 @@ Expected: - `409 Conflict` if the hold expired before you confirmed - Safe to retry (confirming an already confirmed booking returns the confirmed booking) -## 5) Cancel a booking +## 6) Cancel a booking ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID/cancel" \ @@ -232,7 +278,7 @@ Expected: - `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 +## 7) Get a booking ```bash curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID" @@ -240,7 +286,7 @@ curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings/$BOOKING_ID" Returns the booking with nested allocations. -## 7) List bookings +## 8) List bookings ```bash curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/bookings" diff --git a/docs/services.md b/docs/services.md index f456f0e..299da07 100644 --- a/docs/services.md +++ b/docs/services.md @@ -86,9 +86,10 @@ curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/services" 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 +1. The service must have a policy attached +2. The resource must belong to the service +3. The booking is evaluated against the policy's rules +4. 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. @@ -108,10 +109,11 @@ Both endpoints respect the service's policy (working hours, grid, buffers, lead curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/policies" \ -H "Content-Type: application/json" \ -d '{ + "name": "Salon Hours", "config": { "schema_version": 1, - "default": "closed", - "config": { + "default_availability": "closed", + "constraints": { "duration": { "allowed_minutes": [30, 60, 90] }, "grid": { "interval_minutes": 30 }, "buffers": { "after_minutes": 10 } From a3d7581514dd0f8cd4afe22691cdd06e32a54f25 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 21:36:23 +0300 Subject: [PATCH 3/8] Fix small issues --- apps/server/src/database/schema.ts | 4 ++-- apps/server/src/operations/booking/create.ts | 2 +- apps/server/src/operations/policy/get.ts | 2 +- apps/server/src/operations/policy/list.ts | 15 ++++++--------- apps/server/src/operations/policy/update.ts | 4 ++-- packages/schema/inputs/policy.ts | 8 ++++---- 6 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index 9f5e2cd..ae4907b 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -1,4 +1,4 @@ -import type { Generated, Insertable, Selectable, Updateable } from "kysely"; +import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely"; import type { BookingStatus, IdempotencyStatus } from "@floyd-run/schema/types"; export interface LedgersTable { @@ -89,7 +89,7 @@ export interface PoliciesTable { ledgerId: string; name: string | null; description: string | null; - currentVersionId: string | null; + currentVersionId: ColumnType; createdAt: Generated; updatedAt: Generated; } diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index bf47305..c10fd4a 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -73,7 +73,7 @@ export default createOperation({ const version = await trx .selectFrom("policyVersions") .selectAll() - .where("id", "=", policyRow.currentVersionId!) + .where("id", "=", policyRow.currentVersionId) .executeTakeFirstOrThrow(); const policyVersionId = version.id; diff --git a/apps/server/src/operations/policy/get.ts b/apps/server/src/operations/policy/get.ts index d8c3e06..f3deb42 100644 --- a/apps/server/src/operations/policy/get.ts +++ b/apps/server/src/operations/policy/get.ts @@ -12,7 +12,7 @@ export default createOperation({ .where("ledgerId", "=", input.ledgerId) .executeTakeFirst(); - if (!policy || !policy.currentVersionId) { + if (!policy) { return { policy: null, version: null }; } diff --git a/apps/server/src/operations/policy/list.ts b/apps/server/src/operations/policy/list.ts index db59d86..aac6250 100644 --- a/apps/server/src/operations/policy/list.ts +++ b/apps/server/src/operations/policy/list.ts @@ -11,9 +11,7 @@ export default createOperation({ .where("ledgerId", "=", input.ledgerId) .execute(); - const versionIds = policies - .map((p) => p.currentVersionId) - .filter((id): id is string => id !== null); + const versionIds = policies.map((p) => p.currentVersionId); const versions = versionIds.length > 0 @@ -23,12 +21,11 @@ export default createOperation({ const versionMap = new Map(versions.map((v) => [v.id, v])); return { - policies: policies - .filter((p) => p.currentVersionId !== null) - .map((p) => ({ - policy: p, - version: versionMap.get(p.currentVersionId!)!, - })), + policies: policies.flatMap((p) => { + const version = versionMap.get(p.currentVersionId); + if (!version) return []; + return [{ policy: p, version }]; + }), }; }, }); diff --git a/apps/server/src/operations/policy/update.ts b/apps/server/src/operations/policy/update.ts index 20edfc3..0c74c3e 100644 --- a/apps/server/src/operations/policy/update.ts +++ b/apps/server/src/operations/policy/update.ts @@ -46,8 +46,8 @@ export default createOperation({ .updateTable("policies") .set({ currentVersionId: versionId, - name: input.name ?? existing.name, - description: input.description ?? existing.description, + name: input.name !== undefined ? input.name : existing.name, + description: input.description !== undefined ? input.description : existing.description, }) .where("id", "=", input.id) .where("ledgerId", "=", input.ledgerId) diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts index a68c275..a05716c 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -164,16 +164,16 @@ const policyConfigSchema = z export const create = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - name: z.string().max(100).optional(), - description: z.string().max(500).optional(), + name: z.string().max(100).nullable().optional(), + description: z.string().max(500).nullable().optional(), config: policyConfigSchema, }); export const update = z.object({ id: z.string().refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }), ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - name: z.string().max(100).optional(), - description: z.string().max(500).optional(), + name: z.string().max(100).nullable().optional(), + description: z.string().max(500).nullable().optional(), config: policyConfigSchema, }); From f3cc2912dd24d0a4254c2844ec25838d3033bcb5 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 21:42:54 +0300 Subject: [PATCH 4/8] Update 20260217165155_add-policy-versions-table.ts --- ...0260217165155_add-policy-versions-table.ts | 39 +++---------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts index 65b90fa..4b1b48d 100644 --- a/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts +++ b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts @@ -26,53 +26,26 @@ export async function up(db: Kysely): Promise { await db.schema.alterTable("policies").addColumn("current_version_id", "varchar(32)").execute(); - // 3. Migrate existing policies into policy_versions (pure SQL to avoid CamelCasePlugin) - await sql` - INSERT INTO policy_versions (id, policy_id, config, config_source, config_hash, created_at) - SELECT - 'pvr_' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 26), - id, config, config, config_hash, NOW() - FROM policies - `.execute(db); - - await sql` - UPDATE policies p - SET current_version_id = pv.id - FROM policy_versions pv - WHERE pv.policy_id = p.id - `.execute(db); - - // 4. Add FK constraint for current_version_id (after data migration) + // 3. Add FK constraint for current_version_id await sql` ALTER TABLE policies ADD CONSTRAINT fk_policies_current_version FOREIGN KEY (current_version_id) REFERENCES policy_versions(id) `.execute(db); - // 5. Drop old columns from policies + // 4. Drop old columns from policies await db.schema.dropIndex("idx_policies_config_hash").ifExists().execute(); await db.schema.alterTable("policies").dropColumn("config").execute(); await db.schema.alterTable("policies").dropColumn("config_hash").execute(); - // 6. Replace policy_id with policy_version_id on bookings + // 5. Replace policy_id with policy_version_id on bookings await db.schema .alterTable("bookings") - .addColumn("policy_version_id", "varchar(32)", (col) => col.references("policy_versions.id")) + .addColumn("policy_version_id", "varchar(32)", (col) => + col.notNull().references("policy_versions.id"), + ) .execute(); - // Migrate existing bookings that have a policy_id - await sql` - UPDATE bookings b - SET policy_version_id = p.current_version_id - FROM policies p - WHERE b.policy_id = p.id - `.execute(db); - - // Make it NOT NULL after data migration - await sql` - ALTER TABLE bookings ALTER COLUMN policy_version_id SET NOT NULL - `.execute(db); - await db.schema .createIndex("idx_bookings_policy_version") .on("bookings") From f1fb970087e33c8560da450a2220f0d486a1b9a6 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 21:58:32 +0300 Subject: [PATCH 5/8] Add more tests --- .../integration/v1/bookings/create.spec.ts | 20 +++++++ .../integration/v1/policies/create.spec.ts | 50 +++++++++++++++++ .../integration/v1/policies/delete.spec.ts | 37 ++++++++++++- .../test/integration/v1/policies/get.spec.ts | 4 ++ .../integration/v1/policies/update.spec.ts | 53 +++++++++++++++++++ 5 files changed, 163 insertions(+), 1 deletion(-) diff --git a/apps/server/test/integration/v1/bookings/create.spec.ts b/apps/server/test/integration/v1/bookings/create.spec.ts index d92926b..6bdd597 100644 --- a/apps/server/test/integration/v1/bookings/create.spec.ts +++ b/apps/server/test/integration/v1/bookings/create.spec.ts @@ -220,6 +220,26 @@ describe("POST /v1/ledgers/:ledgerId/bookings", () => { expect(response.status).toBe(404); }); + it("returns 409 when service has 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 response = await client.post(`/v1/ledgers/${ledger.id}/bookings`, { + serviceId: service.id, + resourceId: resource.id, + startTime: "2026-06-01T10:00:00.000Z", + endTime: "2026-06-01T11:00:00.000Z", + }); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("service.no_policy"); + }); + it("returns 409 when resource does not belong to service", async () => { const { ledger } = await createLedger(); const { resource: r1 } = await createResource({ ledgerId: ledger.id }); diff --git a/apps/server/test/integration/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts index 002eba6..a9b9dd0 100644 --- a/apps/server/test/integration/v1/policies/create.spec.ts +++ b/apps/server/test/integration/v1/policies/create.spec.ts @@ -76,6 +76,56 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { expect(data.configHash.length).toBeGreaterThan(0); }); + it("preserves configSource with original authoring units", 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 }; + + // configSource should contain the original authoring format + const source = data.configSource as Record; + const constraints = source["constraints"] as Record>; + expect(constraints["duration"]!["allowed_minutes"]).toEqual([30, 60]); + expect(constraints["grid"]!["interval_minutes"]).toBe(15); + + // config should contain normalized ms values + const config = data.config; + const normalizedConstraints = config["constraints"] as Record>; + expect(normalizedConstraints["duration"]!["allowed_ms"]).toEqual([1800000, 3600000]); + }); + + it("accepts name and description", async () => { + const { ledger } = await createLedger(); + + const response = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + name: "Weekday Hours", + description: "Standard business hours policy", + config: validAuthoringConfig, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Policy }; + expect(data.name).toBe("Weekday Hours"); + expect(data.description).toBe("Standard business hours policy"); + }); + + it("defaults name and description to null", 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.name).toBeNull(); + expect(data.description).toBeNull(); + }); + it("returns 422 for invalid schema_version", async () => { const { ledger } = await createLedger(); diff --git a/apps/server/test/integration/v1/policies/delete.spec.ts b/apps/server/test/integration/v1/policies/delete.spec.ts index ea4d5ea..7d01357 100644 --- a/apps/server/test/integration/v1/policies/delete.spec.ts +++ b/apps/server/test/integration/v1/policies/delete.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import { client } from "../../setup/client"; -import { createLedger, createPolicy } from "../../setup/factories"; +import { + createLedger, + createPolicy, + createService, + createResource, + createBooking, +} from "../../setup/factories"; import type { Policy } from "@floyd-run/schema/types"; describe("DELETE /v1/ledgers/:ledgerId/policies/:id", () => { @@ -27,4 +33,33 @@ describe("DELETE /v1/ledgers/:ledgerId/policies/:id", () => { expect(response.status).toBe(404); }); + + it("returns 409 when policy is referenced by a service", async () => { + const { ledger } = await createLedger(); + const { policy } = await createPolicy({ ledgerId: ledger.id }); + await createService({ ledgerId: ledger.id, policyId: policy.id }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/policies/${policy.id}`); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("policy.in_use"); + }); + + it("returns 409 when policy version is referenced by a booking", async () => { + const { ledger } = await createLedger(); + const { policy, version } = await createPolicy({ ledgerId: ledger.id }); + const { resource } = await createResource({ ledgerId: ledger.id }); + await createBooking({ + ledgerId: ledger.id, + resourceId: resource.id, + policyVersionId: version.id, + }); + + const response = await client.delete(`/v1/ledgers/${ledger.id}/policies/${policy.id}`); + + expect(response.status).toBe(409); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("policy.in_use"); + }); }); diff --git a/apps/server/test/integration/v1/policies/get.spec.ts b/apps/server/test/integration/v1/policies/get.spec.ts index 58a08b9..1218627 100644 --- a/apps/server/test/integration/v1/policies/get.spec.ts +++ b/apps/server/test/integration/v1/policies/get.spec.ts @@ -13,8 +13,12 @@ describe("GET /v1/ledgers/:ledgerId/policies/:id", () => { const { data } = (await response.json()) as { data: Policy }; expect(data.id).toBe(policy.id); expect(data.ledgerId).toBe(ledgerId); + expect(data.currentVersionId).toMatch(/^pvr_/); expect(data.config).toBeDefined(); + expect(data.configSource).toBeDefined(); expect(data.configHash).toBeDefined(); + expect(data.name).toBeNull(); + expect(data.description).toBeNull(); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); diff --git a/apps/server/test/integration/v1/policies/update.spec.ts b/apps/server/test/integration/v1/policies/update.spec.ts index 9d7cef2..fed27c5 100644 --- a/apps/server/test/integration/v1/policies/update.spec.ts +++ b/apps/server/test/integration/v1/policies/update.spec.ts @@ -69,6 +69,59 @@ describe("PUT /v1/ledgers/:ledgerId/policies/:id", () => { expect(data.configHash).not.toBe(originalHash); }); + it("creates a new version on update", async () => { + const { policy, version: originalVersion, ledgerId } = await createPolicy(); + + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + config: updatedConfig, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy }; + expect(data.currentVersionId).toMatch(/^pvr_/); + expect(data.currentVersionId).not.toBe(originalVersion.id); + }); + + it("updates name and description", async () => { + const { policy, ledgerId } = await createPolicy(); + + const response = await client.put(`/v1/ledgers/${ledgerId}/policies/${policy.id}`, { + name: "Updated Name", + description: "Updated description", + config: updatedConfig, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy }; + expect(data.name).toBe("Updated Name"); + expect(data.description).toBe("Updated description"); + }); + + it("clears name and description with null", async () => { + const { ledger } = await createLedger(); + + // Create with name/description + const createResponse = await client.post(`/v1/ledgers/${ledger.id}/policies`, { + name: "Original Name", + description: "Original description", + config: validConfig, + }); + const { data: created } = (await createResponse.json()) as { data: Policy }; + expect(created.name).toBe("Original Name"); + + // Update with null to clear + const response = await client.put(`/v1/ledgers/${ledger.id}/policies/${created.id}`, { + name: null, + description: null, + config: updatedConfig, + }); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Policy }; + expect(data.name).toBeNull(); + expect(data.description).toBeNull(); + }); + it("returns 404 for non-existent policy", async () => { const { ledger } = await createLedger(); From 214e9416471a81fd700e0da7adae0cf0ec1f55d2 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 22:40:09 +0300 Subject: [PATCH 6/8] Update some columns --- apps/server/openapi.json | 131 +++++++++++++----- apps/server/src/database/schema.ts | 10 +- ...0260217165155_add-policy-versions-table.ts | 44 +++++- .../src/operations/allocation/create.ts | 2 +- .../operations/allocation/internal/insert.ts | 2 +- apps/server/src/operations/booking/create.ts | 4 +- apps/server/src/operations/resource/create.ts | 2 + apps/server/src/operations/service/create.ts | 4 +- apps/server/src/operations/service/update.ts | 4 +- apps/server/src/routes/v1/serializers.ts | 2 + apps/server/src/scripts/generate-openapi.ts | 29 ++-- .../setup/factories/allocation.factory.ts | 4 +- .../setup/factories/booking.factory.ts | 6 +- .../setup/factories/resource.factory.ts | 9 +- .../setup/factories/service.factory.ts | 4 +- .../integration/v1/resources/create.spec.ts | 39 ++++++ .../integration/v1/services/create.spec.ts | 8 +- .../integration/v1/services/update.spec.ts | 8 +- packages/schema/inputs/allocation.ts | 2 +- packages/schema/inputs/booking.ts | 2 +- packages/schema/inputs/policy.ts | 4 +- packages/schema/inputs/resource.ts | 2 + packages/schema/inputs/service.ts | 8 +- packages/schema/outputs/allocation.ts | 2 +- packages/schema/outputs/booking.ts | 2 +- packages/schema/outputs/resource.ts | 2 + packages/schema/outputs/service.ts | 4 +- 27 files changed, 257 insertions(+), 83 deletions(-) diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 8459068..363eae2 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -43,9 +43,16 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, "timezone": { "type": ["string", "null"] }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, "createdAt": { "type": "string" }, @@ -53,7 +60,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "name", "timezone", "metadata", "createdAt", "updatedAt"] }, "Allocation": { "type": "object", @@ -95,7 +102,7 @@ "type": ["string", "null"] }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -317,7 +324,7 @@ "type": "string" }, "name": { - "type": "string" + "type": ["string", "null"] }, "policyId": { "type": ["string", "null"] @@ -329,7 +336,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -409,7 +416,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -659,9 +666,16 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, "timezone": { "type": ["string", "null"] }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, "createdAt": { "type": "string" }, @@ -669,7 +683,15 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "name", + "timezone", + "metadata", + "createdAt", + "updatedAt" + ] } } }, @@ -699,10 +721,19 @@ "schema": { "type": "object", "properties": { + "name": { + "type": ["string", "null"], + "description": "Human-readable name for the resource", + "example": "Room A" + }, "timezone": { "type": "string", "description": "IANA timezone for the resource (e.g. America/New_York)", "example": "America/New_York" + }, + "metadata": { + "type": "object", + "additionalProperties": {} } }, "required": ["timezone"] @@ -727,9 +758,16 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, "timezone": { "type": ["string", "null"] }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, "createdAt": { "type": "string" }, @@ -737,7 +775,15 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "name", + "timezone", + "metadata", + "createdAt", + "updatedAt" + ] } }, "required": ["data"] @@ -787,9 +833,16 @@ "ledgerId": { "type": "string" }, + "name": { + "type": ["string", "null"] + }, "timezone": { "type": ["string", "null"] }, + "metadata": { + "type": "object", + "additionalProperties": {} + }, "createdAt": { "type": "string" }, @@ -797,7 +850,15 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "name", + "timezone", + "metadata", + "createdAt", + "updatedAt" + ] } }, "required": ["data"] @@ -1060,7 +1121,7 @@ "type": ["string", "null"] }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -1132,7 +1193,7 @@ "description": "If set, the allocation auto-expires after this time" }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} } }, @@ -1189,7 +1250,7 @@ "type": ["string", "null"] }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -1335,7 +1396,7 @@ "type": ["string", "null"] }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -1539,7 +1600,7 @@ "type": "string" }, "name": { - "type": "string" + "type": ["string", "null"] }, "policyId": { "type": ["string", "null"] @@ -1551,7 +1612,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -1602,7 +1663,7 @@ "type": "object", "properties": { "name": { - "type": "string", + "type": ["string", "null"], "description": "Service name", "example": "Haircut" }, @@ -1619,11 +1680,10 @@ "example": ["rsc_01abc123def456ghi789jkl012"] }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} } - }, - "required": ["name"] + } } } } @@ -1646,7 +1706,7 @@ "type": "string" }, "name": { - "type": "string" + "type": ["string", "null"] }, "policyId": { "type": ["string", "null"] @@ -1658,7 +1718,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -1761,7 +1821,7 @@ "type": "string" }, "name": { - "type": "string" + "type": ["string", "null"] }, "policyId": { "type": ["string", "null"] @@ -1773,7 +1833,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -1864,7 +1924,7 @@ "type": "object", "properties": { "name": { - "type": "string", + "type": ["string", "null"], "description": "Service name", "example": "Haircut" }, @@ -1881,11 +1941,10 @@ "example": ["rsc_01abc123def456ghi789jkl012"] }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} } - }, - "required": ["name"] + } } } } @@ -1908,7 +1967,7 @@ "type": "string" }, "name": { - "type": "string" + "type": ["string", "null"] }, "policyId": { "type": ["string", "null"] @@ -1920,7 +1979,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -2541,7 +2600,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -2618,7 +2677,7 @@ "description": "Initial status. Hold creates a temporary reservation." }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} } }, @@ -2701,7 +2760,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -2905,7 +2964,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -3076,7 +3135,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -3280,7 +3339,7 @@ } }, "metadata": { - "type": ["object", "null"], + "type": "object", "additionalProperties": {} }, "createdAt": { @@ -3491,7 +3550,7 @@ "properties": { "name": { "type": ["string", "null"], - "description": "Display name (max 100 chars)", + "description": "Display name (max 255 chars)", "example": "Weekday Hours" }, "description": { @@ -3750,7 +3809,7 @@ "properties": { "name": { "type": ["string", "null"], - "description": "Display name (max 100 chars)" + "description": "Display name (max 255 chars)" }, "description": { "type": ["string", "null"], diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index ae4907b..fc7d09c 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -10,7 +10,9 @@ export interface LedgersTable { export interface ResourcesTable { id: string; ledgerId: string; + name: string | null; timezone: string; + metadata: Record; createdAt: Generated; updatedAt: Generated; } @@ -26,7 +28,7 @@ export interface AllocationsTable { bufferBeforeMs: number; bufferAfterMs: number; expiresAt: Date | null; - metadata: Record | null; + metadata: Record; createdAt: Generated; updatedAt: Generated; } @@ -35,8 +37,8 @@ export interface ServicesTable { id: string; ledgerId: string; policyId: string | null; - name: string; - metadata: Record | null; + name: string | null; + metadata: Record; createdAt: Generated; updatedAt: Generated; } @@ -53,7 +55,7 @@ export interface BookingsTable { policyVersionId: string; status: BookingStatus; expiresAt: Date | null; - metadata: Record | null; + metadata: Record; createdAt: Generated; updatedAt: Generated; } diff --git a/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts index 4b1b48d..fbbdf9f 100644 --- a/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts +++ b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts @@ -20,7 +20,7 @@ export async function up(db: Kysely): Promise { .execute(); // 2. Add name, description, current_version_id to policies - await db.schema.alterTable("policies").addColumn("name", "varchar(100)").execute(); + await db.schema.alterTable("policies").addColumn("name", "varchar(255)").execute(); await db.schema.alterTable("policies").addColumn("description", "varchar(500)").execute(); @@ -54,6 +54,30 @@ export async function up(db: Kysely): Promise { // Drop old policy_id column from bookings await db.schema.alterTable("bookings").dropColumn("policy_id").execute(); + + // 6. Add name and metadata to resources + await db.schema.alterTable("resources").addColumn("name", "varchar(255)").execute(); + + await db.schema + .alterTable("resources") + .addColumn("metadata", "jsonb", (col) => col.notNull().defaultTo(sql`'{}'::jsonb`)) + .execute(); + + // 7. Make service name nullable + await sql`ALTER TABLE services ALTER COLUMN name DROP NOT NULL`.execute(db); + + // 8. Make existing metadata columns NOT NULL with default {} + await sql`ALTER TABLE allocations ALTER COLUMN metadata SET DEFAULT '{}'::jsonb`.execute(db); + await sql`UPDATE allocations SET metadata = '{}' WHERE metadata IS NULL`.execute(db); + await sql`ALTER TABLE allocations ALTER COLUMN metadata SET NOT NULL`.execute(db); + + await sql`ALTER TABLE services ALTER COLUMN metadata SET DEFAULT '{}'::jsonb`.execute(db); + await sql`UPDATE services SET metadata = '{}' WHERE metadata IS NULL`.execute(db); + await sql`ALTER TABLE services ALTER COLUMN metadata SET NOT NULL`.execute(db); + + await sql`ALTER TABLE bookings ALTER COLUMN metadata SET DEFAULT '{}'::jsonb`.execute(db); + await sql`UPDATE bookings SET metadata = '{}' WHERE metadata IS NULL`.execute(db); + await sql`ALTER TABLE bookings ALTER COLUMN metadata SET NOT NULL`.execute(db); } export async function down(db: Kysely): Promise { @@ -93,6 +117,24 @@ export async function down(db: Kysely): Promise { .columns(["ledger_id", "config_hash"]) .execute(); + // Revert metadata columns to nullable + await sql`ALTER TABLE bookings ALTER COLUMN metadata DROP NOT NULL`.execute(db); + await sql`ALTER TABLE bookings ALTER COLUMN metadata DROP DEFAULT`.execute(db); + + await sql`ALTER TABLE services ALTER COLUMN metadata DROP NOT NULL`.execute(db); + await sql`ALTER TABLE services ALTER COLUMN metadata DROP DEFAULT`.execute(db); + + await sql`ALTER TABLE allocations ALTER COLUMN metadata DROP NOT NULL`.execute(db); + await sql`ALTER TABLE allocations ALTER COLUMN metadata DROP DEFAULT`.execute(db); + + // Restore service name as NOT NULL + await sql`UPDATE services SET name = '' WHERE name IS NULL`.execute(db); + await sql`ALTER TABLE services ALTER COLUMN name SET NOT NULL`.execute(db); + + // Drop name and metadata from resources + await sql`ALTER TABLE resources DROP COLUMN IF EXISTS metadata`.execute(db); + await sql`ALTER TABLE resources DROP COLUMN IF EXISTS name`.execute(db); + // Drop FK constraint, new columns, and policy_versions table await sql`ALTER TABLE policies DROP CONSTRAINT IF EXISTS fk_policies_current_version`.execute(db); await db.schema.alterTable("policies").dropColumn("current_version_id").execute(); diff --git a/apps/server/src/operations/allocation/create.ts b/apps/server/src/operations/allocation/create.ts index b5c23cd..a2c6fe0 100644 --- a/apps/server/src/operations/allocation/create.ts +++ b/apps/server/src/operations/allocation/create.ts @@ -36,7 +36,7 @@ export default createOperation({ bufferBeforeMs: 0, bufferAfterMs: 0, expiresAt: input.expiresAt ?? null, - metadata: input.metadata ?? null, + metadata: input.metadata, serverTime, }); diff --git a/apps/server/src/operations/allocation/internal/insert.ts b/apps/server/src/operations/allocation/internal/insert.ts index 829056b..9bad7f8 100644 --- a/apps/server/src/operations/allocation/internal/insert.ts +++ b/apps/server/src/operations/allocation/internal/insert.ts @@ -12,7 +12,7 @@ interface InsertAllocationParams { bufferBeforeMs: number; bufferAfterMs: number; expiresAt: Date | null; - metadata: Record | null; + metadata: Record; serverTime: Date; } diff --git a/apps/server/src/operations/booking/create.ts b/apps/server/src/operations/booking/create.ts index c10fd4a..9e6577e 100644 --- a/apps/server/src/operations/booking/create.ts +++ b/apps/server/src/operations/booking/create.ts @@ -124,7 +124,7 @@ export default createOperation({ policyVersionId, status: input.status, expiresAt, - metadata: input.metadata ?? null, + metadata: input.metadata, }) .returningAll() .executeTakeFirstOrThrow(); @@ -139,7 +139,7 @@ export default createOperation({ bufferBeforeMs, bufferAfterMs, expiresAt, - metadata: null, + metadata: {}, serverTime, }); diff --git a/apps/server/src/operations/resource/create.ts b/apps/server/src/operations/resource/create.ts index 60f8c7b..55a7e6b 100644 --- a/apps/server/src/operations/resource/create.ts +++ b/apps/server/src/operations/resource/create.ts @@ -11,7 +11,9 @@ export default createOperation({ .values({ id: generateId("rsc"), ledgerId: input.ledgerId, + name: input.name ?? null, timezone: input.timezone, + metadata: input.metadata, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/src/operations/service/create.ts b/apps/server/src/operations/service/create.ts index 61e53db..0361d25 100644 --- a/apps/server/src/operations/service/create.ts +++ b/apps/server/src/operations/service/create.ts @@ -45,8 +45,8 @@ export default createOperation({ id: generateId("svc"), ledgerId: input.ledgerId, policyId: input.policyId ?? null, - name: input.name, - metadata: input.metadata ?? null, + name: input.name ?? null, + metadata: input.metadata, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/src/operations/service/update.ts b/apps/server/src/operations/service/update.ts index 789fb90..8995ee2 100644 --- a/apps/server/src/operations/service/update.ts +++ b/apps/server/src/operations/service/update.ts @@ -54,9 +54,9 @@ export default createOperation({ const service = await trx .updateTable("services") .set({ - name: input.name, + name: input.name ?? null, policyId: input.policyId ?? null, - metadata: input.metadata ?? null, + metadata: input.metadata, }) .where("id", "=", input.id) .returningAll() diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index 2ca8cea..ef28519 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -20,7 +20,9 @@ export function serializeResource(resource: ResourceRow): Resource { return { id: resource.id, ledgerId: resource.ledgerId, + name: resource.name, timezone: resource.timezone, + metadata: resource.metadata, createdAt: resource.createdAt.toISOString(), updatedAt: resource.updatedAt.toISOString(), }; diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index dcf4221..d131cc0 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -130,10 +130,15 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ + name: z.string().nullable().optional().openapi({ + description: "Human-readable name for the resource", + example: "Room A", + }), timezone: z.string().openapi({ description: "IANA timezone for the resource (e.g. America/New_York)", example: "America/New_York", }), + metadata: z.record(z.string(), z.unknown()).optional(), }), }, }, @@ -258,7 +263,7 @@ registry.registerPath({ 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(), + metadata: z.record(z.string(), z.unknown()).optional(), }), }, }, @@ -346,7 +351,11 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - name: z.string().openapi({ description: "Service name", example: "Haircut" }), + name: z + .string() + .nullable() + .optional() + .openapi({ description: "Service name", example: "Haircut" }), policyId: z .string() .nullable() @@ -359,7 +368,7 @@ registry.registerPath({ description: "Resources that belong to this service", example: ["rsc_01abc123def456ghi789jkl012"], }), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }), }, }, @@ -390,7 +399,11 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - name: z.string().openapi({ description: "Service name", example: "Haircut" }), + name: z + .string() + .nullable() + .optional() + .openapi({ description: "Service name", example: "Haircut" }), policyId: z .string() .nullable() @@ -403,7 +416,7 @@ registry.registerPath({ description: "Resources that belong to this service", example: ["rsc_01abc123def456ghi789jkl012"], }), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }), }, }, @@ -611,7 +624,7 @@ registry.registerPath({ .enum(["hold", "confirmed"]) .default("hold") .openapi({ description: "Initial status. Hold creates a temporary reservation." }), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }), }, }, @@ -738,7 +751,7 @@ registry.registerPath({ .string() .nullable() .optional() - .openapi({ description: "Display name (max 100 chars)", example: "Weekday Hours" }), + .openapi({ description: "Display name (max 255 chars)", example: "Weekday Hours" }), description: z .string() .nullable() @@ -783,7 +796,7 @@ registry.registerPath({ .string() .nullable() .optional() - .openapi({ description: "Display name (max 100 chars)" }), + .openapi({ description: "Display name (max 255 chars)" }), description: z .string() .nullable() diff --git a/apps/server/test/integration/setup/factories/allocation.factory.ts b/apps/server/test/integration/setup/factories/allocation.factory.ts index bd8bea7..fc5f51b 100644 --- a/apps/server/test/integration/setup/factories/allocation.factory.ts +++ b/apps/server/test/integration/setup/factories/allocation.factory.ts @@ -11,7 +11,7 @@ export async function createAllocation(overrides?: { startTime?: Date; endTime?: Date; expiresAt?: Date | null; - metadata?: Record | null; + metadata?: Record; }) { const ledgerIdParam = overrides?.ledgerId; let ledgerId: string; @@ -48,7 +48,7 @@ export async function createAllocation(overrides?: { bufferBeforeMs: 0, bufferAfterMs: 0, expiresAt: overrides?.expiresAt ?? null, - metadata: overrides?.metadata ?? null, + metadata: overrides?.metadata ?? {}, }) .returningAll() .executeTakeFirst(); diff --git a/apps/server/test/integration/setup/factories/booking.factory.ts b/apps/server/test/integration/setup/factories/booking.factory.ts index 50be44d..253277f 100644 --- a/apps/server/test/integration/setup/factories/booking.factory.ts +++ b/apps/server/test/integration/setup/factories/booking.factory.ts @@ -12,7 +12,7 @@ export async function createBooking(overrides?: { policyVersionId?: string; status?: "hold" | "confirmed" | "canceled" | "expired"; expiresAt?: Date | null; - metadata?: Record | null; + metadata?: Record; startTime?: Date; endTime?: Date; }) { @@ -76,7 +76,7 @@ export async function createBooking(overrides?: { policyVersionId, status, expiresAt, - metadata: overrides?.metadata ?? null, + metadata: overrides?.metadata ?? {}, }) .returningAll() .executeTakeFirstOrThrow(); @@ -94,7 +94,7 @@ export async function createBooking(overrides?: { bufferBeforeMs: 0, bufferAfterMs: 0, expiresAt, - metadata: null, + metadata: {}, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/test/integration/setup/factories/resource.factory.ts b/apps/server/test/integration/setup/factories/resource.factory.ts index 893bb1f..2a4d76b 100644 --- a/apps/server/test/integration/setup/factories/resource.factory.ts +++ b/apps/server/test/integration/setup/factories/resource.factory.ts @@ -2,7 +2,12 @@ import { db } from "database"; import { generateId } from "@floyd-run/utils"; import { createLedger } from "./ledger.factory"; -export async function createResource(overrides?: { ledgerId?: string; timezone?: string }) { +export async function createResource(overrides?: { + ledgerId?: string; + name?: string; + timezone?: string; + metadata?: Record; +}) { let ledgerId = overrides?.ledgerId; if (!ledgerId) { const { ledger } = await createLedger(); @@ -14,7 +19,9 @@ export async function createResource(overrides?: { ledgerId?: string; timezone?: .values({ id: generateId("rsc"), ledgerId, + name: overrides?.name ?? "Test Resource", timezone: overrides?.timezone ?? "UTC", + metadata: overrides?.metadata ?? {}, }) .returningAll() .executeTakeFirst(); diff --git a/apps/server/test/integration/setup/factories/service.factory.ts b/apps/server/test/integration/setup/factories/service.factory.ts index f031e7d..c2ebec1 100644 --- a/apps/server/test/integration/setup/factories/service.factory.ts +++ b/apps/server/test/integration/setup/factories/service.factory.ts @@ -7,7 +7,7 @@ export async function createService(overrides?: { name?: string; policyId?: string | null; resourceIds?: string[]; - metadata?: Record | null; + metadata?: Record; }) { let ledgerId = overrides?.ledgerId; if (!ledgerId) { @@ -22,7 +22,7 @@ export async function createService(overrides?: { ledgerId, name: overrides?.name ?? "Test Service", policyId: overrides?.policyId ?? null, - metadata: overrides?.metadata ?? null, + metadata: overrides?.metadata ?? {}, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/test/integration/v1/resources/create.spec.ts b/apps/server/test/integration/v1/resources/create.spec.ts index 66c2012..e00a91a 100644 --- a/apps/server/test/integration/v1/resources/create.spec.ts +++ b/apps/server/test/integration/v1/resources/create.spec.ts @@ -7,6 +7,7 @@ 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`, { + name: "Room A", timezone: "UTC", }); @@ -14,6 +15,8 @@ describe("POST /v1/ledgers/:ledgerId/resources", () => { const { data } = (await response.json()) as ResourceResponse; expect(data.id).toMatch(/^rsc_/); expect(data.ledgerId).toBe(ledger.id); + expect(data.name).toBe("Room A"); + expect(data.metadata).toEqual({}); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); @@ -21,6 +24,7 @@ describe("POST /v1/ledgers/:ledgerId/resources", () => { it("returns 201 with valid IANA timezone", async () => { const { ledger } = await createLedger(); const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + name: "Room B", timezone: "America/New_York", }); @@ -29,12 +33,47 @@ describe("POST /v1/ledgers/:ledgerId/resources", () => { expect(data.timezone).toBe("America/New_York"); }); + it("returns 201 with custom metadata", async () => { + const { ledger } = await createLedger(); + const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + name: "Room C", + timezone: "UTC", + metadata: { floor: 3, building: "HQ" }, + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as ResourceResponse; + expect(data.metadata).toEqual({ floor: 3, building: "HQ" }); + }); + it("returns 422 for invalid timezone", async () => { const { ledger } = await createLedger(); const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + name: "Room D", timezone: "Not/A_Timezone", }); expect(response.status).toBe(422); }); + + it("returns 201 with null name when not provided", async () => { + const { ledger } = await createLedger(); + const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + timezone: "UTC", + }); + + expect(response.status).toBe(201); + const { data } = (await response.json()) as ResourceResponse; + expect(data.name).toBeNull(); + }); + + it("returns 422 when name exceeds 255 characters", async () => { + const { ledger } = await createLedger(); + const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { + name: "x".repeat(256), + timezone: "UTC", + }); + + expect(response.status).toBe(422); + }); }); diff --git a/apps/server/test/integration/v1/services/create.spec.ts b/apps/server/test/integration/v1/services/create.spec.ts index b5a774e..daa1df4 100644 --- a/apps/server/test/integration/v1/services/create.spec.ts +++ b/apps/server/test/integration/v1/services/create.spec.ts @@ -20,7 +20,7 @@ describe("POST /v1/ledgers/:ledgerId/services", () => { expect(data.name).toBe("Haircut"); expect(data.policyId).toBeNull(); expect(data.resourceIds).toEqual([resource.id]); - expect(data.metadata).toBeNull(); + expect(data.metadata).toEqual({}); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); @@ -84,12 +84,14 @@ describe("POST /v1/ledgers/:ledgerId/services", () => { expect(data.resourceIds).toContain(r2.id); }); - it("returns 422 for missing name", async () => { + it("returns 201 with null name when not provided", async () => { const { ledger } = await createLedger(); const response = await client.post(`/v1/ledgers/${ledger.id}/services`, {}); - expect(response.status).toBe(422); + expect(response.status).toBe(201); + const { data } = (await response.json()) as { data: Service }; + expect(data.name).toBeNull(); }); it("returns 404 for non-existent policyId", async () => { diff --git a/apps/server/test/integration/v1/services/update.spec.ts b/apps/server/test/integration/v1/services/update.spec.ts index eb612dc..46a0c91 100644 --- a/apps/server/test/integration/v1/services/update.spec.ts +++ b/apps/server/test/integration/v1/services/update.spec.ts @@ -101,14 +101,16 @@ describe("PUT /v1/ledgers/:ledgerId/services/:id", () => { expect(response.status).toBe(404); }); - it("returns 422 for missing name", async () => { + it("returns 200 with null name when not provided", async () => { const { ledger } = await createLedger(); - const { service } = await createService({ ledgerId: ledger.id }); + const { service } = await createService({ ledgerId: ledger.id, name: "Original" }); const response = await client.put(`/v1/ledgers/${ledger.id}/services/${service.id}`, { resourceIds: [], }); - expect(response.status).toBe(422); + expect(response.status).toBe(200); + const { data } = (await response.json()) as { data: Service }; + expect(data.name).toBeNull(); }); }); diff --git a/packages/schema/inputs/allocation.ts b/packages/schema/inputs/allocation.ts index 3dddbd4..5e93c92 100644 --- a/packages/schema/inputs/allocation.ts +++ b/packages/schema/inputs/allocation.ts @@ -8,7 +8,7 @@ export const create = z startTime: z.coerce.date(), endTime: z.coerce.date(), expiresAt: z.coerce.date().nullable().optional(), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional().default({}), }) .refine((data) => data.endTime > data.startTime, { message: "endTime must be after startTime", diff --git a/packages/schema/inputs/booking.ts b/packages/schema/inputs/booking.ts index d88ef15..c7d16bd 100644 --- a/packages/schema/inputs/booking.ts +++ b/packages/schema/inputs/booking.ts @@ -10,7 +10,7 @@ export const create = z 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(), + metadata: z.record(z.string(), z.unknown()).optional().default({}), }) .refine((data) => data.endTime > data.startTime, { message: "endTime must be after startTime", diff --git a/packages/schema/inputs/policy.ts b/packages/schema/inputs/policy.ts index a05716c..2fc9162 100644 --- a/packages/schema/inputs/policy.ts +++ b/packages/schema/inputs/policy.ts @@ -164,7 +164,7 @@ const policyConfigSchema = z export const create = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - name: z.string().max(100).nullable().optional(), + name: z.string().max(255).nullable().optional(), description: z.string().max(500).nullable().optional(), config: policyConfigSchema, }); @@ -172,7 +172,7 @@ export const create = 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" }), - name: z.string().max(100).nullable().optional(), + name: z.string().max(255).nullable().optional(), description: z.string().max(500).nullable().optional(), config: policyConfigSchema, }); diff --git a/packages/schema/inputs/resource.ts b/packages/schema/inputs/resource.ts index ba59efd..260dcef 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 create = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + name: z.string().max(255).nullable().optional(), timezone: z .string() .max(64) @@ -17,6 +18,7 @@ export const create = z.object({ }, { message: "Invalid IANA timezone" }, ), + metadata: z.record(z.string(), z.unknown()).optional().default({}), }); export const get = z.object({ diff --git a/packages/schema/inputs/service.ts b/packages/schema/inputs/service.ts index af3127c..cba1e46 100644 --- a/packages/schema/inputs/service.ts +++ b/packages/schema/inputs/service.ts @@ -3,7 +3,7 @@ import { isValidId } from "@floyd-run/utils"; export const create = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - name: z.string().min(1).max(255), + name: z.string().max(255).nullable().optional(), policyId: z .string() .refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }) @@ -12,13 +12,13 @@ export const create = z.object({ resourceIds: z .array(z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" })) .default([]), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional().default({}), }); 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), + name: z.string().max(255).nullable().optional(), policyId: z .string() .refine((id) => isValidId(id, "pol"), { message: "Invalid policy ID" }) @@ -27,7 +27,7 @@ export const update = z.object({ resourceIds: z .array(z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" })) .default([]), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional().default({}), }); export const get = z.object({ diff --git a/packages/schema/outputs/allocation.ts b/packages/schema/outputs/allocation.ts index b008167..415c636 100644 --- a/packages/schema/outputs/allocation.ts +++ b/packages/schema/outputs/allocation.ts @@ -13,7 +13,7 @@ export const base = z.object({ afterMs: z.number(), }), expiresAt: z.string().nullable(), - metadata: z.record(z.string(), z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/packages/schema/outputs/booking.ts b/packages/schema/outputs/booking.ts index 4c79935..6e74918 100644 --- a/packages/schema/outputs/booking.ts +++ b/packages/schema/outputs/booking.ts @@ -26,7 +26,7 @@ export const base = z.object({ ]), expiresAt: z.string().nullable(), allocations: z.array(bookingAllocationSchema), - metadata: z.record(z.string(), z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/packages/schema/outputs/resource.ts b/packages/schema/outputs/resource.ts index b9119d7..d39381d 100644 --- a/packages/schema/outputs/resource.ts +++ b/packages/schema/outputs/resource.ts @@ -3,7 +3,9 @@ import { z } from "./zod"; export const base = z.object({ id: z.string(), ledgerId: z.string(), + name: z.string().nullable(), timezone: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/packages/schema/outputs/service.ts b/packages/schema/outputs/service.ts index eaf307a..db2d8b2 100644 --- a/packages/schema/outputs/service.ts +++ b/packages/schema/outputs/service.ts @@ -3,10 +3,10 @@ import { z } from "./zod"; export const base = z.object({ id: z.string(), ledgerId: z.string(), - name: z.string(), + name: z.string().nullable(), policyId: z.string().nullable(), resourceIds: z.array(z.string()), - metadata: z.record(z.string(), z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()), createdAt: z.string(), updatedAt: z.string(), }); From f8cb386e165a94314dcb4e220ab64d5cfcd577c8 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 22:52:04 +0300 Subject: [PATCH 7/8] Fix some small issues --- apps/server/openapi.json | 162 ++++++++++++++++++ ...0260217165155_add-policy-versions-table.ts | 2 + apps/server/src/scripts/generate-openapi.ts | 24 ++- docs/policies.md | 2 +- 4 files changed, 187 insertions(+), 3 deletions(-) diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 363eae2..6b1eb5c 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -439,6 +439,110 @@ "updatedAt" ] }, + "PolicyWarning": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Warning code", + "example": "implicit_open_default" + }, + "message": { + "type": "string", + "description": "Human-readable warning message" + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "Additional context" + } + }, + "required": ["code", "message"] + }, + "PolicyMutationResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "ledgerId": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "currentVersionId": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": {} + }, + "configSource": { + "type": "object", + "additionalProperties": {} + }, + "configHash": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "ledgerId", + "name", + "description", + "currentVersionId", + "config", + "configSource", + "configHash", + "createdAt", + "updatedAt" + ] + }, + "meta": { + "type": "object", + "properties": { + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Warning code", + "example": "implicit_open_default" + }, + "message": { + "type": "string", + "description": "Human-readable warning message" + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "Additional context" + } + }, + "required": ["code", "message"] + } + } + }, + "required": ["warnings"] + } + }, + "required": ["data"] + }, "Error": { "type": "object", "properties": { @@ -3649,6 +3753,35 @@ "createdAt", "updatedAt" ] + }, + "meta": { + "type": "object", + "properties": { + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Warning code", + "example": "implicit_open_default" + }, + "message": { + "type": "string", + "description": "Human-readable warning message" + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "Additional context" + } + }, + "required": ["code", "message"] + } + } + }, + "required": ["warnings"] } }, "required": ["data"] @@ -3906,6 +4039,35 @@ "createdAt", "updatedAt" ] + }, + "meta": { + "type": "object", + "properties": { + "warnings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Warning code", + "example": "implicit_open_default" + }, + "message": { + "type": "string", + "description": "Human-readable warning message" + }, + "details": { + "type": "object", + "additionalProperties": {}, + "description": "Additional context" + } + }, + "required": ["code", "message"] + } + } + }, + "required": ["warnings"] } }, "required": ["data"] diff --git a/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts index fbbdf9f..0c0ae34 100644 --- a/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts +++ b/apps/server/src/migrations/20260217165155_add-policy-versions-table.ts @@ -1,6 +1,8 @@ import type { Database } from "database/schema"; import { type Kysely, sql } from "kysely"; +// This migration assumes an empty database (no existing policies or bookings). +// It drops columns and adds NOT NULL constraints without backfilling data. export async function up(db: Kysely): Promise { // 1. Create policy_versions table await db.schema diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index d131cc0..6772d0a 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -28,6 +28,26 @@ registry.register("ResourceWindows", availability.resourceWindows); registry.register("Policy", policy.base); registry.register("Service", service.base); registry.register("Booking", booking.base); + +const policyWarning = z.object({ + code: z.string().openapi({ description: "Warning code", example: "implicit_open_default" }), + message: z.string().openapi({ description: "Human-readable warning message" }), + details: z + .record(z.string(), z.unknown()) + .optional() + .openapi({ description: "Additional context" }), +}); +registry.register("PolicyWarning", policyWarning); + +const policyMutationResponse = z.object({ + data: policy.base, + meta: z + .object({ + warnings: z.array(policyWarning), + }) + .optional(), +}); +registry.register("PolicyMutationResponse", policyMutationResponse); registry.register("Error", error.schema); // Ledger routes @@ -774,7 +794,7 @@ registry.registerPath({ responses: { 201: { description: "Policy created (may include warnings)", - content: { "application/json": { schema: policy.get } }, + content: { "application/json": { schema: policyMutationResponse } }, }, }, }); @@ -819,7 +839,7 @@ registry.registerPath({ responses: { 200: { description: "Policy updated (may include warnings)", - content: { "application/json": { schema: policy.get } }, + content: { "application/json": { schema: policyMutationResponse } }, }, 404: { description: "Policy not found", diff --git a/docs/policies.md b/docs/policies.md index 077c3dd..7897a48 100644 --- a/docs/policies.md +++ b/docs/policies.md @@ -75,7 +75,7 @@ curl -X POST https://api.floyd.run/v1/ledgers/{ledgerId}/policies \ | Field | Type | Required | Description | | ------------- | ------ | -------- | -------------------------------- | -| `name` | string | No | Display name (max 100 chars) | +| `name` | string | No | Display name (max 255 chars) | | `description` | string | No | Description (max 500 chars) | | `config` | object | Yes | Policy configuration (see below) | From c62a2b19cf14102ed96d218c344fa547dc8c9149 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Tue, 17 Feb 2026 23:00:43 +0300 Subject: [PATCH 8/8] Remove some unused codes --- apps/server/src/routes/v1/policies.ts | 2 +- apps/server/test/integration/v1/policies/create.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/routes/v1/policies.ts b/apps/server/src/routes/v1/policies.ts index a0ac87b..45c6953 100644 --- a/apps/server/src/routes/v1/policies.ts +++ b/apps/server/src/routes/v1/policies.ts @@ -20,7 +20,7 @@ export const policies = new Hono() id: c.req.param("id"), ledgerId: c.req.param("ledgerId")!, }); - if (!policy || !version) throw new NotFoundError("Policy not found"); + if (!policy) throw new NotFoundError("Policy not found"); return c.json({ data: serializePolicy(policy, version) }); }) diff --git a/apps/server/test/integration/v1/policies/create.spec.ts b/apps/server/test/integration/v1/policies/create.spec.ts index a9b9dd0..df813aa 100644 --- a/apps/server/test/integration/v1/policies/create.spec.ts +++ b/apps/server/test/integration/v1/policies/create.spec.ts @@ -87,7 +87,7 @@ describe("POST /v1/ledgers/:ledgerId/policies", () => { const { data } = (await response.json()) as { data: Policy }; // configSource should contain the original authoring format - const source = data.configSource as Record; + const source = data.configSource; const constraints = source["constraints"] as Record>; expect(constraints["duration"]!["allowed_minutes"]).toEqual([30, 60]); expect(constraints["grid"]!["interval_minutes"]).toBe(15);