feat(bridge): add timeline-config endpoints and fields in view data — R4.1

- GET /api/v1/views/:viewId/timeline-config: reads config from Redis (TTL 30d)
- POST /api/v1/views/:viewId/timeline-config: saves config (requires write:tables scope)
- GET /api/v1/views/:viewId/data now includes a fields key for column mapping UI
- TimelineConfig type exported for test reuse
- 12 new Vitest tests covering 401/403/400 validation, full CRUD, Redis persistence
- SESSION-RESUME.md updated with R4.1 progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 11:27:22 +02:00
parent 8ea4c3fd10
commit 8bda6c5f82
3 changed files with 434 additions and 15 deletions

View file

@ -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)**

View file

@ -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<TimelineConfig>(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<string, unknown>;
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);
});

View file

@ -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<Row> {
throw errors.notFound('Row', rowId);
}
async create(tableId: number, fields: Record<string, unknown>): Promise<Row> {
return new Row({ id: 1, tableId, fields });
}
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
return new Row({ id: rowId, tableId, fields });
}
async delete(_tableId: number, _rowId: number): Promise<void> {}
}
class FakeViewsRepo {
constructor(
private viewDataByView: Map<number, ViewDataResult> = new Map(),
) {}
async list(_tableId: number): Promise<View[]> { return []; }
async listByTable(_tableId: number): Promise<View[]> { 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<ViewDataResult> {
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<string, string> = new Map();
async get<T>(key: string): Promise<T | null> {
const raw = this.store.get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
}
async set<T>(key: string, value: T, _ttl?: number): Promise<void> {
this.store.set(key, JSON.stringify(value));
}
async del(key: string | string[]): Promise<void> {
const keys = Array.isArray(key) ? key : [key];
for (const k of keys) this.store.delete(k);
}
async invalidatePattern(_p: string): Promise<number> { return 0; }
async checkRateLimit(): Promise<boolean> { return true; }
getClient() { return { xadd: async () => '0-0' }; }
}
function buildFakeRepos(viewDataByView?: Map<number, ViewDataResult>): 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<number, ViewDataResult>) {
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<TimelineConfig>('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<TimelineConfig>('bridge:timeline-config:7');
expect(stored?.startCol).toBe('New');
expect(stored?.titleCol).toBe('NewTitle');
});
});