// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many). // Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici : // ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId. // Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone. import { EventEmitter } from 'node:events'; import { randomUUID } from 'node:crypto'; import type { GroupSummary } from '@arboretum/shared'; import type { Db } from '../db/index.js'; const LABEL_MAX = 100; const DESCRIPTION_MAX = 2000; const COLOR_RE = /^#[0-9a-fA-F]{6}$/; interface GroupRow { id: string; label: string; description: string | null; color: string | null; position: number; created_at: string; updated_at: string; } export interface GroupManagerEvents { group_update: [GroupSummary]; group_removed: [string]; } /** Erreur portant un statusCode + code pour mapping HTTP direct par les routes (cf. sendManagerError). */ function httpError(statusCode: number, code: string, message: string): Error { return Object.assign(new Error(message), { statusCode, code }); } /** Valide un label : non vide après trim, borné. */ function normLabel(label: unknown): string { if (typeof label !== 'string') throw httpError(400, 'BAD_REQUEST', 'label is required'); const v = label.trim(); if (v === '') throw httpError(400, 'BAD_REQUEST', 'label must not be empty'); if (v.length > LABEL_MAX) throw httpError(400, 'BAD_REQUEST', `label must be at most ${LABEL_MAX} characters`); return v; } /** Normalise une description : vide/absente → null, bornée sinon. */ function normDescription(description: string | null | undefined): string | null { if (description === null || description === undefined) return null; const v = description.trim(); if (v === '') return null; if (v.length > DESCRIPTION_MAX) throw httpError(400, 'BAD_REQUEST', `description must be at most ${DESCRIPTION_MAX} characters`); return v; } /** Normalise une couleur : vide/absente → null, doit être un hex `#rrggbb` sinon. */ function normColor(color: string | null | undefined): string | null { if (color === null || color === undefined) return null; const v = color.trim(); if (v === '') return null; if (!COLOR_RE.test(v)) throw httpError(400, 'BAD_REQUEST', 'color must be a hex string like #4f46e5'); return v; } export class GroupManager extends EventEmitter { constructor(private readonly db: Db) { super(); } private getGroupRow(id: string): GroupRow | null { return (this.db.prepare('SELECT * FROM groups WHERE id = ?').get(id) as unknown as GroupRow | undefined) ?? null; } private repoIdsFor(groupId: string): string[] { const rows = this.db .prepare('SELECT repo_id FROM group_repos WHERE group_id = ? ORDER BY position ASC, created_at ASC') .all(groupId) as Array<{ repo_id: string }>; return rows.map((r) => r.repo_id); } private rowToSummary(row: GroupRow): GroupSummary { return { id: row.id, label: row.label, description: row.description, color: row.color, repoIds: this.repoIdsFor(row.id), createdAt: row.created_at, updatedAt: row.updated_at, }; } /** Garde-fou : le repo doit exister (meilleur message que de laisser la FK lever une contrainte opaque). */ private assertRepoExists(repoId: string): void { if (typeof repoId !== 'string' || repoId === '') throw httpError(400, 'BAD_REQUEST', 'repoId is required'); const exists = this.db.prepare('SELECT 1 FROM repos WHERE id = ?').get(repoId); if (!exists) throw httpError(404, 'REPO_NOT_FOUND', 'No repo with this id'); } /** Position d'insertion suivante dans un groupe (append en queue). */ private nextPosition(groupId: string): number { const row = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM group_repos WHERE group_id = ?').get(groupId) as { pos: number; }; return row.pos; } listGroups(): GroupSummary[] { const rows = this.db.prepare('SELECT * FROM groups ORDER BY position ASC, created_at ASC').all() as unknown as GroupRow[]; return rows.map((r) => this.rowToSummary(r)); } getGroup(id: string): GroupSummary { const row = this.getGroupRow(id); if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id'); return this.rowToSummary(row); } createGroup(opts: { label: string; description?: string; color?: string; repoIds?: string[] }): GroupSummary { const label = normLabel(opts.label); const description = normDescription(opts.description); const color = normColor(opts.color); const repoIds = opts.repoIds ?? []; if (!Array.isArray(repoIds)) throw httpError(400, 'BAD_REQUEST', 'repoIds must be an array'); for (const repoId of repoIds) this.assertRepoExists(repoId); const now = new Date().toISOString(); const id = randomUUID(); const position = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM groups').get() as { pos: number }; this.db.exec('BEGIN'); try { this.db .prepare('INSERT INTO groups (id, label, description, color, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)') .run(id, label, description, color, position.pos, now, now); const insertRepo = this.db.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)'); let pos = 0; const seen = new Set(); for (const repoId of repoIds) { if (seen.has(repoId)) continue; seen.add(repoId); insertRepo.run(id, repoId, pos++, now); } this.db.exec('COMMIT'); } catch (err) { this.db.exec('ROLLBACK'); throw err; } const summary = this.getGroup(id); this.emit('group_update', summary); return summary; } updateGroup(id: string, patch: { label?: string; description?: string | null; color?: string | null }): GroupSummary { const row = this.getGroupRow(id); if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id'); if (patch.label !== undefined) row.label = normLabel(patch.label); if (patch.description !== undefined) row.description = normDescription(patch.description); if (patch.color !== undefined) row.color = normColor(patch.color); const now = new Date().toISOString(); this.db .prepare('UPDATE groups SET label = ?, description = ?, color = ?, updated_at = ? WHERE id = ?') .run(row.label, row.description, row.color, now, id); const summary = this.getGroup(id); this.emit('group_update', summary); return summary; } deleteGroup(id: string): boolean { // CASCADE (PRAGMA foreign_keys = ON) purge group_repos. const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id); if (res.changes === 0) return false; // Les sessions de groupe (P6) ne sont pas en FK : on désorpheline leur group_id manuellement. this.db.prepare('UPDATE sessions SET group_id = NULL WHERE group_id = ?').run(id); this.emit('group_removed', id); return true; } addRepo(groupId: string, repoId: string): GroupSummary { if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id'); this.assertRepoExists(repoId); const now = new Date().toISOString(); // INSERT OR IGNORE → idempotent (PRIMARY KEY (group_id, repo_id)). this.db .prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)') .run(groupId, repoId, this.nextPosition(groupId), now); this.touch(groupId, now); const summary = this.getGroup(groupId); this.emit('group_update', summary); return summary; } removeRepo(groupId: string, repoId: string): GroupSummary { if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id'); // DELETE no-op si absent → idempotent. this.db.prepare('DELETE FROM group_repos WHERE group_id = ? AND repo_id = ?').run(groupId, repoId); this.touch(groupId, new Date().toISOString()); const summary = this.getGroup(groupId); this.emit('group_update', summary); return summary; } private touch(groupId: string, now: string): void { this.db.prepare('UPDATE groups SET updated_at = ? WHERE id = ?').run(now, groupId); } }