Amélioration de la notion de workspace

This commit is contained in:
ocroguennec committed 2026-09-19 17:19:53 +02:00
1 parent 1d5737703b
commit c918de1e7d
11 files changed
+202 -58

No files matched your search

+60 -8
View File
@@ -3050,6 +3050,7 @@ db.exec(`
nom TEXT NOT NULL,
libelle_menu TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'private_equity',
actif_global INTEGER NOT NULL DEFAULT 1,
ordre INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -3057,6 +3058,57 @@ db.exec(`
)
`);
// ── Migration : workspaces.type (19/09/26) ───────────────────────────────
// Jusqu'ici, TOUT le routage crowdlending/PE (frontend ET backend — plus de
// 20 endroits, cf. project_workspaces_transformation.md) reposait sur une
// comparaison littérale `slug === 'crowdlending'` : le slug — pourtant un
// simple identifiant technique choisi librement par l'admin (regex
// minuscules/chiffres/tirets, cf. adminWorkspaces.js) — portait donc
// implicitement toute la sémantique "quel modèle de page afficher ?".
// Question d'Olivier (19/09/26) : comment le programme reconnaît-il qu'il
// doit utiliser les pages Crowdlending ou Private Equity, sachant que
// l'admin choisit juste un nom ? Réponse à l'époque : aucune vraie
// typologie, seulement "crowdlending" vs "tout le reste" — décision
// d'Olivier du même jour : corriger ça maintenant, dans tout le code.
// `type` porte désormais cette sémantique explicitement ('crowdlending' ou
// 'private_equity' à ce jour — toute valeur non 'crowdlending' continue,
// pour l'instant, à être traitée comme "PE" côté UI, en attendant qu'un
// 3e modèle de page existe réellement). Choisi à la création d'un
// workspace (formulaire admin, cf. adminWorkspaces.js), immutable ensuite
// — même régime que `slug`, pour la même raison : en changer sous un
// workspace déjà peuplé de données changerait silencieusement quel modèle
// de page/API s'applique à ces données. DEFAULT 'private_equity' ci-dessus
// : filet de sécurité reproduisant le comportement historique implicite
// ("pas crowdlending" = PE) si jamais une ligne était insérée sans passer
// par l'API ; l'API elle-même exige toujours une valeur explicite.
{
const wsCols = db.prepare('PRAGMA table_info(workspaces)').all().map(c => c.name);
if (!wsCols.includes('type')) {
db.exec("ALTER TABLE workspaces ADD COLUMN type TEXT NOT NULL DEFAULT 'private_equity'");
}
// Backfill volontairement INCONDITIONNEL (pas seulement juste après l'ALTER ci-dessus) :
// le seul workspace qui ait jamais eu un sens "crowdlending" est celui de slug
// 'crowdlending' (seed ci-dessous, protégé, non supprimable) — tous les autres (aujourd'hui :
// 'private-equity') étaient déjà traités comme "PE" par l'ancien code, donc reçoivent le
// DEFAULT ci-dessus sans rien faire. Auto-réparateur à chaque démarrage (20/09/26) : une
// version antérieure de cette migration ne faisait ce backfill qu'au moment de l'ALTER, donc
// un redémarrage ayant appliqué l'ALTER avant la correction d'un bug dans cette ligne laissait
// le workspace crowdlending bloqué sur le DEFAULT 'private_equity' — sans nouveau redémarrage
// "à vide" pour le rattraper, ce cas ne pouvait jamais se corriger. Cette ligne s'exécute donc
// désormais à chaque démarrage, sans condition, et ne fait rien dès que le type est déjà bon.
db.exec("UPDATE workspaces SET type = 'crowdlending' WHERE slug = 'crowdlending' AND type <> 'crowdlending'");
}
// Au plus UN workspace de type 'crowdlending' (l'app entière suppose ce
// singleton — resolveActiveWorkspaceId, grantDefaultWorkspace, etc. font
// un .get() qui prend la première ligne trouvée). Index partiel (SQLite
// supporte les index UNIQUE avec clause WHERE) : ne contraint que le type
// 'crowdlending', laisse 'private_equity' libre en plusieurs exemplaires
// si Olivier veut un jour plusieurs workspaces PE distincts.
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_type_crowdlending_singleton
ON workspaces(type) WHERE type = 'crowdlending'
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_workspaces (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -3087,8 +3139,8 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(
// Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE).
db.prepare(`
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
VALUES ('crowdlending', 'Crowdlending', 'Crowdlending', 'Suivi de portefeuille crowdlending', 1, 0)
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre)
VALUES ('crowdlending', 'Crowdlending', 'Crowdlending', 'Suivi de portefeuille crowdlending', 'crowdlending', 1, 0)
`).run();
/** Accorde + active le workspace par défaut (crowdlending) à un utilisateur.
@@ -3097,7 +3149,7 @@ db.prepare(`
* auto du profil investisseur principal à ces 3 mêmes endroits, pour
* qu'aucun nouveau compte ne se retrouve sans accès à l'app actuelle. */
export function grantDefaultWorkspace(userId) {
const ws = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const ws = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (!ws) return;
db.prepare(`
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
@@ -3110,7 +3162,7 @@ export function grantDefaultWorkspace(userId) {
// rien casser du fonctionnement actuel. Idempotent (INSERT OR IGNORE sur la
// PK composite user_id/workspace_id).
{
const wsBackfill = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const wsBackfill = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsBackfill) {
db.prepare(`
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
@@ -3141,7 +3193,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_ref_workspaces_ws ON referentiel_workspa
// (comptes créés avant cette migration, ou entrées jamais touchées depuis)
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO referentiel_workspaces (referentiel_id, workspace_id)
@@ -3174,7 +3226,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_plat_workspaces_ws ON plateforme_workspa
// Backfill idempotent : toute plateforme utilisateur sans aucune affectation
// est rattachée au workspace "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const wsCrowdlending = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending) {
db.prepare(`
INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id)
@@ -3204,7 +3256,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_depret_workspace ON depots_retraits(work
// Backfill idempotent : tout dépôt/retrait sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const wsCrowdlending2 = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending2) {
db.prepare('UPDATE depots_retraits SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending2.id);
}
@@ -3226,7 +3278,7 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_remb_workspace ON remboursements(workspa
// Backfill idempotent : tout remboursement sans workspace (créé avant cette
// migration) est rattaché à "crowdlending" — seul workspace réel à ce jour.
{
const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const wsCrowdlending3 = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (wsCrowdlending3) {
db.prepare('UPDATE remboursements SET workspace_id = ? WHERE workspace_id IS NULL').run(wsCrowdlending3.id);
}
+4 -3
View File
@@ -22,8 +22,9 @@ export function getUserActiveWorkspaceIds(userId) {
* Lu depuis le header X-Workspace-Id, envoyé automatiquement par le frontend
* (frontend/src/api.js, miroir de X-Investisseur-Id) et validé contre les workspaces actifs
* de l'utilisateur. Si le header est absent, invalide, ou non autorisé (ex. appel API v1/MCP
* qui ne l'envoie pas encore), retombe silencieusement sur le workspace "crowdlending" —
* jamais d'échec bloquant, pour rester transparent tant qu'un seul workspace existe réellement.
* qui ne l'envoie pas encore), retombe silencieusement sur le workspace de type "crowdlending"
* (le singleton — cf. workspaces.type, 19/09/26) — jamais d'échec bloquant, pour rester
* transparent quel que soit le nombre de workspaces existants.
*/
export function resolveActiveWorkspaceId(req) {
const raw = req.header('X-Workspace-Id');
@@ -32,6 +33,6 @@ export function resolveActiveWorkspaceId(req) {
const allowed = new Set(getUserActiveWorkspaceIds(req.user.id));
if (allowed.has(id)) return id;
}
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const cl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
return cl ? cl.id : null;
}
+25 -6
View File
@@ -14,6 +14,14 @@ import { audit } from '../utils/audit.js';
const router = Router();
// Types de workspace reconnus par l'app (19/09/26 — cf. db/index.js pour le contexte complet
// de cette migration). 'crowdlending' est le singleton historique (un seul exemplaire, imposé
// par l'index unique partiel côté DB et re-vérifié ici pour un message d'erreur clair) ;
// 'private_equity' peut avoir plusieurs exemplaires (ex. si Olivier veut un jour séparer deux
// portefeuilles PE distincts). Un 3e type nécessiterait d'abord une page/route dédiée avant
// d'avoir un sens ici — l'ajouter à cette liste seul ne suffit pas.
const WORKSPACE_TYPES = ['crowdlending', 'private_equity'];
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT w.*,
@@ -31,6 +39,11 @@ const WorkspaceCreateSchema = z.object({
nom: z.string().min(1),
libelleMenu: z.string().min(1),
description: z.string().optional(),
// Choisi une fois à la création, jamais modifiable ensuite (même régime que slug, cf.
// WorkspaceUpdateSchema ci-dessous qui n'inclut ni l'un ni l'autre) — changer le type d'un
// workspace déjà peuplé de données changerait silencieusement le modèle de page/API qui
// s'applique à ces données.
type: z.enum(WORKSPACE_TYPES, { errorMap: () => ({ message: `Type invalide (valeurs acceptées : ${WORKSPACE_TYPES.join(', ')})` }) }),
actifGlobal: z.boolean().default(true),
ordre: z.number().int().default(0),
});
@@ -40,14 +53,20 @@ router.post('/', (req, res, next) => {
const body = WorkspaceCreateSchema.parse(req.body);
const exists = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(body.slug);
if (exists) throw new HttpError(409, 'Ce slug est déjà utilisé');
// Cf. l'index unique partiel côté DB (idx_workspaces_type_crowdlending_singleton) : cette
// vérification applicative donne un message clair avant même d'atteindre la contrainte SQL.
if (body.type === 'crowdlending') {
const alreadyCl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (alreadyCl) throw new HttpError(409, 'Un workspace de type Crowdlending existe déjà');
}
const r = db.prepare(`
INSERT INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
VALUES (?,?,?,?,?,?)
`).run(body.slug, body.nom, body.libelleMenu, body.description || null, body.actifGlobal ? 1 : 0, body.ordre);
INSERT INTO workspaces (slug, nom, libelle_menu, description, type, actif_global, ordre)
VALUES (?,?,?,?,?,?,?)
`).run(body.slug, body.nom, body.libelleMenu, body.description || null, body.type, body.actifGlobal ? 1 : 0, body.ordre);
audit(req, { action: 'workspace_created', category: 'workspaces', actorId: req.user.id, details: { slug: body.slug } });
res.status(201).json({ id: r.lastInsertRowid, slug: body.slug, nom: body.nom, libelle_menu: body.libelleMenu });
audit(req, { action: 'workspace_created', category: 'workspaces', actorId: req.user.id, details: { slug: body.slug, type: body.type } });
res.status(201).json({ id: r.lastInsertRowid, slug: body.slug, nom: body.nom, libelle_menu: body.libelleMenu, type: body.type });
} catch (e) { next(e); }
});
@@ -82,7 +101,7 @@ router.delete('/:id', (req, res, next) => {
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
if (!ws) throw new HttpError(404, 'Espace de travail introuvable');
// Le workspace socle ne peut pas être supprimé — toute l'app actuelle en dépend.
if (ws.slug === 'crowdlending') throw new HttpError(400, 'Le workspace Crowdlending ne peut pas être supprimé');
if (ws.type === 'crowdlending') throw new HttpError(400, 'Le workspace Crowdlending ne peut pas être supprimé');
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
audit(req, { action: 'workspace_deleted', category: 'workspaces', actorId: req.user.id, details: { slug: ws.slug } });
+2 -2
View File
@@ -137,8 +137,8 @@ router.get('/', (req, res, next) => {
`).all(Number(investissement_pe_id));
} else {
const workspaceId = resolveActiveWorkspaceId(req);
const ws = db.prepare('SELECT slug FROM workspaces WHERE id = ?').get(workspaceId);
if (!ws || ws.slug === 'crowdlending') {
const ws = db.prepare('SELECT type FROM workspaces WHERE id = ?').get(workspaceId);
if (!ws || ws.type === 'crowdlending') {
rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
FROM frais_operations f
+1 -1
View File
@@ -626,7 +626,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
if (row && userAllowedWs.has(row.id)) ids.push(row.id);
}
if (ids.length === 0) {
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const cl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (cl && userAllowedWs.has(cl.id)) ids.push(cl.id);
}
return ids;
+1 -1
View File
@@ -551,7 +551,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
if (row) ids.push(row.id);
}
if (ids.length === 0) {
const cl = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
const cl = db.prepare("SELECT id FROM workspaces WHERE type = 'crowdlending'").get();
if (cl) ids.push(cl.id);
}
return ids;
+5 -1
View File
@@ -14,8 +14,12 @@ import { COLOR_THEME_VALUES } from './preferences.js';
const router = Router();
router.get('/', (req, res) => {
// w.type (19/09/26) : c'est ce champ, PAS w.slug, que le frontend doit utiliser pour choisir
// entre les pages "Crowdlending" et "Private Equity" (App.jsx, Layout.jsx) — cf. db/index.js
// pour le contexte. slug reste exposé (identifiant technique stable, ex. exports/imports ZIP)
// mais ne porte plus la sémantique de routage.
const rows = db.prepare(`
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user, uw.color_theme
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, w.type, uw.active_by_user, uw.color_theme
FROM user_workspaces uw
JOIN workspaces w ON w.id = uw.workspace_id
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND w.actif_global = 1