import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Filter, RowsResponse, Sort, Summaries, SummaryResponse } from '@wellformed/shared'; import { badRequest, notFound } from '../errors.js'; import { getTable, type MetaSnapshot } from '../db/meta.js'; import { assertRoomForRows } from '../billing/limits.js'; import { deleteRow, insertRow, updateRow } from '../engine/mutate.js'; import { fetchRow, runQuery, runSummary } from '../engine/query.js'; import { filterSchema, rowIdSchema, rowsQuerySchema, sortSchema, summariesSchema, summaryQuerySchema, tableNameSchema, } from './schemas.js'; const tableParams = z.object({ table: tableNameSchema }); const rowParams = z.object({ table: tableNameSchema, id: rowIdSchema }); const writeBodySchema = z.object({ values: z.record(z.string(), z.unknown()), /** Only honoured on insert, and only to restore a row an undo removed. */ id: rowIdSchema.optional(), }); function parseJsonParam(raw: string | undefined, schema: z.ZodType, what: string): T | null { if (!raw) return null; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw badRequest(`The "${what}" parameter is not valid JSON.`); } const result = schema.safeParse(parsed); if (!result.success) { throw badRequest(`The "${what}" parameter is malformed.`, result.error.issues); } return result.data; } /** * The rows a request is about: the saved view's filters with the client's * ad-hoc ones layered on top, exactly as the filter chips in the view bar * suggest. Shared so the aggregate row summarises the rows the grid is showing * and not some near-miss of them. */ function resolveScope( meta: MetaSnapshot, table: string, query: { view?: string; filters?: string; hidden?: string }, ): { filters: Filter[]; sort: Sort | null; hidden: string[] } { let filters: Filter[] = []; let sort: Sort | null = null; let hidden: string[] = []; if (query.view) { const view = meta.views.find((v) => v.id === query.view && v.table === table); if (!view) throw notFound(`No view "${query.view}" on "${table}".`); filters = [...view.filters]; sort = view.sort; hidden = [...view.hidden]; } const extra = parseJsonParam(query.filters, z.array(filterSchema).max(20), 'filters'); if (extra) filters = [...filters, ...extra]; // Unlike filters, hidden replaces rather than layers: the client sends the // whole set it wants left out, or no parameter to take the view's word for it. if (query.hidden !== undefined) { hidden = parseJsonParam(query.hidden, z.array(z.string()).max(500), 'hidden') ?? []; } return { filters, sort, hidden }; } export async function rowRoutes(app: FastifyInstance): Promise { app.get('/tables/:table/rows', async (request): Promise => { request.ws.require('viewer'); const { table } = tableParams.parse(request.params); const query = rowsQuerySchema.parse(request.query); const meta = request.ws.meta(); getTable(meta, table); const scope = resolveScope(meta, table, query); const filters = scope.filters; let sort = scope.sort; const sortOverride = parseJsonParam(query.sort, sortSchema.nullable(), 'sort'); if (query.sort !== undefined) sort = sortOverride; const result = runQuery(request.ws.db, meta, { table, filters, sort, search: query.q, hidden: scope.hidden, limit: query.limit, offset: query.offset, }); return { rows: result.rows, total: result.total, limit: query.limit, offset: query.offset, sql: result.sql, tokens: result.tokens, columnErrors: result.columnErrors, }; }); /** * The aggregate row under the grid. * * A separate call rather than a field on the rows response: the grid pages, * and the summary does not — hanging it off page one would recompute it on * every scroll and leave it stale on every other page. Which functions to * compute come from the request, so the footer answers while the choice is * still unsaved, and only becomes the view's when the view is saved. */ app.get('/tables/:table/summary', async (request): Promise => { request.ws.require('viewer'); const { table } = tableParams.parse(request.params); const query = summaryQuerySchema.parse(request.query); const meta = request.ws.meta(); getTable(meta, table); const { filters, hidden } = resolveScope(meta, table, query); const requested = parseJsonParam(query.summaries, summariesSchema, 'summaries'); const view = query.view ? meta.views.find((v) => v.id === query.view) : undefined; const summaries: Summaries = requested ?? view?.summaries ?? {}; return runSummary(request.ws.db, meta, { table, filters, search: query.q, hidden, summaries }); }); app.get('/tables/:table/rows/:id', async (request) => { request.ws.require('viewer'); const { table, id } = rowParams.parse(request.params); const meta = request.ws.meta(); getTable(meta, table); const row = fetchRow(request.ws.db, meta, table, id); if (!row) throw notFound(`No row "${id}" in "${table}".`); return { row }; }); app.post('/tables/:table/rows', async (request, reply) => { request.ws.require('editor'); const { table } = tableParams.parse(request.params); const body = writeBodySchema.parse(request.body); const meta = request.ws.meta(); // The one place a workspace grows a row. Editing and deleting are never // gated on the plan — only adding is. assertRoomForRows(request.ws.plan(), request.ws.db, meta); const result = insertRow( request.ws.db, meta, table, body.values, request.ws.actor(), body.id, ); reply.code(201); return result; }); app.patch('/tables/:table/rows/:id', async (request) => { request.ws.require('editor'); const { table, id } = rowParams.parse(request.params); const body = writeBodySchema.omit({ id: true }).parse(request.body); return updateRow(request.ws.db, request.ws.meta(), table, id, body.values, request.ws.actor()); }); app.delete('/tables/:table/rows/:id', async (request) => { request.ws.require('editor'); const { table, id } = rowParams.parse(request.params); const result = deleteRow(request.ws.db, request.ws.meta(), table, id, request.ws.actor()); return result; }); }