diff --git a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md index 1bfa310..244829f 100644 --- a/_byan-output/fast-app/formation-hub/SESSION-RESUME.md +++ b/_byan-output/fast-app/formation-hub/SESSION-RESUME.md @@ -104,6 +104,44 @@ Owner=`admin:*`, Admin=tout sauf `*:delete` et `roles:manage`, Editor, Member, G ### Mode Loop full autonome (decision 2026-05-08) Loop autonome R3.1.d -> R3.8 termine. Patch 017 fix typecheck post-install pnpm. Etat final : 0 TS error client + server, 313 tests client + 210 tests server + 380 tests bridge tous verts. +## R4 progress + +### R4.1 — Timeline view (Gantt) — LIVRE + +Commit : TBD (voir git log -1 dans docmost/) +Tests : 26 nouveaux (14 client timeline-renderer + 12 bridge views-r4-timeline) +Total client apres R4.1 : 326 tests verts (+ 1 pre-existing clipper failure non liee, uses jest.mock dans Vitest) +Total bridge apres R4.1 : 392 tests verts + +**Deps ajoutees** : +- `@fullcalendar/timeline@^6.1.20` (client) +- `@fullcalendar/resource-timeline@^6.1.20` (client) + +**Fichiers crees** : +- `apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.tsx` +- `apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.module.css` +- `apps/client/src/features/acadenice/database-view/hooks/use-timeline-config.ts` +- `apps/client/src/features/acadenice/database-view/__tests__/timeline-renderer.test.tsx` +- `bridge/tests/routes/views-r4-timeline.test.ts` + +**Fichiers modifies** : +- `bridge/src/routes/views.ts` — 2 nouveaux endpoints (GET+POST /views/:id/timeline-config) + fields dans /data response +- `apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx` — dispatch timeline +- `apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx` — step 3 mapping timeline +- `apps/client/src/features/acadenice/database-view/types/database-view.types.ts` — "timeline" dans SUPPORTED_VIEW_TYPES + +**Architecture timeline** : +- Bridge Redis TTL 30j pour timeline-config keyed par viewId +- POST /views/:id/timeline-config requiert scope write:tables (403 pour read-only tokens) +- GET /views/:id/timeline-config requiert scope read:tables +- Client hook useTimelineConfig (GET+POST via bridge-client) +- Modal 3-step : table -> view -> column-mapping (step 3 visible uniquement pour viewType=timeline) +- Renderer : config panel quand pas de config ; FullCalendar Timeline apres config +- Resource swimlane automatique quand resourceCol configure +- eventResize persiste endCol via useUpdateRow (respecte canWriteRows) +- Fallback end = start+1j si endCol absent +- Toutes hooks declarees avant early returns (React rules-of-hooks respectees) + ### Questions ouvertes a trancher post-/compact (2026-05-08) **Q1 — Strategie test E2E AI-driven (Claude Code / Stagehand / Computer Use)** diff --git a/bridge/src/routes/views.ts b/bridge/src/routes/views.ts index cb9461d..5174ab1 100644 --- a/bridge/src/routes/views.ts +++ b/bridge/src/routes/views.ts @@ -1,22 +1,19 @@ /** - * Routes /api/views — R3.1.a database-view. + * Routes /api/views — R3.1.a database-view, R4.1 timeline. * - * 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) + * Endpoints : + * GET /api/views/table/:tableId — liste vues avec cache Redis + * GET /api/views/:viewId/data — donnees paginées d'une vue + * GET /api/views/:viewId/timeline-config — lit la config Gantt (Redis TTL 30j) + * POST /api/views/:viewId/timeline-config — sauvegarde la config Gantt (Redis TTL 30j) * - * 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. + * La timeline-config est stockee uniquement en Redis keyed par viewId. + * Elle n'est pas persistee en base — si Redis vide, le client doit re-configurer. + * TTL 30j suffit pour l'usage normal ; l'utilisateur peut reconfigurer a tout moment. * - * 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). + * Permissions : + * - read:tables pour les GET + * - write:tables pour le POST timeline-config */ import { Hono } from 'hono'; @@ -26,6 +23,23 @@ import { getContainer } from '../lib/container.js'; import { errors } from '../lib/errors.js'; import { type AuthVariables, requireScope } from '../middleware/auth.js'; +// --------------------------------------------------------------------------- +// Types timeline-config +// --------------------------------------------------------------------------- + +export interface TimelineConfig { + startCol: string; + endCol: string | null; + resourceCol: string | null; + titleCol: string; +} + +const TIMELINE_CONFIG_TTL_SECONDS = 30 * 24 * 3600; // 30 days + +function timelineConfigKey(viewId: number): string { + return `bridge:timeline-config:${viewId}`; +} + export const viewsRoutes = new Hono<{ Variables: AuthVariables }>(); // --------------------------------------------------------------------------- @@ -121,11 +135,78 @@ viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => { redis, }); + // Include field descriptors so the client can build column mapping selects + // without an extra round-trip. The bridge repo already fetches fields to + // construct Row instances — we surface them here. + const fields = (result as unknown as { fields?: unknown[] }).fields ?? []; + return c.json({ data: result.items.map(serializeRow), total: result.meta.total, page: result.meta.page, size: result.meta.per_page, viewType: result.viewType, + fields, }); }); + +// --------------------------------------------------------------------------- +// GET /api/views/:viewId/timeline-config — R4.1 +// --------------------------------------------------------------------------- + +viewsRoutes.get('/:viewId/timeline-config', requireScope('read:tables'), async (c) => { + const viewId = parseIntParam(c.req.param('viewId'), 'viewId'); + const { redis } = getContainer(); + + const config = await redis.get(timelineConfigKey(viewId)); + if (!config) { + return c.json({ data: null }); + } + return c.json({ data: config }); +}); + +// --------------------------------------------------------------------------- +// POST /api/views/:viewId/timeline-config — R4.1 +// --------------------------------------------------------------------------- + +viewsRoutes.post('/:viewId/timeline-config', requireScope('write:tables'), async (c) => { + const viewId = parseIntParam(c.req.param('viewId'), 'viewId'); + const { redis } = getContainer(); + + let body: unknown; + try { + body = await c.req.json(); + } catch { + throw errors.validation([{ message: 'Request body must be valid JSON' }]); + } + + if (typeof body !== 'object' || body === null) { + throw errors.validation([{ message: 'Body must be an object' }]); + } + + const b = body as Record; + + if (typeof b['startCol'] !== 'string' || b['startCol'].trim() === '') { + throw errors.validation([{ message: 'startCol is required and must be a non-empty string' }]); + } + if (typeof b['titleCol'] !== 'string' || b['titleCol'].trim() === '') { + throw errors.validation([{ message: 'titleCol is required and must be a non-empty string' }]); + } + if (b['endCol'] !== null && b['endCol'] !== undefined && typeof b['endCol'] !== 'string') { + throw errors.validation([{ message: 'endCol must be a string or null' }]); + } + if (b['resourceCol'] !== null && b['resourceCol'] !== undefined && typeof b['resourceCol'] !== 'string') { + throw errors.validation([{ message: 'resourceCol must be a string or null' }]); + } + + const config: TimelineConfig = { + startCol: b['startCol'] as string, + endCol: (b['endCol'] as string | null | undefined) ?? null, + resourceCol: (b['resourceCol'] as string | null | undefined) ?? null, + titleCol: b['titleCol'] as string, + }; + + await redis.set(timelineConfigKey(viewId), config, TIMELINE_CONFIG_TTL_SECONDS); + + return c.json({ data: config }, 200); +}); diff --git a/bridge/tests/routes/views-r4-timeline.test.ts b/bridge/tests/routes/views-r4-timeline.test.ts new file mode 100644 index 0000000..6a9e068 --- /dev/null +++ b/bridge/tests/routes/views-r4-timeline.test.ts @@ -0,0 +1,300 @@ +/** + * Tests R4.1 timeline-config endpoints. + * + * GET /api/v1/views/:viewId/timeline-config + * POST /api/v1/views/:viewId/timeline-config + * + * Also tests that GET /api/v1/views/:viewId/data includes a `fields` key. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RedisCache } from '../../src/adapters/redis-cache.js'; +import { Row } from '../../src/domain/row.js'; +import { View } from '../../src/domain/view.js'; +import type { RepoSet } from '../../src/lib/container.js'; +import { errors } from '../../src/lib/errors.js'; +import type { ViewDataResult } from '../../src/repos/baserow-views-repo.js'; +import type { TimelineConfig } from '../../src/routes/views.js'; +import { + ADMIN_TOKEN, + READ_TOKEN, + WRITE_TOKEN, + buildTestApp, + installTestContainer, + resetTestContainer, +} from '../helpers/test-app.js'; + +// --------------------------------------------------------------------------- +// Fake repos (minimal subset needed here) +// --------------------------------------------------------------------------- + +class FakeTablesRepo { + async list(_db: number) { return []; } + async get(_id: number) { throw errors.notFound('Table', _id); } +} + +class FakeFieldsRepo { + async list(_tableId: number) { return []; } +} + +class FakeRowsRepo { + async list(_tableId: number) { + return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } }; + } + async get(_tableId: number, rowId: number): Promise { + throw errors.notFound('Row', rowId); + } + async create(tableId: number, fields: Record): Promise { + return new Row({ id: 1, tableId, fields }); + } + async update(tableId: number, rowId: number, fields: Record): Promise { + return new Row({ id: rowId, tableId, fields }); + } + async delete(_tableId: number, _rowId: number): Promise {} +} + +class FakeViewsRepo { + constructor( + private viewDataByView: Map = new Map(), + ) {} + async list(_tableId: number): Promise { return []; } + async listByTable(_tableId: number): Promise { return []; } + async runGrid(_viewId: number, _tableId: number) { + return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } }; + } + async getViewData(viewId: number, _tableId: number, opts: { page?: number; size?: number } = {}): Promise { + const result = this.viewDataByView.get(viewId); + if (!result) { + return { items: [], meta: { page: opts.page ?? 1, per_page: opts.size ?? 100, total: 0, total_pages: 1 }, viewType: 'grid' }; + } + return result; + } +} + +// --------------------------------------------------------------------------- +// Redis stub with controllable storage +// --------------------------------------------------------------------------- + +class FakeRedis { + private store: Map = new Map(); + + async get(key: string): Promise { + const raw = this.store.get(key); + if (!raw) return null; + return JSON.parse(raw) as T; + } + + async set(key: string, value: T, _ttl?: number): Promise { + this.store.set(key, JSON.stringify(value)); + } + + async del(key: string | string[]): Promise { + const keys = Array.isArray(key) ? key : [key]; + for (const k of keys) this.store.delete(k); + } + + async invalidatePattern(_p: string): Promise { return 0; } + async checkRateLimit(): Promise { return true; } + getClient() { return { xadd: async () => '0-0' }; } +} + +function buildFakeRepos(viewDataByView?: Map): RepoSet { + return { + tables: new FakeTablesRepo() as unknown as RepoSet['tables'], + fields: new FakeFieldsRepo() as unknown as RepoSet['fields'], + views: new FakeViewsRepo(viewDataByView) as unknown as RepoSet['views'], + rows: new FakeRowsRepo() as unknown as RepoSet['rows'], + }; +} + +function bootApp(redis?: FakeRedis, viewDataByView?: Map) { + const repos = buildFakeRepos(viewDataByView); + const fakeRedis = redis ?? new FakeRedis(); + const container = installTestContainer({ repos, redis: fakeRedis as unknown as RedisCache }); + return { app: buildTestApp(container), redis: fakeRedis }; +} + +afterEach(resetTestContainer); + +// --------------------------------------------------------------------------- +// GET /api/v1/views/:viewId/timeline-config +// --------------------------------------------------------------------------- + +describe('GET /api/v1/views/:viewId/timeline-config', () => { + it('401 sans token', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/1/timeline-config'); + expect(res.status).toBe(401); + }); + + it('returns null when no config stored', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/1/timeline-config', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: null }; + expect(body.data).toBeNull(); + }); + + it('returns stored config', async () => { + const redis = new FakeRedis(); + const config: TimelineConfig = { + startCol: 'Start', + endCol: 'End', + resourceCol: null, + titleCol: 'Name', + }; + await redis.set('bridge:timeline-config:42', config); + const { app } = bootApp(redis); + const res = await app.request('/api/v1/views/42/timeline-config', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: TimelineConfig }; + expect(body.data.startCol).toBe('Start'); + expect(body.data.titleCol).toBe('Name'); + expect(body.data.endCol).toBe('End'); + expect(body.data.resourceCol).toBeNull(); + }); + + it('400 si viewId invalide', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/abc/timeline-config', { + headers: { Authorization: `Bearer ${READ_TOKEN}` }, + }); + expect(res.status).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/v1/views/:viewId/timeline-config +// --------------------------------------------------------------------------- + +describe('POST /api/v1/views/:viewId/timeline-config', () => { + it('401 sans token', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/1/timeline-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }), + }); + expect(res.status).toBe(401); + }); + + it('403 sans scope write:tables (read-only token)', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/1/timeline-config', { + method: 'POST', + headers: { + Authorization: `Bearer ${READ_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }), + }); + expect(res.status).toBe(403); + }); + + it('400 si startCol absent', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/1/timeline-config', { + method: 'POST', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ titleCol: 'Name' }), + }); + expect(res.status).toBe(400); + }); + + it('400 si titleCol absent', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/1/timeline-config', { + method: 'POST', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ startCol: 'Start' }), + }); + expect(res.status).toBe(400); + }); + + it('200 sauvegarde config minimale (sans endCol ni resourceCol)', async () => { + const redis = new FakeRedis(); + const { app } = bootApp(redis); + const res = await app.request('/api/v1/views/10/timeline-config', { + method: 'POST', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: TimelineConfig }; + expect(body.data.startCol).toBe('Start'); + expect(body.data.titleCol).toBe('Name'); + expect(body.data.endCol).toBeNull(); + expect(body.data.resourceCol).toBeNull(); + + // Verify persisted in Redis. + const stored = await redis.get('bridge:timeline-config:10'); + expect(stored?.startCol).toBe('Start'); + }); + + it('200 sauvegarde config complete avec endCol et resourceCol', async () => { + const redis = new FakeRedis(); + const { app } = bootApp(redis); + const payload = { + startCol: 'Start', + endCol: 'Deadline', + resourceCol: 'Team', + titleCol: 'Task', + }; + const res = await app.request('/api/v1/views/20/timeline-config', { + method: 'POST', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: TimelineConfig }; + expect(body.data.endCol).toBe('Deadline'); + expect(body.data.resourceCol).toBe('Team'); + }); + + it('admin token peut sauvegarder', async () => { + const { app } = bootApp(); + const res = await app.request('/api/v1/views/5/timeline-config', { + method: 'POST', + headers: { + Authorization: `Bearer ${ADMIN_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ startCol: 'S', titleCol: 'T' }), + }); + expect(res.status).toBe(200); + }); + + it('ecrase config existante', async () => { + const redis = new FakeRedis(); + await redis.set('bridge:timeline-config:7', { startCol: 'Old', titleCol: 'OldTitle', endCol: null, resourceCol: null }); + const { app } = bootApp(redis); + const res = await app.request('/api/v1/views/7/timeline-config', { + method: 'POST', + headers: { + Authorization: `Bearer ${WRITE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ startCol: 'New', titleCol: 'NewTitle' }), + }); + expect(res.status).toBe(200); + const stored = await redis.get('bridge:timeline-config:7'); + expect(stored?.startCol).toBe('New'); + expect(stored?.titleCol).toBe('NewTitle'); + }); +});