diff --git a/bridge/src/adapters/baserow-admin-client.ts b/bridge/src/adapters/baserow-admin-client.ts new file mode 100644 index 0000000..4f359dd --- /dev/null +++ b/bridge/src/adapters/baserow-admin-client.ts @@ -0,0 +1,218 @@ +/** + * Baserow Admin Client — endpoints CRUD pour tables / fields / views. + * + * Toutes les ops nécessitent un user JWT (Bearer "JWT ") car les + * Database Tokens (Token brg_*) ne couvrent que les rows. Le JWT est obtenu + * via le `BaserowJwtManager` configuré avec un compte de service Baserow + * (BASEROW_USER_EMAIL / BASEROW_USER_PASSWORD). + * + * Routes Baserow utilisées (référence: https://baserow.io/api-docs) : + * POST /api/database/tables/database/{database_id}/ create-table + * PATCH /api/database/tables/{table_id}/ update-table + * DELETE /api/database/tables/{table_id}/ delete-table + * POST /api/database/fields/table/{table_id}/ create-field + * PATCH /api/database/fields/{field_id}/ update-field + * DELETE /api/database/fields/{field_id}/ delete-field + * POST /api/database/views/table/{table_id}/ create-view + * PATCH /api/database/views/{view_id}/ update-view + * DELETE /api/database/views/{view_id}/ delete-view + * GET /api/applications/workspace/{workspace_id}/ list-databases + */ + +import { ofetch } from 'ofetch'; +import type { Logger } from 'pino'; +import type { BaserowJwtManager } from '../lib/baserow-jwt-manager.js'; +import { errors } from '../lib/errors.js'; + +export interface BaserowAdminClientOptions { + baseUrl: string; + jwtManager: BaserowJwtManager; + logger: Logger; +} + +export type FieldType = + | 'text' + | 'long_text' + | 'number' + | 'rating' + | 'boolean' + | 'date' + | 'last_modified' + | 'created_on' + | 'url' + | 'email' + | 'phone_number' + | 'single_select' + | 'multiple_select' + | 'link_row' + | 'file' + | 'formula' + | 'lookup' + | 'count' + | 'rollup' + | 'multiple_collaborators' + | 'autonumber' + | 'duration' + | 'uuid'; + +export interface CreateFieldPayload { + name: string; + type: FieldType; + // Type-specific extras (passed through verbatim). + // For formula: { formula: "field('A') - field('B')", formula_type?: 'number' } + // For number: { number_decimal_places: 2 } + // For single_select / multiple_select: { select_options: [{value, color}] } + // For link_row: { link_row_table_id: } + [key: string]: unknown; +} + +export interface UpdateFieldPayload { + name?: string; + type?: FieldType; + [key: string]: unknown; +} + +export interface CreateTablePayload { + name: string; + data?: string[][]; + first_row_header?: boolean; +} + +export interface CreateViewPayload { + name: string; + type: 'grid' | 'gallery' | 'form' | 'kanban' | 'calendar' | 'timeline'; + filter_type?: 'AND' | 'OR'; + filters_disabled?: boolean; +} + +export class BaserowAdminClient { + private readonly baseUrl: string; + private readonly jwtManager: BaserowJwtManager; + private readonly logger: Logger; + + constructor(opts: BaserowAdminClientOptions) { + this.baseUrl = opts.baseUrl; + this.jwtManager = opts.jwtManager; + this.logger = opts.logger; + } + + private async request( + path: string, + init?: { method?: string; body?: unknown }, + ): Promise { + if (!this.jwtManager.isEnabled()) { + throw errors.baserowUserAuthNotConfigured(); + } + const token = await this.jwtManager.getToken(); + const url = `${this.baseUrl}${path}`; + return ofetch(url, { + method: init?.method, + body: init?.body !== undefined ? JSON.stringify(init.body) : undefined, + headers: { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }, + retry: 1, + retryDelay: 200, + timeout: 15_000, + onResponseError: ({ response }) => { + this.logger.error( + { status: response.status, url, body: response._data }, + 'baserow admin error', + ); + }, + }).catch((err: unknown) => { + const error = err as { response?: { status?: number; _data?: unknown } }; + const status = error.response?.status; + if (status === 401 || status === 403) throw errors.authInvalid(); + if (status === 404) throw errors.notFound('Baserow resource', path); + if (!error.response) throw errors.baserowDown(); + throw err; + }); + } + + // -------- Workspaces / databases -------- + + async listWorkspaces(): Promise { + return this.request(`/workspaces/`); + } + + async listDatabases(workspaceId: number): Promise { + return this.request( + `/applications/workspace/${workspaceId}/`, + ); + } + + async createDatabase(workspaceId: number, name: string): Promise { + return this.request(`/applications/workspace/${workspaceId}/`, { + method: 'POST', + body: { name, type: 'database' }, + }); + } + + // -------- Tables -------- + + async listTables(databaseId: number): Promise { + return this.request( + `/database/tables/database/${databaseId}/`, + ); + } + + async createTable(databaseId: number, payload: CreateTablePayload): Promise { + return this.request(`/database/tables/database/${databaseId}/`, { + method: 'POST', + body: payload, + }); + } + + async updateTable(tableId: number, payload: { name: string }): Promise { + return this.request(`/database/tables/${tableId}/`, { + method: 'PATCH', + body: payload, + }); + } + + async deleteTable(tableId: number): Promise { + await this.request(`/database/tables/${tableId}/`, { method: 'DELETE' }); + } + + // -------- Fields -------- + + async createField(tableId: number, payload: CreateFieldPayload): Promise { + return this.request(`/database/fields/table/${tableId}/`, { + method: 'POST', + body: payload, + }); + } + + async updateField(fieldId: number, payload: UpdateFieldPayload): Promise { + return this.request(`/database/fields/${fieldId}/`, { + method: 'PATCH', + body: payload, + }); + } + + async deleteField(fieldId: number): Promise { + await this.request(`/database/fields/${fieldId}/`, { method: 'DELETE' }); + } + + // -------- Views -------- + + async createView(tableId: number, payload: CreateViewPayload): Promise { + return this.request(`/database/views/table/${tableId}/`, { + method: 'POST', + body: payload, + }); + } + + async updateView(viewId: number, payload: Partial): Promise { + return this.request(`/database/views/${viewId}/`, { + method: 'PATCH', + body: payload, + }); + } + + async deleteView(viewId: number): Promise { + await this.request(`/database/views/${viewId}/`, { method: 'DELETE' }); + } +} diff --git a/bridge/src/routes/admin.ts b/bridge/src/routes/admin.ts new file mode 100644 index 0000000..6c1e6e2 --- /dev/null +++ b/bridge/src/routes/admin.ts @@ -0,0 +1,221 @@ +/** + * Routes /api/v1/admin — CRUD tables / fields / views via Baserow user JWT. + * + * Toutes ces routes nécessitent le scope `tables:write` (ou `admin:*`) côté + * caller, et le BASEROW_USER_EMAIL/PASSWORD côté config (sinon 503). + * + * Endpoints exposés : + * GET /v1/admin/databases?workspaceId=N list databases in workspace + * POST /v1/admin/databases body: { workspaceId, name } + * GET /v1/admin/tables?databaseId=N list tables in database + * POST /v1/admin/tables body: { databaseId, name } + * PATCH /v1/admin/tables/:tableId body: { name } + * DELETE /v1/admin/tables/:tableId + * POST /v1/admin/tables/:tableId/fields body: { name, type, ... } + * PATCH /v1/admin/fields/:fieldId body: { name?, type?, ... } + * DELETE /v1/admin/fields/:fieldId + * POST /v1/admin/tables/:tableId/views body: { name, type } + * PATCH /v1/admin/views/:viewId body: { name?, ... } + * DELETE /v1/admin/views/:viewId + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { getContainer } from '../lib/container.js'; +import { errors } from '../lib/errors.js'; +import { parseBody } from '../lib/http.js'; +import { type AuthVariables, requireScope } from '../middleware/auth.js'; + +export const adminRoutes = new Hono<{ Variables: AuthVariables }>(); + +// ---------- Schemas ---------- + +const createDatabaseSchema = z.object({ + workspaceId: z.coerce.number().int().positive(), + name: z.string().min(1).max(255), +}); + +const createTableSchema = z.object({ + databaseId: z.coerce.number().int().positive(), + name: z.string().min(1).max(255), + data: z.array(z.array(z.string())).optional(), + first_row_header: z.boolean().optional(), +}); + +const updateTableSchema = z.object({ + name: z.string().min(1).max(255), +}); + +const fieldTypeSchema = z.enum([ + 'text', + 'long_text', + 'number', + 'rating', + 'boolean', + 'date', + 'last_modified', + 'created_on', + 'url', + 'email', + 'phone_number', + 'single_select', + 'multiple_select', + 'link_row', + 'file', + 'formula', + 'lookup', + 'count', + 'rollup', + 'multiple_collaborators', + 'autonumber', + 'duration', + 'uuid', +]); + +const createFieldSchema = z + .object({ + name: z.string().min(1).max(255), + type: fieldTypeSchema, + }) + .passthrough(); + +const updateFieldSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + type: fieldTypeSchema.optional(), + }) + .passthrough(); + +const createViewSchema = z + .object({ + name: z.string().min(1).max(255), + type: z.enum(['grid', 'gallery', 'form', 'kanban', 'calendar', 'timeline']), + }) + .passthrough(); + +const updateViewSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + }) + .passthrough(); + +// ---------- Helpers ---------- + +function parseIntId(raw: string | undefined, label: string): number { + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + throw errors.validation([`${label} must be a positive integer`]); + } + return n; +} + +// ---------- Workspaces ---------- + +adminRoutes.get('/workspaces', requireScope('tables:write'), async (c) => { + const data = await getContainer().baserowAdmin.listWorkspaces(); + return c.json({ data }); +}); + +// ---------- Databases ---------- + +adminRoutes.get('/databases', requireScope('tables:write'), async (c) => { + const url = new URL(c.req.url); + const wsRaw = url.searchParams.get('workspaceId'); + const workspaceId = parseIntId(wsRaw ?? undefined, 'workspaceId'); + const data = await getContainer().baserowAdmin.listDatabases(workspaceId); + return c.json({ data }); +}); + +adminRoutes.post('/databases', requireScope('tables:write'), async (c) => { + const body = await parseBody(c, createDatabaseSchema); + const data = await getContainer().baserowAdmin.createDatabase( + body.workspaceId, + body.name, + ); + return c.json({ data }, 201); +}); + +// ---------- Tables ---------- + +adminRoutes.get('/tables', requireScope('tables:write'), async (c) => { + const url = new URL(c.req.url); + const dbRaw = url.searchParams.get('databaseId'); + const databaseId = parseIntId(dbRaw ?? undefined, 'databaseId'); + const data = await getContainer().baserowAdmin.listTables(databaseId); + return c.json({ data }); +}); + +adminRoutes.post('/tables', requireScope('tables:write'), async (c) => { + const body = await parseBody(c, createTableSchema); + const data = await getContainer().baserowAdmin.createTable(body.databaseId, { + name: body.name, + data: body.data, + first_row_header: body.first_row_header, + }); + return c.json({ data }, 201); +}); + +adminRoutes.patch('/tables/:tableId', requireScope('tables:write'), async (c) => { + const tableId = parseIntId(c.req.param('tableId'), 'tableId'); + const body = await parseBody(c, updateTableSchema); + const data = await getContainer().baserowAdmin.updateTable(tableId, body); + return c.json({ data }); +}); + +adminRoutes.delete('/tables/:tableId', requireScope('tables:write'), async (c) => { + const tableId = parseIntId(c.req.param('tableId'), 'tableId'); + await getContainer().baserowAdmin.deleteTable(tableId); + return c.body(null, 204); +}); + +// ---------- Fields ---------- + +adminRoutes.post( + '/tables/:tableId/fields', + requireScope('tables:write'), + async (c) => { + const tableId = parseIntId(c.req.param('tableId'), 'tableId'); + const body = await parseBody(c, createFieldSchema); + const data = await getContainer().baserowAdmin.createField(tableId, body); + return c.json({ data }, 201); + }, +); + +adminRoutes.patch('/fields/:fieldId', requireScope('tables:write'), async (c) => { + const fieldId = parseIntId(c.req.param('fieldId'), 'fieldId'); + const body = await parseBody(c, updateFieldSchema); + const data = await getContainer().baserowAdmin.updateField(fieldId, body); + return c.json({ data }); +}); + +adminRoutes.delete('/fields/:fieldId', requireScope('tables:write'), async (c) => { + const fieldId = parseIntId(c.req.param('fieldId'), 'fieldId'); + await getContainer().baserowAdmin.deleteField(fieldId); + return c.body(null, 204); +}); + +// ---------- Views ---------- + +adminRoutes.post( + '/tables/:tableId/views', + requireScope('tables:write'), + async (c) => { + const tableId = parseIntId(c.req.param('tableId'), 'tableId'); + const body = await parseBody(c, createViewSchema); + const data = await getContainer().baserowAdmin.createView(tableId, body); + return c.json({ data }, 201); + }, +); + +adminRoutes.patch('/views/:viewId', requireScope('tables:write'), async (c) => { + const viewId = parseIntId(c.req.param('viewId'), 'viewId'); + const body = await parseBody(c, updateViewSchema); + const data = await getContainer().baserowAdmin.updateView(viewId, body); + return c.json({ data }); +}); + +adminRoutes.delete('/views/:viewId', requireScope('tables:write'), async (c) => { + const viewId = parseIntId(c.req.param('viewId'), 'viewId'); + await getContainer().baserowAdmin.deleteView(viewId); + return c.body(null, 204); +});