diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 38b9fdc..dc6b280 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -43,9 +43,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -53,7 +50,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] }, "Allocation": { "type": "object", @@ -125,6 +122,50 @@ }, "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", "properties": { @@ -358,9 +399,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -368,7 +406,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] } } }, @@ -397,13 +435,7 @@ "application/json": { "schema": { "type": "object", - "properties": { - "timezone": { - "type": "string", - "default": "UTC", - "example": "America/New_York" - } - } + "properties": {} } } } @@ -425,9 +457,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -435,7 +464,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] } }, "required": ["data"] @@ -485,9 +514,6 @@ "ledgerId": { "type": "string" }, - "timezone": { - "type": "string" - }, "createdAt": { "type": "string" }, @@ -495,7 +521,7 @@ "type": "string" } }, - "required": ["id", "ledgerId", "timezone", "createdAt", "updatedAt"] + "required": ["id", "ledgerId", "createdAt", "updatedAt"] } }, "required": ["data"] @@ -605,6 +631,107 @@ } } }, + "/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"] + } + } + } + } + } + } + }, "/v1/ledgers/{ledgerId}/allocations": { "get": { "tags": ["Allocations"], 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/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/migrations/20260206235234_remove-timezone.ts b/apps/server/src/migrations/20260206235234_remove-timezone.ts new file mode 100644 index 0000000..97999cc --- /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/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/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..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 } from "@floyd-run/schema/outputs"; +import { + allocation, + resource, + ledger, + webhook, + error, + availability, +} from "@floyd-run/schema/outputs"; const registry = new OpenAPIRegistry(); @@ -11,6 +18,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 +121,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 +151,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/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..16aa39b --- /dev/null +++ b/apps/server/src/services/availability/query.ts @@ -0,0 +1,60 @@ +import { sql } from "kysely"; +import { db } from "database"; +import { createService } from "lib/service"; +import { availability } from "@floyd-run/schema/inputs"; +import type { AvailabilityItem } from "@floyd-run/schema/types"; +import { clampInterval, mergeIntervals, buildTimeline } from "lib/timeline"; + +interface BlockingAllocation { + resourceId: string; + startAt: Date; + endAt: Date; +} + +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, merge overlaps, build timeline + const clamped = allocations.map((a) => + clampInterval({ start: a.startAt, end: a.endAt }, startAt, endAt), + ); + const merged = mergeIntervals(clamped); + 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/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/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/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/availability.md b/docs/availability.md new file mode 100644 index 0000000..3d5c390 --- /dev/null +++ b/docs/availability.md @@ -0,0 +1,81 @@ +# 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 bce4150..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" } @@ -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" } @@ -136,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", @@ -149,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", @@ -221,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", @@ -239,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/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/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/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/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/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(), }); 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; 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" },