Wiki/bridge/src/routes/views.ts
Corentin JOGUET 95089c460c
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
feat(bridge): add views endpoints for R3.1.a database-view
Two new endpoints under /api/v1/views:
  GET /api/v1/views/table/:tableId  — list views for a table with Redis
    cache TTL 60s. Returns full view metadata (filters, sortings, groupBys,
    order). Cache invalidated by view.created|updated|deleted webhook events.
  GET /api/v1/views/:viewId/data    — paginated rows of a view applying
    Baserow view filters/sorts via ?view_id= query param. Redis cache TTL 30s
    keyed by (viewId, page, size, search). Requires tableId query param.

Domain: View entity extended with order, filters, sortings, groupBys.
Adapter: BaserowListOptions gains viewId param (forwards to Baserow ?view_id=).
Webhook: baserow-handler extended for view.* events — invalidates views:table
  and views:data cache keys. rows.* events now also invalidate views:data:*.
Tests: +44 tests (336 total, was 292). Routes 20, repo 20, webhook 4.
Coverage: view.ts 100%, routes/views.ts 100% lines, baserow-handler 100%.

Co-Authored-By: Amelia (bmad-bmm-dev BYAN) <noreply@anthropic.com>
2026-05-07 23:24:10 +02:00

131 lines
4.7 KiB
TypeScript

/**
* Routes /api/views — R3.1.a database-view.
*
* Deux endpoints :
* GET /api/views/table/:tableId — liste les vues d'une table avec cache Redis
* GET /api/views/:viewId/data — donnees paginées d'une vue (filters/sort/group
* appliques par Baserow via view_id param)
*
* Separation de /api/v1/tables/* voulue : les routes `tables` sont des metadata
* generiques (CRUD rows/fields/views sans cache), les routes `views` ici sont
* des endpoints specialises R3.1.a avec cache et pagination orientee "database
* view" style Notion.
*
* Permissions mappees sur les scopes generiques du bridge :
* - `database.tables.read` dans `acadenice_permissions[]` est traite comme
* `read:tables` par le RBAC DocAdenice avant emission du JWT. On utilise
* donc `requireScope('read:tables')` pour rester coherent avec le systeme
* existant. De meme `database.rows.read` -> `read:tables` (meme scope
* car les rows sont lues en contexte de table).
*/
import { Hono } from 'hono';
import type { Row } from '../domain/row.js';
import type { View } from '../domain/view.js';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const viewsRoutes = new Hono<{ Variables: AuthVariables }>();
// ---------------------------------------------------------------------------
// Serialisation
// ---------------------------------------------------------------------------
function serializeView(v: View) {
return {
id: v.id,
tableId: v.tableId,
name: v.name,
type: v.type,
order: v.order,
filters: v.filters,
sortings: v.sortings,
groupBys: v.groupBys,
};
}
function serializeRow(r: Row) {
return {
id: r.id,
tableId: r.tableId,
fields: r.fields,
order: r.order,
createdOn: r.createdOn?.toISOString() ?? null,
updatedOn: r.updatedOn?.toISOString() ?? null,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseIntParam(raw: string, label: string): number {
const n = Number.parseInt(raw, 10);
if (Number.isNaN(n) || n <= 0) {
throw errors.validation([{ message: `${label} must be a positive integer` }]);
}
return n;
}
function parseIntQuery(url: URL, name: string, defaultVal: number, max?: number): number {
const raw = url.searchParams.get(name);
if (!raw) return defaultVal;
const n = Number.parseInt(raw, 10);
if (Number.isNaN(n) || n <= 0) {
throw errors.validation([{ message: `${name} must be a positive integer` }]);
}
return max !== undefined ? Math.min(n, max) : n;
}
// ---------------------------------------------------------------------------
// GET /api/views/table/:tableId — liste les vues d'une table (cached)
// ---------------------------------------------------------------------------
viewsRoutes.get('/table/:tableId', requireScope('read:tables'), async (c) => {
const tableId = parseIntParam(c.req.param('tableId'), 'tableId');
const { repos, redis } = getContainer();
const views = await repos.views.listByTable(tableId, redis);
return c.json({ data: views.map(serializeView), total: views.length });
});
// ---------------------------------------------------------------------------
// GET /api/views/:viewId/data — donnees d'une vue (rows + pagination cached)
// ---------------------------------------------------------------------------
viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => {
const viewId = parseIntParam(c.req.param('viewId'), 'viewId');
const { repos, redis } = getContainer();
const url = new URL(c.req.url);
const page = parseIntQuery(url, 'page', 1);
const size = parseIntQuery(url, 'size', 100, 200);
const search = url.searchParams.get('search') ?? undefined;
// tableId est requis pour construire les Row instances avec le bon identifiant
// de table. Baserow ne retourne pas le tableId dans la reponse listRows, donc
// le caller doit le fournir. Cela evite un aller-retour supplementaire pour
// aller chercher la vue puis deduire sa table.
const tableIdRaw = url.searchParams.get('tableId');
if (!tableIdRaw) {
throw errors.validation([{ message: 'tableId query param required' }]);
}
const tableId = parseIntParam(tableIdRaw, 'tableId');
const result = await repos.views.getViewData(viewId, tableId, {
page,
size,
search,
redis,
});
return c.json({
data: result.items.map(serializeRow),
total: result.meta.total,
page: result.meta.page,
size: result.meta.per_page,
viewType: result.viewType,
});
});