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
13 changes: 13 additions & 0 deletions .changeset/close-the-eight-reports-rest-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/client": minor
"@objectstack/rest": patch
---

feat(client): close the 8 reports-family REST gaps (#3587 batch 2/5)

New `client.reports` namespace speaking the plugin-reports REST surface:
`list` / `save` / `get` / `delete` (schedules cascade), `run`, `schedule`,
`listSchedules`, `unschedule`. The two DELETE routes return 204 — the client
methods return `{ deleted: true }` without attempting to parse an empty body.
Fixed path (`/api/v1/reports` is not in `ApiRoutesSchema`), matching the
keys / share-links precedent. REST route-ledger ratchet: 34 → 26.
1 change: 1 addition & 0 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| **analytics** | ✅ | 3 | Analytics queries |
| **automation** | ✅ | 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
| **actions** | ✅ | 2 | Server-registered action handlers (`engine.registerAction`) |
| **reports** | ✅ | 8 | Saved reports: definitions, execution, recurring email schedules (501 without `@objectstack/plugin-reports`) |
| **keys** | ✅ | 1 | API key minting (one-time secret) |
| **shareLinks** | ✅ | 3 | Record share-link management |
| **security** | ✅ | 3 | Suggested audience bindings (admin) |
Expand Down
64 changes: 64 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,70 @@ describe('ObjectStackClient', () => {
});
});

describe('Reports namespace (#3587 gap closure)', () => {
it('reports.list pins GET /reports with filters and unwraps {data}', async () => {
const { client, fetchMock } = createMockClient({ data: [{ id: 'r1' }] });
const rows = await client.reports.list({ object: 'lead', ownerId: 'u1' });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/reports?object=lead&ownerId=u1',
);
expect(rows).toEqual([{ id: 'r1' }]);
});

it('reports.save pins POST /reports', async () => {
const { client, fetchMock } = createMockClient({ id: 'r1' });
await client.reports.save({ name: 'Pipeline', object: 'lead' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/reports');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ name: 'Pipeline', object: 'lead' });
});

it('reports.get / delete pin /reports/:id and delete tolerates 204', async () => {
const { client, fetchMock } = createMockClient({ id: 'r1' });
await client.reports.get('r1');
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/r1');

const del = createMockClient(undefined, 204);
// A 204 has no JSON body — the method must not try to parse one.
del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() });
const out = await del.client.reports.delete('r1');
expect(String(del.fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/r1');
expect(del.fetchMock.mock.calls[0][1].method).toBe('DELETE');
expect(out).toEqual({ deleted: true });
});

it('reports.run pins POST /reports/:id/run', async () => {
const { client, fetchMock } = createMockClient({ rows: [] });
await client.reports.run('r1');
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/reports/r1/run');
expect(init.method).toBe('POST');
});

it('reports.schedule pins POST /reports/:id/schedule with the schedule body', async () => {
const { client, fetchMock } = createMockClient({ id: 's1' });
await client.reports.schedule('r1', { recipients: ['a@example.com'], cronExpression: '0 8 * * 1' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/reports/r1/schedule');
expect(JSON.parse(init.body)).toEqual({ recipients: ['a@example.com'], cronExpression: '0 8 * * 1' });
});

it('reports.listSchedules / unschedule pin the schedule routes', async () => {
const { client, fetchMock } = createMockClient({ data: [{ id: 's1' }] });
const rows = await client.reports.listSchedules('r1');
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/r1/schedules');
expect(rows).toEqual([{ id: 's1' }]);

const del = createMockClient(undefined, 204);
del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() });
const out = await del.client.reports.unschedule('s1');
expect(String(del.fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/schedules/s1');
expect(del.fetchMock.mock.calls[0][1].method).toBe('DELETE');
expect(out).toEqual({ deleted: true });
});
});

describe('Approvals namespace (ADR-0019)', () => {
it('should list approval requests with filters', async () => {
const { client, fetchMock } = createMockClient({
Expand Down
95 changes: 95 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2953,6 +2953,101 @@ export class ObjectStackClient {
}
};

/**
* Saved reports (#3587 gap closure)
*
* Tenant-wide report definitions, execution, and recurring email
* schedules, served by `@objectstack/plugin-reports` behind the REST
* surface. Every route 501s [NOT_IMPLEMENTED] on deployments without the
* reports service. Fixed path — `reports` is not in `ApiRoutesSchema`.
*/
reports = {
/** List saved reports, optionally filtered by object or owner. */
list: async (opts?: { object?: string; ownerId?: string }): Promise<any[]> => {
const params = new URLSearchParams();
if (opts?.object) params.set('object', opts.object);
if (opts?.ownerId) params.set('ownerId', opts.ownerId);
const qs = params.toString();
const res = await this.fetch(`${this.baseUrl}/api/v1/reports${qs ? `?${qs}` : ''}`);
const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res);
return Array.isArray(body) ? body : (body?.data ?? []);
},

/** Create or update a saved report definition. 400 [VALIDATION_FAILED] on a bad spec. */
save: async (report: any): Promise<any> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/reports`, {
method: 'POST',
body: JSON.stringify(report ?? {}),
});
return this.unwrapResponse<any>(res);
},

/** Get a saved report by id. 404 [REPORT_NOT_FOUND] when absent. */
get: async (id: string): Promise<any> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}`);
return this.unwrapResponse<any>(res);
},

/** Delete a saved report; its schedules cascade. */
delete: async (id: string): Promise<{ deleted: boolean }> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (res.status === 204) return { deleted: true };
return this.unwrapResponse<{ deleted: boolean }>(res);
},

/** Execute a saved report and return its rendered output. */
run: async (id: string): Promise<any> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/run`, {
method: 'POST',
body: JSON.stringify({}),
});
return this.unwrapResponse<any>(res);
},

/**
* Create a recurring email schedule for a report. Provide either
* `intervalMinutes` or `cronExpression`; `recipients` is required.
*/
schedule: async (
id: string,
opts: {
recipients: string[];
name?: string;
intervalMinutes?: number;
cronExpression?: string;
timezone?: string;
format?: string;
subjectTemplate?: string;
ownerId?: string;
active?: boolean;
},
): Promise<any> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/schedule`, {
method: 'POST',
body: JSON.stringify(opts),
});
return this.unwrapResponse<any>(res);
},

/** List the recurring schedules attached to a report. */
listSchedules: async (id: string): Promise<any[]> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/schedules`);
const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res);
return Array.isArray(body) ? body : (body?.data ?? []);
},

/** Delete a schedule by its id (report-independent path). */
unschedule: async (scheduleId: string): Promise<{ deleted: boolean }> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/schedules/${encodeURIComponent(scheduleId)}`, {
method: 'DELETE',
});
if (res.status === 204) return { deleted: true };
return this.unwrapResponse<{ deleted: boolean }>(res);
},
};

