From aa31d207b7d6f3d0bde24a91ade879bde7e24edc Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Sat, 7 Feb 2026 02:45:16 +0300 Subject: [PATCH 1/5] Initial availability --- apps/server/src/routes/v1/availability.ts | 19 ++ apps/server/src/routes/v1/index.ts | 2 + .../server/src/services/availability/index.ts | 5 + .../server/src/services/availability/query.ts | 148 +++++++++ apps/server/src/services/index.ts | 2 + apps/server/test/setup/types.ts | 3 +- .../server/test/v1/availability/query.spec.ts | 308 ++++++++++++++++++ packages/schema/inputs/availability.ts | 11 + packages/schema/inputs/index.ts | 1 + packages/schema/outputs/availability.ts | 16 + packages/schema/outputs/index.ts | 1 + packages/schema/types/index.ts | 2 + 12 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/routes/v1/availability.ts create mode 100644 apps/server/src/services/availability/index.ts create mode 100644 apps/server/src/services/availability/query.ts create mode 100644 apps/server/test/v1/availability/query.spec.ts create mode 100644 packages/schema/inputs/availability.ts create mode 100644 packages/schema/outputs/availability.ts diff --git a/apps/server/src/routes/v1/availability.ts b/apps/server/src/routes/v1/availability.ts new file mode 100644 index 0000000..53140ea --- /dev/null +++ b/apps/server/src/routes/v1/availability.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; +import { services } from "../../services/index.js"; + +// Nested under /v1/ledgers/:ledgerId/availability +export const availability = new Hono().get("/", async (c) => { + const ledgerId = c.req.param("ledgerId")!; + const resourceIds = c.req.queries("resourceIds") || []; + const startAt = c.req.query("startAt")!; + const endAt = c.req.query("endAt")!; + + const result = await services.availability.query({ + ledgerId, + resourceIds, + startAt, + endAt, + }); + + return c.json({ data: result.items }); +}); diff --git a/apps/server/src/routes/v1/index.ts b/apps/server/src/routes/v1/index.ts index 775a003..9643d92 100644 --- a/apps/server/src/routes/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { allocations } from "./allocations"; +import { availability } from "./availability"; import { resources } from "./resources"; import { ledgers } from "./ledgers"; import { webhooks } from "./webhooks"; @@ -8,4 +9,5 @@ export const v1 = new Hono() .route("/ledgers", ledgers) .route("/ledgers/:ledgerId/resources", resources) .route("/ledgers/:ledgerId/allocations", allocations) + .route("/ledgers/:ledgerId/availability", availability) .route("/ledgers/:ledgerId/webhooks", webhooks); diff --git a/apps/server/src/services/availability/index.ts b/apps/server/src/services/availability/index.ts new file mode 100644 index 0000000..51a6c20 --- /dev/null +++ b/apps/server/src/services/availability/index.ts @@ -0,0 +1,5 @@ +import query from "./query"; + +export const availability = { + query, +}; diff --git a/apps/server/src/services/availability/query.ts b/apps/server/src/services/availability/query.ts new file mode 100644 index 0000000..ef22e38 --- /dev/null +++ b/apps/server/src/services/availability/query.ts @@ -0,0 +1,148 @@ +import { sql } from "kysely"; +import { db } from "database"; +import { createService } from "lib/service"; +import { availability } from "@floyd-run/schema/inputs"; +import type { AvailabilityItem, TimelineBlock } from "@floyd-run/schema/types"; + +interface BlockingAllocation { + resourceId: string; + startAt: Date; + endAt: Date; +} + +/** + * Clamps an interval to the query window [windowStart, windowEnd) + */ +function clampInterval( + start: Date, + end: Date, + windowStart: Date, + windowEnd: Date, +): { start: Date; end: Date } { + return { + start: start < windowStart ? windowStart : start, + end: end > windowEnd ? windowEnd : end, + }; +} + +/** + * Merges overlapping or adjacent intervals. + * Input must be sorted by start time. + */ +function mergeIntervals(intervals: { start: Date; end: Date }[]): { start: Date; end: Date }[] { + if (intervals.length === 0) return []; + + const merged: { start: Date; end: Date }[] = []; + let current = { ...intervals[0]! }; + + for (let i = 1; i < intervals.length; i++) { + const next = intervals[i]!; + if (next.start <= current.end) { + // Overlapping or adjacent - extend current + if (next.end > current.end) { + current.end = next.end; + } + } else { + // Gap - push current and start new + merged.push(current); + current = { ...next }; + } + } + merged.push(current); + + return merged; +} + +/** + * Builds a timeline of free/busy blocks from merged busy intervals. + * Guarantees: sequential, non-overlapping, covers full window, no adjacent same-status. + */ +function buildTimeline( + busyIntervals: { start: Date; end: Date }[], + windowStart: Date, + windowEnd: Date, +): TimelineBlock[] { + const timeline: TimelineBlock[] = []; + let cursor = windowStart; + + for (const busy of busyIntervals) { + // Add free block before this busy interval (if gap exists) + if (busy.start > cursor) { + timeline.push({ + startAt: cursor.toISOString(), + endAt: busy.start.toISOString(), + status: "free", + }); + } + + // Add busy block + timeline.push({ + startAt: busy.start.toISOString(), + endAt: busy.end.toISOString(), + status: "busy", + }); + + cursor = busy.end; + } + + // Add trailing free block if window extends past last busy + if (cursor < windowEnd) { + timeline.push({ + startAt: cursor.toISOString(), + endAt: windowEnd.toISOString(), + status: "free", + }); + } + + return timeline; +} + +export default createService({ + input: availability.querySchema, + execute: async (input): Promise<{ items: AvailabilityItem[] }> => { + const { ledgerId, resourceIds, startAt, endAt } = input; + + // Single query to fetch all blocking allocations + const blockingAllocations = await sql` + SELECT resource_id, start_at, end_at + FROM allocations + WHERE ledger_id = ${ledgerId} + AND resource_id = ANY(${sql.raw(`ARRAY[${resourceIds.map((id) => `'${id}'`).join(",")}]::text[]`)}) + AND status IN ('hold', 'confirmed') + AND (status != 'hold' OR expires_at > clock_timestamp()) + AND start_at < ${endAt} + AND end_at > ${startAt} + ORDER BY resource_id, start_at + `.execute(db); + + // Group allocations by resource_id + const allocationsByResource = new Map(); + for (const resourceId of resourceIds) { + allocationsByResource.set(resourceId, []); + } + for (const row of blockingAllocations.rows) { + const list = allocationsByResource.get(row.resourceId); + if (list) { + list.push(row); + } + } + + // Build timeline for each resource + const items: AvailabilityItem[] = resourceIds.map((resourceId) => { + const allocations = allocationsByResource.get(resourceId) || []; + + // Clamp allocations to window + const clamped = allocations.map((a) => clampInterval(a.startAt, a.endAt, startAt, endAt)); + + // Merge overlapping/adjacent intervals + const merged = mergeIntervals(clamped); + + // Build timeline with free/busy blocks + const timeline = buildTimeline(merged, startAt, endAt); + + return { resourceId, timeline }; + }); + + return { items }; + }, +}); diff --git a/apps/server/src/services/index.ts b/apps/server/src/services/index.ts index dc9a4bc..10ba2f1 100644 --- a/apps/server/src/services/index.ts +++ b/apps/server/src/services/index.ts @@ -1,10 +1,12 @@ import { allocation } from "./allocation"; +import { availability } from "./availability"; import { resource } from "./resource"; import { ledger } from "./ledger"; import { webhook } from "./webhook"; export const services = { allocation, + availability, resource, ledger, webhook, diff --git a/apps/server/test/setup/types.ts b/apps/server/test/setup/types.ts index dde752f..d30a1ca 100644 --- a/apps/server/test/setup/types.ts +++ b/apps/server/test/setup/types.ts @@ -1,4 +1,4 @@ -import type { Allocation, Resource, Ledger } from "@floyd-run/schema/types"; +import type { Allocation, Resource, Ledger, AvailabilityItem } from "@floyd-run/schema/types"; // Generic API response type that can represent both success and error responses export interface ApiResponse { @@ -11,4 +11,5 @@ export interface ApiResponse { export type AllocationResponse = ApiResponse; export type ResourceResponse = ApiResponse; export type LedgerResponse = ApiResponse; +export type AvailabilityResponse = ApiResponse; export type ListResponse = ApiResponse; diff --git a/apps/server/test/v1/availability/query.spec.ts b/apps/server/test/v1/availability/query.spec.ts new file mode 100644 index 0000000..539cd8b --- /dev/null +++ b/apps/server/test/v1/availability/query.spec.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; +import { client } from "../../setup/client"; +import { createAllocation, createLedger, createResource } from "../../setup/factories"; +import type { AvailabilityResponse } from "../../setup/types"; + +describe("GET /v1/ledgers/:ledgerId/availability", () => { + it("returns full window as free when no allocations", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + expect(data).toHaveLength(1); + expect(data[0]!.resourceId).toBe(resource.id); + expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + }); + + it("returns busy block for confirmed allocation", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + const allocStart = new Date("2026-01-01T10:30:00.000Z"); + const allocEnd = new Date("2026-01-01T11:00:00.000Z"); + + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "confirmed", + startAt: allocStart, + endAt: allocEnd, + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + expect(data[0]!.timeline).toEqual([ + { startAt: "2026-01-01T10:00:00.000Z", endAt: "2026-01-01T10:30:00.000Z", status: "free" }, + { startAt: "2026-01-01T10:30:00.000Z", endAt: "2026-01-01T11:00:00.000Z", status: "busy" }, + { startAt: "2026-01-01T11:00:00.000Z", endAt: "2026-01-01T12:00:00.000Z", status: "free" }, + ]); + }); + + it("returns busy block for unexpired hold", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + const allocStart = new Date("2026-01-01T10:30:00.000Z"); + const allocEnd = new Date("2026-01-01T11:00:00.000Z"); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now + + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "hold", + startAt: allocStart, + endAt: allocEnd, + expiresAt, + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + expect(data[0]!.timeline).toHaveLength(3); + expect(data[0]!.timeline[1]!.status).toBe("busy"); + }); + + it("ignores expired holds", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + const allocStart = new Date("2026-01-01T10:30:00.000Z"); + const allocEnd = new Date("2026-01-01T11:00:00.000Z"); + const expiresAt = new Date(Date.now() - 60 * 1000); // expired 1 minute ago + + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "hold", + startAt: allocStart, + endAt: allocEnd, + expiresAt, + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + // Expired hold should not block - entire window is free + expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + }); + + it("ignores cancelled allocations", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "cancelled", + startAt: new Date("2026-01-01T10:30:00.000Z"), + endAt: new Date("2026-01-01T11:00:00.000Z"), + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + }); + + it("merges overlapping allocations", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + // Two overlapping allocations: 10:30-11:00 and 10:45-11:15 + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "confirmed", + startAt: new Date("2026-01-01T10:30:00.000Z"), + endAt: new Date("2026-01-01T11:00:00.000Z"), + }); + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "confirmed", + startAt: new Date("2026-01-01T10:45:00.000Z"), + endAt: new Date("2026-01-01T11:15:00.000Z"), + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + // Should be merged into single busy block 10:30-11:15 + expect(data[0]!.timeline).toEqual([ + { startAt: "2026-01-01T10:00:00.000Z", endAt: "2026-01-01T10:30:00.000Z", status: "free" }, + { startAt: "2026-01-01T10:30:00.000Z", endAt: "2026-01-01T11:15:00.000Z", status: "busy" }, + { startAt: "2026-01-01T11:15:00.000Z", endAt: "2026-01-01T12:00:00.000Z", status: "free" }, + ]); + }); + + it("merges adjacent allocations", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + // Two adjacent allocations: 10:30-11:00 and 11:00-11:30 + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "confirmed", + startAt: new Date("2026-01-01T10:30:00.000Z"), + endAt: new Date("2026-01-01T11:00:00.000Z"), + }); + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "confirmed", + startAt: new Date("2026-01-01T11:00:00.000Z"), + endAt: new Date("2026-01-01T11:30:00.000Z"), + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + // Should be merged into single busy block 10:30-11:30 + expect(data[0]!.timeline).toEqual([ + { startAt: "2026-01-01T10:00:00.000Z", endAt: "2026-01-01T10:30:00.000Z", status: "free" }, + { startAt: "2026-01-01T10:30:00.000Z", endAt: "2026-01-01T11:30:00.000Z", status: "busy" }, + { startAt: "2026-01-01T11:30:00.000Z", endAt: "2026-01-01T12:00:00.000Z", status: "free" }, + ]); + }); + + it("clamps allocations to query window", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + // Allocation extends before and after the query window + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "confirmed", + startAt: new Date("2026-01-01T09:00:00.000Z"), + endAt: new Date("2026-01-01T13:00:00.000Z"), + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + // Should be clamped to query window + expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "busy" }]); + }); + + it("handles multiple resources", async () => { + const { ledger } = await createLedger(); + const { resource: resource1 } = await createResource({ ledgerId: ledger.id }); + const { resource: resource2 } = await createResource({ ledgerId: ledger.id }); + + // Allocation on resource1 only + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource1.id, + status: "confirmed", + startAt: new Date("2026-01-01T10:30:00.000Z"), + endAt: new Date("2026-01-01T11:00:00.000Z"), + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource1.id}&resourceIds=${resource2.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + expect(data).toHaveLength(2); + + const item1 = data.find((d) => d.resourceId === resource1.id)!; + const item2 = data.find((d) => d.resourceId === resource2.id)!; + + expect(item1.timeline).toHaveLength(3); + expect(item1.timeline[1]!.status).toBe("busy"); + + expect(item2.timeline).toEqual([{ startAt, endAt, status: "free" }]); + }); + + it("excludes allocations outside query window", async () => { + const { ledger } = await createLedger(); + const { resource } = await createResource({ ledgerId: ledger.id }); + + // Allocation completely outside the query window + await createAllocation({ + ledgerId: ledger.id, + resourceId: resource.id, + status: "confirmed", + startAt: new Date("2026-01-01T08:00:00.000Z"), + endAt: new Date("2026-01-01T09:00:00.000Z"), + }); + + const startAt = "2026-01-01T10:00:00.000Z"; + const endAt = "2026-01-01T12:00:00.000Z"; + + const response = await client.get( + `/v1/ledgers/${ledger.id}/availability?resourceIds=${resource.id}&startAt=${startAt}&endAt=${endAt}`, + ); + + expect(response.status).toBe(200); + const { data } = (await response.json()) as AvailabilityResponse; + + expect(data[0]!.timeline).toEqual([{ startAt, endAt, status: "free" }]); + }); +}); diff --git a/packages/schema/inputs/availability.ts b/packages/schema/inputs/availability.ts new file mode 100644 index 0000000..0cb0b34 --- /dev/null +++ b/packages/schema/inputs/availability.ts @@ -0,0 +1,11 @@ +import z from "zod"; +import { isValidId } from "@floyd-run/utils"; + +export const querySchema = z.object({ + ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), + resourceIds: z.array( + z.string().refine((id) => isValidId(id, "rsc"), { message: "Invalid resource ID" }), + ), + startAt: z.coerce.date(), + endAt: z.coerce.date(), +}); diff --git a/packages/schema/inputs/index.ts b/packages/schema/inputs/index.ts index c924220..20fe92e 100644 --- a/packages/schema/inputs/index.ts +++ b/packages/schema/inputs/index.ts @@ -1,4 +1,5 @@ export * as allocation from "./allocation"; +export * as availability from "./availability"; export * as resource from "./resource"; export * as ledger from "./ledger"; export * as webhook from "./webhook"; diff --git a/packages/schema/outputs/availability.ts b/packages/schema/outputs/availability.ts new file mode 100644 index 0000000..6d48228 --- /dev/null +++ b/packages/schema/outputs/availability.ts @@ -0,0 +1,16 @@ +import { z } from "./zod"; + +export const timelineBlockSchema = z.object({ + startAt: z.string(), + endAt: z.string(), + status: z.enum(["free", "busy"]), +}); + +export const itemSchema = z.object({ + resourceId: z.string(), + timeline: z.array(timelineBlockSchema), +}); + +export const querySchema = z.object({ + data: z.array(itemSchema), +}); diff --git a/packages/schema/outputs/index.ts b/packages/schema/outputs/index.ts index 3bcfdc1..59367f3 100644 --- a/packages/schema/outputs/index.ts +++ b/packages/schema/outputs/index.ts @@ -1,4 +1,5 @@ export * as allocation from "./allocation"; +export * as availability from "./availability"; export * as resource from "./resource"; export * as ledger from "./ledger"; export * as webhook from "./webhook"; diff --git a/packages/schema/types/index.ts b/packages/schema/types/index.ts index aa35fdb..a8fbe6d 100644 --- a/packages/schema/types/index.ts +++ b/packages/schema/types/index.ts @@ -10,3 +10,5 @@ export type WebhookDeliveryStatus = ConstantType; export type Allocation = z.infer; export type Resource = z.infer; export type Ledger = z.infer; +export type AvailabilityItem = z.infer; +export type TimelineBlock = z.infer; From 2e3ed835ea482263bbc8d8f85c9acf871098962b Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Sat, 7 Feb 2026 02:57:06 +0300 Subject: [PATCH 2/5] Refactor availability query --- apps/server/src/lib/timeline.ts | 88 +++++++++++++++ .../server/src/services/availability/query.ts | 100 ++---------------- 2 files changed, 94 insertions(+), 94 deletions(-) create mode 100644 apps/server/src/lib/timeline.ts diff --git a/apps/server/src/lib/timeline.ts b/apps/server/src/lib/timeline.ts new file mode 100644 index 0000000..07486f1 --- /dev/null +++ b/apps/server/src/lib/timeline.ts @@ -0,0 +1,88 @@ +import type { TimelineBlock } from "@floyd-run/schema/types"; + +export interface Interval { + start: Date; + end: Date; +} + +/** + * Clamps an interval to the query window [windowStart, windowEnd) + */ +export function clampInterval(interval: Interval, windowStart: Date, windowEnd: Date): Interval { + return { + start: interval.start < windowStart ? windowStart : interval.start, + end: interval.end > windowEnd ? windowEnd : interval.end, + }; +} + +/** + * Merges overlapping or adjacent intervals. + * Input must be sorted by start time. + */ +export function mergeIntervals(intervals: Interval[]): Interval[] { + if (intervals.length === 0) return []; + + const merged: Interval[] = []; + let current = { ...intervals[0]! }; + + for (let i = 1; i < intervals.length; i++) { + const next = intervals[i]!; + if (next.start <= current.end) { + // Overlapping or adjacent - extend current + if (next.end > current.end) { + current.end = next.end; + } + } else { + // Gap - push current and start new + merged.push(current); + current = { ...next }; + } + } + merged.push(current); + + return merged; +} + +/** + * Builds a timeline of free/busy blocks from merged busy intervals. + * Guarantees: sequential, non-overlapping, covers full window, no adjacent same-status. + */ +export function buildTimeline( + busyIntervals: Interval[], + windowStart: Date, + windowEnd: Date, +): TimelineBlock[] { + const timeline: TimelineBlock[] = []; + let cursor = windowStart; + + for (const busy of busyIntervals) { + // Add free block before this busy interval (if gap exists) + if (busy.start > cursor) { + timeline.push({ + startAt: cursor.toISOString(), + endAt: busy.start.toISOString(), + status: "free", + }); + } + + // Add busy block + timeline.push({ + startAt: busy.start.toISOString(), + endAt: busy.end.toISOString(), + status: "busy", + }); + + cursor = busy.end; + } + + // Add trailing free block if window extends past last busy + if (cursor < windowEnd) { + timeline.push({ + startAt: cursor.toISOString(), + endAt: windowEnd.toISOString(), + status: "free", + }); + } + + return timeline; +} diff --git a/apps/server/src/services/availability/query.ts b/apps/server/src/services/availability/query.ts index ef22e38..16aa39b 100644 --- a/apps/server/src/services/availability/query.ts +++ b/apps/server/src/services/availability/query.ts @@ -2,7 +2,8 @@ import { sql } from "kysely"; import { db } from "database"; import { createService } from "lib/service"; import { availability } from "@floyd-run/schema/inputs"; -import type { AvailabilityItem, TimelineBlock } from "@floyd-run/schema/types"; +import type { AvailabilityItem } from "@floyd-run/schema/types"; +import { clampInterval, mergeIntervals, buildTimeline } from "lib/timeline"; interface BlockingAllocation { resourceId: string; @@ -10,93 +11,6 @@ interface BlockingAllocation { endAt: Date; } -/** - * Clamps an interval to the query window [windowStart, windowEnd) - */ -function clampInterval( - start: Date, - end: Date, - windowStart: Date, - windowEnd: Date, -): { start: Date; end: Date } { - return { - start: start < windowStart ? windowStart : start, - end: end > windowEnd ? windowEnd : end, - }; -} - -/** - * Merges overlapping or adjacent intervals. - * Input must be sorted by start time. - */ -function mergeIntervals(intervals: { start: Date; end: Date }[]): { start: Date; end: Date }[] { - if (intervals.length === 0) return []; - - const merged: { start: Date; end: Date }[] = []; - let current = { ...intervals[0]! }; - - for (let i = 1; i < intervals.length; i++) { - const next = intervals[i]!; - if (next.start <= current.end) { - // Overlapping or adjacent - extend current - if (next.end > current.end) { - current.end = next.end; - } - } else { - // Gap - push current and start new - merged.push(current); - current = { ...next }; - } - } - merged.push(current); - - return merged; -} - -/** - * Builds a timeline of free/busy blocks from merged busy intervals. - * Guarantees: sequential, non-overlapping, covers full window, no adjacent same-status. - */ -function buildTimeline( - busyIntervals: { start: Date; end: Date }[], - windowStart: Date, - windowEnd: Date, -): TimelineBlock[] { - const timeline: TimelineBlock[] = []; - let cursor = windowStart; - - for (const busy of busyIntervals) { - // Add free block before this busy interval (if gap exists) - if (busy.start > cursor) { - timeline.push({ - startAt: cursor.toISOString(), - endAt: busy.start.toISOString(), - status: "free", - }); - } - - // Add busy block - timeline.push({ - startAt: busy.start.toISOString(), - endAt: busy.end.toISOString(), - status: "busy", - }); - - cursor = busy.end; - } - - // Add trailing free block if window extends past last busy - if (cursor < windowEnd) { - timeline.push({ - startAt: cursor.toISOString(), - endAt: windowEnd.toISOString(), - status: "free", - }); - } - - return timeline; -} - export default createService({ input: availability.querySchema, execute: async (input): Promise<{ items: AvailabilityItem[] }> => { @@ -131,13 +45,11 @@ export default createService({ const items: AvailabilityItem[] = resourceIds.map((resourceId) => { const allocations = allocationsByResource.get(resourceId) || []; - // Clamp allocations to window - const clamped = allocations.map((a) => clampInterval(a.startAt, a.endAt, startAt, endAt)); - - // Merge overlapping/adjacent intervals + // Clamp allocations to window, merge overlaps, build timeline + const clamped = allocations.map((a) => + clampInterval({ start: a.startAt, end: a.endAt }, startAt, endAt), + ); const merged = mergeIntervals(clamped); - - // Build timeline with free/busy blocks const timeline = buildTimeline(merged, startAt, endAt); return { resourceId, timeline }; From 762d1732c3a70bd93344ef63228b54117bb728de Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Sat, 7 Feb 2026 03:00:48 +0300 Subject: [PATCH 3/5] Remove timezone and update openapi --- apps/server/openapi.json | 669 +++++++++++++++--- apps/server/src/database/schema.ts | 1 - .../20260206235234_remove-timezone.ts | 13 + apps/server/src/routes/v1/serializers.ts | 1 - apps/server/src/scripts/generate-openapi.ts | 42 +- apps/server/src/services/resource/create.ts | 1 - .../test/setup/factories/resource.factory.ts | 4 +- apps/server/test/v1/resources/create.spec.ts | 16 +- apps/server/test/v1/resources/get.spec.ts | 1 - docs/quickstart.md | 9 +- docs/time-format.md | 1 - packages/schema/inputs/resource.ts | 1 - packages/schema/outputs/resource.ts | 1 - 13 files changed, 609 insertions(+), 151 deletions(-) create mode 100644 apps/server/src/migrations/20260206235234_remove-timezone.ts diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 38b9fdc..0c18dd6 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -32,7 +32,11 @@ "type": "string" } }, - "required": ["id", "createdAt", "updatedAt"] + "required": [ + "id", + "createdAt", + "updatedAt" + ] }, "Resource": { "type": "object", @@ -43,9 +47,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -53,7 +54,12 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "createdAt", + "updatedAt" + ] }, "Allocation": { "type": "object", @@ -69,7 +75,12 @@ }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": [ + "hold", + "confirmed", + "cancelled", + "expired" + ] }, "startAt": { "type": "string" @@ -78,10 +89,16 @@ "type": "string" }, "expiresAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "metadata": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": {} }, "createdAt": { @@ -123,7 +140,74 @@ "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "url", + "createdAt", + "updatedAt" + ] + }, + "AvailabilityItem": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timeline": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "free", + "busy" + ] + } + }, + "required": [ + "startAt", + "endAt", + "status" + ] + } + } + }, + "required": [ + "resourceId", + "timeline" + ] + }, + "TimelineBlock": { + "type": "object", + "properties": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "free", + "busy" + ] + } + }, + "required": [ + "startAt", + "endAt", + "status" + ] }, "Error": { "type": "object", @@ -147,12 +231,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } }, "parameters": {} @@ -160,7 +248,9 @@ "paths": { "/v1/ledgers": { "get": { - "tags": ["Ledgers"], + "tags": [ + "Ledgers" + ], "summary": "List all ledgers", "responses": { "200": { @@ -185,11 +275,17 @@ "type": "string" } }, - "required": ["id", "createdAt", "updatedAt"] + "required": [ + "id", + "createdAt", + "updatedAt" + ] } } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -197,7 +293,9 @@ } }, "post": { - "tags": ["Ledgers"], + "tags": [ + "Ledgers" + ], "summary": "Create a new ledger", "requestBody": { "content": { @@ -230,10 +328,16 @@ "type": "string" } }, - "required": ["id", "createdAt", "updatedAt"] + "required": [ + "id", + "createdAt", + "updatedAt" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -243,7 +347,9 @@ }, "/v1/ledgers/{id}": { "get": { - "tags": ["Ledgers"], + "tags": [ + "Ledgers" + ], "summary": "Get a ledger by ID", "parameters": [ { @@ -278,10 +384,16 @@ "type": "string" } }, - "required": ["id", "createdAt", "updatedAt"] + "required": [ + "id", + "createdAt", + "updatedAt" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -312,12 +424,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -327,7 +443,9 @@ }, "/v1/ledgers/{ledgerId}/resources": { "get": { - "tags": ["Resources"], + "tags": [ + "Resources" + ], "summary": "List all resources in a ledger", "parameters": [ { @@ -358,9 +476,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -368,11 +483,18 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "createdAt", + "updatedAt" + ] } } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -380,7 +502,9 @@ } }, "post": { - "tags": ["Resources"], + "tags": [ + "Resources" + ], "summary": "Create a new resource", "parameters": [ { @@ -397,13 +521,7 @@ "application/json": { "schema": { "type": "object", - "properties": { - "timezone": { - "type": "string", - "default": "UTC", - "example": "America/New_York" - } - } + "properties": {} } } } @@ -425,9 +543,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -435,10 +550,17 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "createdAt", + "updatedAt" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -448,7 +570,9 @@ }, "/v1/ledgers/{ledgerId}/resources/{id}": { "get": { - "tags": ["Resources"], + "tags": [ + "Resources" + ], "summary": "Get a resource by ID", "parameters": [ { @@ -485,9 +609,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -495,10 +616,17 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "createdAt", + "updatedAt" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -529,12 +657,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -542,7 +674,9 @@ } }, "delete": { - "tags": ["Resources"], + "tags": [ + "Resources" + ], "summary": "Delete a resource", "parameters": [ { @@ -592,12 +726,133 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/v1/ledgers/{ledgerId}/availability": { + "get": { + "tags": [ + "Availability" + ], + "summary": "Query resource availability", + "description": "Returns a timeline of free/busy blocks for the specified resources within the given time window. Overlapping and adjacent allocations are merged into single busy blocks.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "ledgerId", + "in": "path" + }, + { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resource IDs to query (can be repeated)", + "example": [ + "rsc_01abc123def456ghi789jkl012" + ] + }, + "required": true, + "description": "Resource IDs to query (can be repeated)", + "name": "resourceIds", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Start of the time window (ISO 8601)", + "example": "2026-01-04T10:00:00Z" + }, + "required": true, + "description": "Start of the time window (ISO 8601)", + "name": "startAt", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "End of the time window (ISO 8601)", + "example": "2026-01-04T18:00:00Z" + }, + "required": true, + "description": "End of the time window (ISO 8601)", + "name": "endAt", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Availability timeline for each resource", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceId": { + "type": "string" + }, + "timeline": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startAt": { + "type": "string" + }, + "endAt": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "free", + "busy" + ] + } + }, + "required": [ + "startAt", + "endAt", + "status" + ] + } + } + }, + "required": [ + "resourceId", + "timeline" + ] + } + } + }, + "required": [ + "data" + ] } } } @@ -607,7 +862,9 @@ }, "/v1/ledgers/{ledgerId}/allocations": { "get": { - "tags": ["Allocations"], + "tags": [ + "Allocations" + ], "summary": "List all allocations in a ledger", "parameters": [ { @@ -643,7 +900,12 @@ }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": [ + "hold", + "confirmed", + "cancelled", + "expired" + ] }, "startAt": { "type": "string" @@ -652,10 +914,16 @@ "type": "string" }, "expiresAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "metadata": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": {} }, "createdAt": { @@ -680,7 +948,9 @@ } } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -688,7 +958,9 @@ } }, "post": { - "tags": ["Allocations"], + "tags": [ + "Allocations" + ], "summary": "Create a new allocation", "description": "Creates a new allocation for a resource. Supports idempotency via the Idempotency-Key header.", "parameters": [ @@ -713,7 +985,10 @@ }, "status": { "type": "string", - "enum": ["hold", "confirmed"], + "enum": [ + "hold", + "confirmed" + ], "default": "hold" }, "startAt": { @@ -725,15 +1000,25 @@ "format": "date-time" }, "expiresAt": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "format": "date-time" }, "metadata": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": {} } }, - "required": ["resourceId", "startAt", "endAt"] + "required": [ + "resourceId", + "startAt", + "endAt" + ] } } } @@ -760,7 +1045,12 @@ }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": [ + "hold", + "confirmed", + "cancelled", + "expired" + ] }, "startAt": { "type": "string" @@ -769,10 +1059,16 @@ "type": "string" }, "expiresAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "metadata": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": {} }, "createdAt": { @@ -802,10 +1098,14 @@ "type": "string" } }, - "required": ["serverTime"] + "required": [ + "serverTime" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -836,12 +1136,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -851,7 +1155,9 @@ }, "/v1/ledgers/{ledgerId}/allocations/{id}": { "get": { - "tags": ["Allocations"], + "tags": [ + "Allocations" + ], "summary": "Get an allocation by ID", "parameters": [ { @@ -893,7 +1199,12 @@ }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": [ + "hold", + "confirmed", + "cancelled", + "expired" + ] }, "startAt": { "type": "string" @@ -902,10 +1213,16 @@ "type": "string" }, "expiresAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "metadata": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": {} }, "createdAt": { @@ -935,10 +1252,14 @@ "type": "string" } }, - "required": ["serverTime"] + "required": [ + "serverTime" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -969,12 +1290,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -984,7 +1309,9 @@ }, "/v1/ledgers/{ledgerId}/allocations/{id}/confirm": { "post": { - "tags": ["Allocations"], + "tags": [ + "Allocations" + ], "summary": "Confirm a held allocation", "description": "Confirms an allocation that is currently in HOLD status.", "parameters": [ @@ -1027,7 +1354,12 @@ }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": [ + "hold", + "confirmed", + "cancelled", + "expired" + ] }, "startAt": { "type": "string" @@ -1036,10 +1368,16 @@ "type": "string" }, "expiresAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "metadata": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": {} }, "createdAt": { @@ -1069,10 +1407,14 @@ "type": "string" } }, - "required": ["serverTime"] + "required": [ + "serverTime" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -1103,12 +1445,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1139,12 +1485,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1154,7 +1504,9 @@ }, "/v1/ledgers/{ledgerId}/allocations/{id}/cancel": { "post": { - "tags": ["Allocations"], + "tags": [ + "Allocations" + ], "summary": "Cancel an allocation", "description": "Cancels an allocation that is in HOLD or CONFIRMED status.", "parameters": [ @@ -1197,7 +1549,12 @@ }, "status": { "type": "string", - "enum": ["hold", "confirmed", "cancelled", "expired"] + "enum": [ + "hold", + "confirmed", + "cancelled", + "expired" + ] }, "startAt": { "type": "string" @@ -1206,10 +1563,16 @@ "type": "string" }, "expiresAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "metadata": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": {} }, "createdAt": { @@ -1239,10 +1602,14 @@ "type": "string" } }, - "required": ["serverTime"] + "required": [ + "serverTime" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -1273,12 +1640,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1309,12 +1680,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1324,7 +1699,9 @@ }, "/v1/ledgers/{ledgerId}/webhooks": { "get": { - "tags": ["Webhooks"], + "tags": [ + "Webhooks" + ], "summary": "List webhook subscriptions", "parameters": [ { @@ -1365,11 +1742,19 @@ "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "url", + "createdAt", + "updatedAt" + ] } } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -1377,7 +1762,9 @@ } }, "post": { - "tags": ["Webhooks"], + "tags": [ + "Webhooks" + ], "summary": "Create a webhook subscription", "description": "Creates a new webhook subscription. The secret is only returned once at creation time.", "parameters": [ @@ -1402,7 +1789,9 @@ "example": "https://example.com/webhook" } }, - "required": ["url"] + "required": [ + "url" + ] } } } @@ -1437,10 +1826,19 @@ "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] + "required": [ + "id", + "ledgerId", + "url", + "createdAt", + "updatedAt", + "secret" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -1450,7 +1848,9 @@ }, "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}": { "patch": { - "tags": ["Webhooks"], + "tags": [ + "Webhooks" + ], "summary": "Update a webhook subscription", "parameters": [ { @@ -1512,10 +1912,18 @@ "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] + "required": [ + "id", + "ledgerId", + "url", + "createdAt", + "updatedAt" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -1546,12 +1954,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1559,7 +1971,9 @@ } }, "delete": { - "tags": ["Webhooks"], + "tags": [ + "Webhooks" + ], "summary": "Delete a webhook subscription", "parameters": [ { @@ -1609,12 +2023,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1624,7 +2042,9 @@ }, "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}/rotate-secret": { "post": { - "tags": ["Webhooks"], + "tags": [ + "Webhooks" + ], "summary": "Rotate webhook secret", "description": "Generates a new secret for the webhook subscription. The old secret is invalidated immediately.", "parameters": [ @@ -1675,10 +2095,19 @@ "type": "string" } }, - "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] + "required": [ + "id", + "ledgerId", + "url", + "createdAt", + "updatedAt", + "secret" + ] } }, - "required": ["data"] + "required": [ + "data" + ] } } } @@ -1709,12 +2138,16 @@ "additionalProperties": {} } }, - "required": ["code"] + "required": [ + "code" + ] } ] } }, - "required": ["error"] + "required": [ + "error" + ] } } } @@ -1724,4 +2157,4 @@ } }, "webhooks": {} -} +} \ No newline at end of file diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index da12448..643dfec 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -14,7 +14,6 @@ export interface LedgersTable { export interface ResourcesTable { id: string; ledgerId: string; - timezone: string; createdAt: Generated; updatedAt: Generated; } diff --git a/apps/server/src/migrations/20260206235234_remove-timezone.ts b/apps/server/src/migrations/20260206235234_remove-timezone.ts new file mode 100644 index 0000000..068b250 --- /dev/null +++ b/apps/server/src/migrations/20260206235234_remove-timezone.ts @@ -0,0 +1,13 @@ +import type { Database } from 'database/schema'; +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema.alterTable("resources").dropColumn("timezone").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("resources") + .addColumn("timezone", "varchar(100)", (col) => col.notNull().defaultTo("UTC")) + .execute(); +} diff --git a/apps/server/src/routes/v1/serializers.ts b/apps/server/src/routes/v1/serializers.ts index d749742..98d0746 100644 --- a/apps/server/src/routes/v1/serializers.ts +++ b/apps/server/src/routes/v1/serializers.ts @@ -5,7 +5,6 @@ export function serializeResource(resource: ResourceRow): Resource { return { id: resource.id, ledgerId: resource.ledgerId, - timezone: resource.timezone, 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 6de3250..e85618b 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -2,7 +2,7 @@ import { OpenAPIRegistry, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-ope import { z } from "zod"; import { writeFileSync } from "fs"; import { join } from "path"; -import { allocation, resource, ledger, webhook, error } from "@floyd-run/schema/outputs"; +import { allocation, resource, ledger, webhook, error, availability } from "@floyd-run/schema/outputs"; const registry = new OpenAPIRegistry(); @@ -11,6 +11,8 @@ registry.register("Ledger", ledger.schema); registry.register("Resource", resource.schema); registry.register("Allocation", allocation.schema); registry.register("WebhookSubscription", webhook.subscriptionSchema); +registry.register("AvailabilityItem", availability.itemSchema); +registry.register("TimelineBlock", availability.timelineBlockSchema); registry.register("Error", error.schema); // Ledger routes @@ -112,9 +114,7 @@ registry.registerPath({ body: { content: { "application/json": { - schema: z.object({ - timezone: z.string().default("UTC").openapi({ example: "America/New_York" }), - }), + schema: z.object({}), }, }, }, @@ -144,6 +144,40 @@ registry.registerPath({ }, }); +// Availability routes +registry.registerPath({ + method: "get", + path: "/v1/ledgers/{ledgerId}/availability", + tags: ["Availability"], + summary: "Query resource availability", + description: + "Returns a timeline of free/busy blocks for the specified resources within the given time window. " + + "Overlapping and adjacent allocations are merged into single busy blocks.", + request: { + params: z.object({ ledgerId: z.string() }), + query: z.object({ + resourceIds: z.array(z.string()).openapi({ + description: "Resource IDs to query (can be repeated)", + example: ["rsc_01abc123def456ghi789jkl012"], + }), + startAt: z.string().datetime().openapi({ + description: "Start of the time window (ISO 8601)", + example: "2026-01-04T10:00:00Z", + }), + endAt: z.string().datetime().openapi({ + description: "End of the time window (ISO 8601)", + example: "2026-01-04T18:00:00Z", + }), + }), + }, + responses: { + 200: { + description: "Availability timeline for each resource", + content: { "application/json": { schema: availability.querySchema } }, + }, + }, +}); + // Allocation routes registry.registerPath({ method: "get", diff --git a/apps/server/src/services/resource/create.ts b/apps/server/src/services/resource/create.ts index 6a87b6d..7387b65 100644 --- a/apps/server/src/services/resource/create.ts +++ b/apps/server/src/services/resource/create.ts @@ -11,7 +11,6 @@ export default createService({ .values({ id: generateId("rsc"), ledgerId: input.ledgerId, - timezone: input.timezone, }) .returningAll() .executeTakeFirstOrThrow(); diff --git a/apps/server/test/setup/factories/resource.factory.ts b/apps/server/test/setup/factories/resource.factory.ts index 644b532..38bbd69 100644 --- a/apps/server/test/setup/factories/resource.factory.ts +++ b/apps/server/test/setup/factories/resource.factory.ts @@ -1,9 +1,8 @@ -import { faker } from "@faker-js/faker"; 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 }) { let ledgerId = overrides?.ledgerId; if (!ledgerId) { const { ledger } = await createLedger(); @@ -15,7 +14,6 @@ export async function createResource(overrides?: { ledgerId?: string; timezone?: .values({ id: generateId("rsc"), ledgerId, - timezone: overrides?.timezone ?? faker.location.timeZone(), }) .returningAll() .executeTakeFirst(); diff --git a/apps/server/test/v1/resources/create.spec.ts b/apps/server/test/v1/resources/create.spec.ts index e5302b0..00f1091 100644 --- a/apps/server/test/v1/resources/create.spec.ts +++ b/apps/server/test/v1/resources/create.spec.ts @@ -4,7 +4,7 @@ import { createLedger } from "../../setup/factories"; import type { ResourceResponse } from "../../setup/types"; describe("POST /v1/ledgers/:ledgerId/resources", () => { - it("returns 201 with default timezone", async () => { + it("returns 201 with created resource", async () => { const { ledger } = await createLedger(); const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, {}); @@ -12,17 +12,7 @@ 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.timezone).toBe("UTC"); - }); - - it("returns 201 with custom timezone", async () => { - const { ledger } = await createLedger(); - const response = await client.post(`/v1/ledgers/${ledger.id}/resources`, { - timezone: "America/New_York", - }); - - expect(response.status).toBe(201); - const { data } = (await response.json()) as ResourceResponse; - expect(data.timezone).toBe("America/New_York"); + expect(data.createdAt).toBeDefined(); + expect(data.updatedAt).toBeDefined(); }); }); diff --git a/apps/server/test/v1/resources/get.spec.ts b/apps/server/test/v1/resources/get.spec.ts index 09a7e85..2cf2e3e 100644 --- a/apps/server/test/v1/resources/get.spec.ts +++ b/apps/server/test/v1/resources/get.spec.ts @@ -19,7 +19,6 @@ describe("GET /v1/ledgers/:ledgerId/resources/:id", () => { const { data } = (await response.json()) as ResourceResponse; expect(data.id).toBe(resource.id); expect(data.ledgerId).toBe(ledgerId); - expect(data.timezone).toBe(resource.timezone); expect(data.createdAt).toBeDefined(); expect(data.updatedAt).toBeDefined(); }); diff --git a/docs/quickstart.md b/docs/quickstart.md index bce4150..543bb37 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -110,9 +110,7 @@ Resources represent bookable entities (rooms, people, services, etc.). You need ```bash curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/resources" \ -H "Content-Type: application/json" \ - -d '{ - "timezone": "America/New_York" - }' + -d '{}' ``` Response: @@ -120,9 +118,8 @@ Response: ```json { "data": { - "id": "res_01abc123def456ghi789jkl012", - "ledgerId": "ws_01abc123def456ghi789jkl012", - "timezone": "America/New_York", + "id": "rsc_01abc123def456ghi789jkl012", + "ledgerId": "ldg_01abc123def456ghi789jkl012", "createdAt": "2026-01-04T10:00:00.000Z", "updatedAt": "2026-01-04T10:00:00.000Z" } diff --git a/docs/time-format.md b/docs/time-format.md index 4ca5dc3..b37a3b6 100644 --- a/docs/time-format.md +++ b/docs/time-format.md @@ -15,7 +15,6 @@ Avoid “naive” timestamps (no timezone), e.g. `2026-01-04T10:00:00`. - For API calls: send UTC (`Z`) - For UI: convert to/from user locale at the edge -- For scheduling rules (business hours): use a resource/org timezone (when you add availability logic) ## Interval rules diff --git a/packages/schema/inputs/resource.ts b/packages/schema/inputs/resource.ts index fe0e61f..864de5d 100644 --- a/packages/schema/inputs/resource.ts +++ b/packages/schema/inputs/resource.ts @@ -3,7 +3,6 @@ import { isValidId } from "@floyd-run/utils"; export const createSchema = z.object({ ledgerId: z.string().refine((id) => isValidId(id, "ldg"), { message: "Invalid ledger ID" }), - timezone: z.string().default("UTC"), }); export const getSchema = z.object({ diff --git a/packages/schema/outputs/resource.ts b/packages/schema/outputs/resource.ts index 31cca49..746a2c0 100644 --- a/packages/schema/outputs/resource.ts +++ b/packages/schema/outputs/resource.ts @@ -3,7 +3,6 @@ import { z } from "./zod"; export const schema = z.object({ id: z.string(), ledgerId: z.string(), - timezone: z.string(), createdAt: z.string(), updatedAt: z.string(), }); From 84fd8cc707d6d92af8b9ec1eefbe5552c8011022 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Sat, 7 Feb 2026 03:11:35 +0300 Subject: [PATCH 4/5] Update docs --- docs/availability.md | 67 ++++++++++++++++++++++++++++++++++++++++++++ docs/introduction.md | 1 + docs/quickstart.md | 17 +++++------ scalar.config.json | 1 + 4 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 docs/availability.md diff --git a/docs/availability.md b/docs/availability.md new file mode 100644 index 0000000..54bb366 --- /dev/null +++ b/docs/availability.md @@ -0,0 +1,67 @@ +# Availability + +Query free/busy timelines for resources before creating allocations. + +## Query availability + +```bash +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/availability?resourceIds=$RESOURCE_ID&startAt=2026-01-04T10:00:00Z&endAt=2026-01-04T18:00:00Z" +``` + +Response: + +```json +{ + "data": [ + { + "resourceId": "rsc_01abc123def456ghi789jkl012", + "timeline": [ + { "startAt": "2026-01-04T10:00:00.000Z", "endAt": "2026-01-04T11:00:00.000Z", "status": "free" }, + { "startAt": "2026-01-04T11:00:00.000Z", "endAt": "2026-01-04T12:00:00.000Z", "status": "busy" }, + { "startAt": "2026-01-04T12:00:00.000Z", "endAt": "2026-01-04T18:00:00.000Z", "status": "free" } + ] + } + ] +} +``` + +## Multiple resources + +Query multiple resources in a single request: + +```bash +curl "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/availability?resourceIds=$RESOURCE_1&resourceIds=$RESOURCE_2&startAt=2026-01-04T10:00:00Z&endAt=2026-01-04T18:00:00Z" +``` + +Each resource gets its own timeline in the response. + +## What counts as "busy" + +A time slot is marked `busy` if it overlaps with: + +- A `hold` allocation that hasn't expired +- A `confirmed` allocation + +Expired holds and cancelled allocations do **not** block time. + +## Timeline behavior + +- **Clamping**: Allocations extending outside the query window are clamped to fit +- **Merging**: Overlapping and adjacent busy blocks are merged into single blocks +- **Gaps filled**: Free blocks are automatically generated for unoccupied time + +## Use case: find available slots + +1. Query availability for the desired time window +2. Find `free` blocks that match your duration requirements +3. Create a hold on the chosen slot + +```javascript +const { data } = await fetch(`${baseUrl}/v1/ledgers/${ledgerId}/availability?...`).then(r => r.json()); + +const freeSlots = data[0].timeline.filter(block => block.status === "free"); +const suitableSlot = freeSlots.find(slot => { + const duration = new Date(slot.endAt) - new Date(slot.startAt); + return duration >= requiredDuration; +}); +``` diff --git a/docs/introduction.md b/docs/introduction.md index 23d602b..39d9f23 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -38,6 +38,7 @@ See the [Quickstart](./quickstart) for full setup instructions. - [Quickstart](./quickstart) - Get running in 5 minutes - [Allocations](./allocations) - The booking model +- [Availability](./availability) - Query free/busy timelines - [Webhooks](./webhooks) - Real-time notifications - [Idempotency](./idempotency) - Safe retries - [Errors](./errors) - Error handling diff --git a/docs/quickstart.md b/docs/quickstart.md index 543bb37..fa3d014 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -96,7 +96,7 @@ Response: ```json { "data": { - "id": "ws_01abc123def456ghi789jkl012", + "id": "ldg_01abc123def456ghi789jkl012", "createdAt": "2026-01-04T10:00:00.000Z", "updatedAt": "2026-01-04T10:00:00.000Z" } @@ -133,7 +133,7 @@ curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/allocations" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: demo-001" \ -d '{ - "resourceId": "res_01abc123def456ghi789jkl012", + "resourceId": "rsc_01abc123def456ghi789jkl012", "startAt": "2026-01-04T10:00:00Z", "endAt": "2026-01-04T10:30:00Z", "expiresAt": "2026-01-04T10:05:00Z", @@ -146,9 +146,9 @@ Response: ```json { "data": { - "id": "alloc_01abc123def456ghi789jkl012", - "ledgerId": "ws_01abc123def456ghi789jkl012", - "resourceId": "res_01abc123def456ghi789jkl012", + "id": "alc_01abc123def456ghi789jkl012", + "ledgerId": "ldg_01abc123def456ghi789jkl012", + "resourceId": "rsc_01abc123def456ghi789jkl012", "startAt": "2026-01-04T10:00:00.000Z", "endAt": "2026-01-04T10:30:00.000Z", "status": "hold", @@ -218,9 +218,9 @@ Response: { "data": [ { - "id": "alloc_01abc123def456ghi789jkl012", - "ledgerId": "ws_01abc123def456ghi789jkl012", - "resourceId": "res_01abc123def456ghi789jkl012", + "id": "alc_01abc123def456ghi789jkl012", + "ledgerId": "ldg_01abc123def456ghi789jkl012", + "resourceId": "rsc_01abc123def456ghi789jkl012", "status": "confirmed", "startAt": "2026-01-04T10:00:00.000Z", "endAt": "2026-01-04T10:30:00.000Z", @@ -236,6 +236,7 @@ Response: ## Next - [Allocations](./allocations.md) - Deep dive into the booking model +- [Availability](./availability.md) - Query free/busy timelines - [Idempotency](./idempotency.md) - Safe retries - [Webhooks](./webhooks.md) - Real-time notifications - [Errors](./errors.md) - Error handling diff --git a/scalar.config.json b/scalar.config.json index 3012a7d..335083d 100644 --- a/scalar.config.json +++ b/scalar.config.json @@ -25,6 +25,7 @@ "icon": "phosphor/regular/puzzle-piece", "children": [ { "type": "page", "name": "Allocations", "path": "docs/allocations.md" }, + { "type": "page", "name": "Availability", "path": "docs/availability.md" }, { "type": "page", "name": "Webhooks", "path": "docs/webhooks.md" }, { "type": "page", "name": "Idempotency", "path": "docs/idempotency.md" }, { "type": "page", "name": "Errors", "path": "docs/errors.md" }, From 083547b762c3acee53283bdf864425192345c775 Mon Sep 17 00:00:00 2001 From: Ali Davut Date: Sat, 7 Feb 2026 03:15:18 +0300 Subject: [PATCH 5/5] Fix formatting --- apps/server/openapi.json | 526 ++++-------------- .../20260206235234_remove-timezone.ts | 4 +- apps/server/src/scripts/generate-openapi.ts | 9 +- docs/availability.md | 26 +- 4 files changed, 140 insertions(+), 425 deletions(-) diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 0c18dd6..dc6b280 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -32,11 +32,7 @@ "type": "string" } }, - "required": [ - "id", - "createdAt", - "updatedAt" - ] + "required": ["id", "createdAt", "updatedAt"] }, "Resource": { "type": "object", @@ -54,12 +50,7 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "createdAt", - "updatedAt" - ] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] }, "Allocation": { "type": "object", @@ -75,12 +66,7 @@ }, "status": { "type": "string", - "enum": [ - "hold", - "confirmed", - "cancelled", - "expired" - ] + "enum": ["hold", "confirmed", "cancelled", "expired"] }, "startAt": { "type": "string" @@ -89,16 +75,10 @@ "type": "string" }, "expiresAt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "metadata": { - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "additionalProperties": {} }, "createdAt": { @@ -140,13 +120,7 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "url", - "createdAt", - "updatedAt" - ] + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] }, "AvailabilityItem": { "type": "object", @@ -167,24 +141,14 @@ }, "status": { "type": "string", - "enum": [ - "free", - "busy" - ] + "enum": ["free", "busy"] } }, - "required": [ - "startAt", - "endAt", - "status" - ] + "required": ["startAt", "endAt", "status"] } } }, - "required": [ - "resourceId", - "timeline" - ] + "required": ["resourceId", "timeline"] }, "TimelineBlock": { "type": "object", @@ -197,17 +161,10 @@ }, "status": { "type": "string", - "enum": [ - "free", - "busy" - ] + "enum": ["free", "busy"] } }, - "required": [ - "startAt", - "endAt", - "status" - ] + "required": ["startAt", "endAt", "status"] }, "Error": { "type": "object", @@ -231,16 +188,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } }, "parameters": {} @@ -248,9 +201,7 @@ "paths": { "/v1/ledgers": { "get": { - "tags": [ - "Ledgers" - ], + "tags": ["Ledgers"], "summary": "List all ledgers", "responses": { "200": { @@ -275,17 +226,11 @@ "type": "string" } }, - "required": [ - "id", - "createdAt", - "updatedAt" - ] + "required": ["id", "createdAt", "updatedAt"] } } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -293,9 +238,7 @@ } }, "post": { - "tags": [ - "Ledgers" - ], + "tags": ["Ledgers"], "summary": "Create a new ledger", "requestBody": { "content": { @@ -328,16 +271,10 @@ "type": "string" } }, - "required": [ - "id", - "createdAt", - "updatedAt" - ] + "required": ["id", "createdAt", "updatedAt"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -347,9 +284,7 @@ }, "/v1/ledgers/{id}": { "get": { - "tags": [ - "Ledgers" - ], + "tags": ["Ledgers"], "summary": "Get a ledger by ID", "parameters": [ { @@ -384,16 +319,10 @@ "type": "string" } }, - "required": [ - "id", - "createdAt", - "updatedAt" - ] + "required": ["id", "createdAt", "updatedAt"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -424,16 +353,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -443,9 +368,7 @@ }, "/v1/ledgers/{ledgerId}/resources": { "get": { - "tags": [ - "Resources" - ], + "tags": ["Resources"], "summary": "List all resources in a ledger", "parameters": [ { @@ -483,18 +406,11 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "createdAt", - "updatedAt" - ] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] } } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -502,9 +418,7 @@ } }, "post": { - "tags": [ - "Resources" - ], + "tags": ["Resources"], "summary": "Create a new resource", "parameters": [ { @@ -550,17 +464,10 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "createdAt", - "updatedAt" - ] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -570,9 +477,7 @@ }, "/v1/ledgers/{ledgerId}/resources/{id}": { "get": { - "tags": [ - "Resources" - ], + "tags": ["Resources"], "summary": "Get a resource by ID", "parameters": [ { @@ -616,17 +521,10 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "createdAt", - "updatedAt" - ] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -657,16 +555,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -674,9 +568,7 @@ } }, "delete": { - "tags": [ - "Resources" - ], + "tags": ["Resources"], "summary": "Delete a resource", "parameters": [ { @@ -726,16 +618,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -745,9 +633,7 @@ }, "/v1/ledgers/{ledgerId}/availability": { "get": { - "tags": [ - "Availability" - ], + "tags": ["Availability"], "summary": "Query resource availability", "description": "Returns a timeline of free/busy blocks for the specified resources within the given time window. Overlapping and adjacent allocations are merged into single busy blocks.", "parameters": [ @@ -766,9 +652,7 @@ "type": "string" }, "description": "Resource IDs to query (can be repeated)", - "example": [ - "rsc_01abc123def456ghi789jkl012" - ] + "example": ["rsc_01abc123def456ghi789jkl012"] }, "required": true, "description": "Resource IDs to query (can be repeated)", @@ -829,30 +713,18 @@ }, "status": { "type": "string", - "enum": [ - "free", - "busy" - ] + "enum": ["free", "busy"] } }, - "required": [ - "startAt", - "endAt", - "status" - ] + "required": ["startAt", "endAt", "status"] } } }, - "required": [ - "resourceId", - "timeline" - ] + "required": ["resourceId", "timeline"] } } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -862,9 +734,7 @@ }, "/v1/ledgers/{ledgerId}/allocations": { "get": { - "tags": [ - "Allocations" - ], + "tags": ["Allocations"], "summary": "List all allocations in a ledger", "parameters": [ { @@ -900,12 +770,7 @@ }, "status": { "type": "string", - "enum": [ - "hold", - "confirmed", - "cancelled", - "expired" - ] + "enum": ["hold", "confirmed", "cancelled", "expired"] }, "startAt": { "type": "string" @@ -914,16 +779,10 @@ "type": "string" }, "expiresAt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "metadata": { - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "additionalProperties": {} }, "createdAt": { @@ -948,9 +807,7 @@ } } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -958,9 +815,7 @@ } }, "post": { - "tags": [ - "Allocations" - ], + "tags": ["Allocations"], "summary": "Create a new allocation", "description": "Creates a new allocation for a resource. Supports idempotency via the Idempotency-Key header.", "parameters": [ @@ -985,10 +840,7 @@ }, "status": { "type": "string", - "enum": [ - "hold", - "confirmed" - ], + "enum": ["hold", "confirmed"], "default": "hold" }, "startAt": { @@ -1000,25 +852,15 @@ "format": "date-time" }, "expiresAt": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "format": "date-time" }, "metadata": { - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "additionalProperties": {} } }, - "required": [ - "resourceId", - "startAt", - "endAt" - ] + "required": ["resourceId", "startAt", "endAt"] } } } @@ -1045,12 +887,7 @@ }, "status": { "type": "string", - "enum": [ - "hold", - "confirmed", - "cancelled", - "expired" - ] + "enum": ["hold", "confirmed", "cancelled", "expired"] }, "startAt": { "type": "string" @@ -1059,16 +896,10 @@ "type": "string" }, "expiresAt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "metadata": { - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "additionalProperties": {} }, "createdAt": { @@ -1098,14 +929,10 @@ "type": "string" } }, - "required": [ - "serverTime" - ] + "required": ["serverTime"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1136,16 +963,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -1155,9 +978,7 @@ }, "/v1/ledgers/{ledgerId}/allocations/{id}": { "get": { - "tags": [ - "Allocations" - ], + "tags": ["Allocations"], "summary": "Get an allocation by ID", "parameters": [ { @@ -1199,12 +1020,7 @@ }, "status": { "type": "string", - "enum": [ - "hold", - "confirmed", - "cancelled", - "expired" - ] + "enum": ["hold", "confirmed", "cancelled", "expired"] }, "startAt": { "type": "string" @@ -1213,16 +1029,10 @@ "type": "string" }, "expiresAt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "metadata": { - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "additionalProperties": {} }, "createdAt": { @@ -1252,14 +1062,10 @@ "type": "string" } }, - "required": [ - "serverTime" - ] + "required": ["serverTime"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1290,16 +1096,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -1309,9 +1111,7 @@ }, "/v1/ledgers/{ledgerId}/allocations/{id}/confirm": { "post": { - "tags": [ - "Allocations" - ], + "tags": ["Allocations"], "summary": "Confirm a held allocation", "description": "Confirms an allocation that is currently in HOLD status.", "parameters": [ @@ -1354,12 +1154,7 @@ }, "status": { "type": "string", - "enum": [ - "hold", - "confirmed", - "cancelled", - "expired" - ] + "enum": ["hold", "confirmed", "cancelled", "expired"] }, "startAt": { "type": "string" @@ -1368,16 +1163,10 @@ "type": "string" }, "expiresAt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "metadata": { - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "additionalProperties": {} }, "createdAt": { @@ -1407,14 +1196,10 @@ "type": "string" } }, - "required": [ - "serverTime" - ] + "required": ["serverTime"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1445,16 +1230,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -1485,16 +1266,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -1504,9 +1281,7 @@ }, "/v1/ledgers/{ledgerId}/allocations/{id}/cancel": { "post": { - "tags": [ - "Allocations" - ], + "tags": ["Allocations"], "summary": "Cancel an allocation", "description": "Cancels an allocation that is in HOLD or CONFIRMED status.", "parameters": [ @@ -1549,12 +1324,7 @@ }, "status": { "type": "string", - "enum": [ - "hold", - "confirmed", - "cancelled", - "expired" - ] + "enum": ["hold", "confirmed", "cancelled", "expired"] }, "startAt": { "type": "string" @@ -1563,16 +1333,10 @@ "type": "string" }, "expiresAt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "metadata": { - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "additionalProperties": {} }, "createdAt": { @@ -1602,14 +1366,10 @@ "type": "string" } }, - "required": [ - "serverTime" - ] + "required": ["serverTime"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1640,16 +1400,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -1680,16 +1436,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -1699,9 +1451,7 @@ }, "/v1/ledgers/{ledgerId}/webhooks": { "get": { - "tags": [ - "Webhooks" - ], + "tags": ["Webhooks"], "summary": "List webhook subscriptions", "parameters": [ { @@ -1742,19 +1492,11 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "url", - "createdAt", - "updatedAt" - ] + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] } } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1762,9 +1504,7 @@ } }, "post": { - "tags": [ - "Webhooks" - ], + "tags": ["Webhooks"], "summary": "Create a webhook subscription", "description": "Creates a new webhook subscription. The secret is only returned once at creation time.", "parameters": [ @@ -1789,9 +1529,7 @@ "example": "https://example.com/webhook" } }, - "required": [ - "url" - ] + "required": ["url"] } } } @@ -1826,19 +1564,10 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "url", - "createdAt", - "updatedAt", - "secret" - ] + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1848,9 +1577,7 @@ }, "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}": { "patch": { - "tags": [ - "Webhooks" - ], + "tags": ["Webhooks"], "summary": "Update a webhook subscription", "parameters": [ { @@ -1912,18 +1639,10 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "url", - "createdAt", - "updatedAt" - ] + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -1954,16 +1673,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -1971,9 +1686,7 @@ } }, "delete": { - "tags": [ - "Webhooks" - ], + "tags": ["Webhooks"], "summary": "Delete a webhook subscription", "parameters": [ { @@ -2023,16 +1736,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -2042,9 +1751,7 @@ }, "/v1/ledgers/{ledgerId}/webhooks/{subscriptionId}/rotate-secret": { "post": { - "tags": [ - "Webhooks" - ], + "tags": ["Webhooks"], "summary": "Rotate webhook secret", "description": "Generates a new secret for the webhook subscription. The old secret is invalidated immediately.", "parameters": [ @@ -2095,19 +1802,10 @@ "type": "string" } }, - "required": [ - "id", - "ledgerId", - "url", - "createdAt", - "updatedAt", - "secret" - ] + "required": ["id", "ledgerId", "url", "createdAt", "updatedAt", "secret"] } }, - "required": [ - "data" - ] + "required": ["data"] } } } @@ -2138,16 +1836,12 @@ "additionalProperties": {} } }, - "required": [ - "code" - ] + "required": ["code"] } ] } }, - "required": [ - "error" - ] + "required": ["error"] } } } @@ -2157,4 +1851,4 @@ } }, "webhooks": {} -} \ No newline at end of file +} diff --git a/apps/server/src/migrations/20260206235234_remove-timezone.ts b/apps/server/src/migrations/20260206235234_remove-timezone.ts index 068b250..97999cc 100644 --- a/apps/server/src/migrations/20260206235234_remove-timezone.ts +++ b/apps/server/src/migrations/20260206235234_remove-timezone.ts @@ -1,5 +1,5 @@ -import type { Database } from 'database/schema'; -import { Kysely } from 'kysely'; +import type { Database } from "database/schema"; +import { Kysely } from "kysely"; export async function up(db: Kysely): Promise { await db.schema.alterTable("resources").dropColumn("timezone").execute(); diff --git a/apps/server/src/scripts/generate-openapi.ts b/apps/server/src/scripts/generate-openapi.ts index e85618b..4191177 100644 --- a/apps/server/src/scripts/generate-openapi.ts +++ b/apps/server/src/scripts/generate-openapi.ts @@ -2,7 +2,14 @@ import { OpenAPIRegistry, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-ope import { z } from "zod"; import { writeFileSync } from "fs"; import { join } from "path"; -import { allocation, resource, ledger, webhook, error, availability } from "@floyd-run/schema/outputs"; +import { + allocation, + resource, + ledger, + webhook, + error, + availability, +} from "@floyd-run/schema/outputs"; const registry = new OpenAPIRegistry(); diff --git a/docs/availability.md b/docs/availability.md index 54bb366..3d5c390 100644 --- a/docs/availability.md +++ b/docs/availability.md @@ -16,9 +16,21 @@ Response: { "resourceId": "rsc_01abc123def456ghi789jkl012", "timeline": [ - { "startAt": "2026-01-04T10:00:00.000Z", "endAt": "2026-01-04T11:00:00.000Z", "status": "free" }, - { "startAt": "2026-01-04T11:00:00.000Z", "endAt": "2026-01-04T12:00:00.000Z", "status": "busy" }, - { "startAt": "2026-01-04T12:00:00.000Z", "endAt": "2026-01-04T18:00:00.000Z", "status": "free" } + { + "startAt": "2026-01-04T10:00:00.000Z", + "endAt": "2026-01-04T11:00:00.000Z", + "status": "free" + }, + { + "startAt": "2026-01-04T11:00:00.000Z", + "endAt": "2026-01-04T12:00:00.000Z", + "status": "busy" + }, + { + "startAt": "2026-01-04T12:00:00.000Z", + "endAt": "2026-01-04T18:00:00.000Z", + "status": "free" + } ] } ] @@ -57,10 +69,12 @@ Expired holds and cancelled allocations do **not** block time. 3. Create a hold on the chosen slot ```javascript -const { data } = await fetch(`${baseUrl}/v1/ledgers/${ledgerId}/availability?...`).then(r => r.json()); +const { data } = await fetch(`${baseUrl}/v1/ledgers/${ledgerId}/availability?...`).then((r) => + r.json(), +); -const freeSlots = data[0].timeline.filter(block => block.status === "free"); -const suitableSlot = freeSlots.find(slot => { +const freeSlots = data[0].timeline.filter((block) => block.status === "free"); +const suitableSlot = freeSlots.find((slot) => { const duration = new Date(slot.endAt) - new Date(slot.startAt); return duration >= requiredDuration; });