Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 33 additions & 39 deletions apps/server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@
}
},
"/v1/ledgers/{ledgerId}/availability": {
"get": {
"post": {
"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.",
Expand All @@ -644,46 +644,40 @@
"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"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"resourceIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Resource IDs to query",
"example": ["rsc_01abc123def456ghi789jkl012"]
},
"startAt": {
"type": "string",
"format": "date-time",
"description": "Start of the time window (ISO 8601)",
"example": "2026-01-04T10:00:00Z"
},
"endAt": {
"type": "string",
"format": "date-time",
"description": "End of the time window (ISO 8601)",
"example": "2026-01-04T18:00:00Z"
}
},
"required": ["resourceIds", "startAt", "endAt"]
}
}
}
},
"responses": {
"200": {
"description": "Availability timeline for each resource",
Expand Down
12 changes: 5 additions & 7 deletions apps/server/src/routes/v1/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@ import { Hono } from "hono";
import { services } from "../../services/index.js";

// Nested under /v1/ledgers/:ledgerId/availability
export const availability = new Hono().get("/", async (c) => {
export const availability = new Hono().post("/", 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 body = await c.req.json();

const result = await services.availability.query({
ledgerId,
resourceIds,
startAt,
endAt,
resourceIds: body.resourceIds,
startAt: body.startAt,
endAt: body.endAt,
});

return c.json({ data: result.items });
Expand Down
36 changes: 21 additions & 15 deletions apps/server/src/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ registry.registerPath({

// Availability routes
registry.registerPath({
method: "get",
method: "post",
path: "/v1/ledgers/{ledgerId}/availability",
tags: ["Availability"],
summary: "Query resource availability",
Expand All @@ -162,20 +162,26 @@ registry.registerPath({
"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",
}),
}),
body: {
content: {
"application/json": {
schema: z.object({
resourceIds: z.array(z.string()).openapi({
description: "Resource IDs to query",
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: {
Expand Down
82 changes: 51 additions & 31 deletions apps/server/test/v1/availability/query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import { client } from "../../setup/client";
import { createAllocation, createLedger, createResource } from "../../setup/factories";
import type { AvailabilityResponse } from "../../setup/types";

describe("GET /v1/ledgers/:ledgerId/availability", () => {
describe("POST /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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down Expand Up @@ -41,9 +43,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down Expand Up @@ -75,9 +79,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down Expand Up @@ -106,9 +112,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand All @@ -132,9 +140,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down Expand Up @@ -165,9 +175,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down Expand Up @@ -203,9 +215,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down Expand Up @@ -234,9 +248,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand All @@ -262,9 +278,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource1.id, resource2.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down Expand Up @@ -296,9 +314,11 @@ describe("GET /v1/ledgers/:ledgerId/availability", () => {
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}`,
);
const response = await client.post(`/v1/ledgers/${ledger.id}/availability`, {
resourceIds: [resource.id],
startAt,
endAt,
});

expect(response.status).toBe(200);
const { data } = (await response.json()) as AvailabilityResponse;
Expand Down
24 changes: 19 additions & 5 deletions docs/availability.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ 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"
curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/availability" \
-H "Content-Type: application/json" \
-d '{
"resourceIds": ["rsc_01abc123def456ghi789jkl012"],
"startAt": "2026-01-04T10:00:00Z",
"endAt": "2026-01-04T18:00:00Z"
}'
```

Response:
Expand Down Expand Up @@ -42,7 +48,13 @@ Response:
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"
curl -X POST "$FLOYD_BASE_URL/v1/ledgers/$LEDGER_ID/availability" \
-H "Content-Type: application/json" \
-d '{
"resourceIds": ["rsc_resource1", "rsc_resource2"],
"startAt": "2026-01-04T10:00:00Z",
"endAt": "2026-01-04T18:00:00Z"
}'
```

Each resource gets its own timeline in the response.
Expand All @@ -69,9 +81,11 @@ 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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resourceIds, startAt, endAt }),
}).then((r) => r.json());

const freeSlots = data[0].timeline.filter((block) => block.status === "free");
const suitableSlot = freeSlots.find((slot) => {
Expand Down