// The former `views` CRUD namespace was removed in #3612 — no server
// surface mounts /ui/views (both surfaces serve only /ui/view/:object…).
// View definitions are metadata: read and save them via `meta.*`.
Expand Down
12 changes: 6 additions & 6 deletions packages/rest/src/rest-route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,13 @@ describe('REST route ledger hygiene', () => {
});

it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 34 after the
// metadata batch closed its 9 (data-actions 2, search 1, email 1,
// analytics 1, security-explain 2, record-shares 3, sharing-rules 5,
// reports 8, approvals 6, external-datasource 5 remain). Closing a gap =
// reclassify to `sdk` AND lower this bound. Raising it demands an
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 26 after the
// metadata batch closed its 9 and the reports batch its 8 (data-actions 2,
// search 1, email 1, analytics 1, security-explain 2, record-shares 3,
// sharing-rules 5, approvals 6, external-datasource 5 remain). Closing a
// gap = reclassify to `sdk` AND lower this bound. Raising it demands an
// explicit, reviewed decision.
const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
expect(gaps).toBeLessThanOrEqual(34);
expect(gaps).toBeLessThanOrEqual(26);
});
});
24 changes: 8 additions & 16 deletions packages/rest/src/rest-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,14 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
note: 'duplicate mount with the dispatcher /security domain' },

// ── reports ───────────────────────────────────────────────────────────────
{ route: 'GET /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'saved-report listing with no SDK expression' },
{ route: 'POST /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'saved-report upsert with no SDK expression' },
{ route: 'GET /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'saved-report read with no SDK expression' },
{ route: 'DELETE /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'saved-report delete (cascades schedules) with no SDK expression' },
{ route: 'POST /api/v1/reports/:id/run', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'report execution with no SDK expression' },
{ route: 'POST /api/v1/reports/:id/schedule', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'recurring email schedule creation with no SDK expression' },
{ route: 'GET /api/v1/reports/:id/schedules', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'schedule listing with no SDK expression' },
{ route: 'DELETE /api/v1/reports/schedules/:scheduleId', family: 'reports', source: 'route-manager', disposition: 'gap',
note: 'schedule delete with no SDK expression' },
{ route: 'GET /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.list' },
{ route: 'POST /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.save' },
{ route: 'GET /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.get' },
{ route: 'DELETE /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.delete' },
{ route: 'POST /api/v1/reports/:id/run', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.run' },
{ route: 'POST /api/v1/reports/:id/schedule', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.schedule' },
{ route: 'GET /api/v1/reports/:id/schedules', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.listSchedules' },
{ route: 'DELETE /api/v1/reports/schedules/:scheduleId', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.unschedule' },

// ── approvals ─────────────────────────────────────────────────────────────
{ route: 'GET /api/v1/approvals/requests', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.listRequests' },
Expand Down
Loading