diff --git a/.changeset/close-the-eight-reports-rest-gaps.md b/.changeset/close-the-eight-reports-rest-gaps.md new file mode 100644 index 0000000000..901a4e22e3 --- /dev/null +++ b/.changeset/close-the-eight-reports-rest-gaps.md @@ -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. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index d074617915..8fa4f327f4 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -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) | diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 1319ebb610..b43ceb6634 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -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({ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f37f55b453..d34818fe28 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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 => { + 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 => { + const res = await this.fetch(`${this.baseUrl}/api/v1/reports`, { + method: 'POST', + body: JSON.stringify(report ?? {}), + }); + return this.unwrapResponse(res); + }, + + /** Get a saved report by id. 404 [REPORT_NOT_FOUND] when absent. */ + get: async (id: string): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}`); + return this.unwrapResponse(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 => { + const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/run`, { + method: 'POST', + body: JSON.stringify({}), + }); + return this.unwrapResponse(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 => { + const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/schedule`, { + method: 'POST', + body: JSON.stringify(opts), + }); + return this.unwrapResponse(res); + }, + + /** List the recurring schedules attached to a report. */ + listSchedules: async (id: string): Promise => { + 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.*`. diff --git a/packages/rest/src/rest-route-ledger.conformance.test.ts b/packages/rest/src/rest-route-ledger.conformance.test.ts index b4c3be9bff..2037f87454 100644 --- a/packages/rest/src/rest-route-ledger.conformance.test.ts +++ b/packages/rest/src/rest-route-ledger.conformance.test.ts @@ -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); }); }); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 894632a9ca..cf4be5fc46 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -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' },