Mise en place des workspaces

This commit is contained in:
ocroguennec committed 2026-09-08 12:33:04 +02:00
1 parent be3c93897b
commit 6b6cad7cd6
19 files changed
+1273 -56

No files matched your search

+75
View File
@@ -3028,4 +3028,79 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_thread ON ai_messages(user_i
}
}
// ── Migration : Workspaces (extension multi-workspace, 08/09/26) ─────────
// Transforme l'app en socle commun + "workspaces" activables/désactivables
// par utilisateur, gouvernés par l'admin — cf. project_workspaces_transformation.md
// (mémoire projet) pour le plan complet. Phase 0 : catalogue global
// (`workspaces`) + octroi (admin) / activation (utilisateur) par compte
// (`user_workspaces`). Le workspace "crowdlending" représente l'app
// actuelle et est accordé + activé automatiquement à tous les comptes
// existants (backfill ci-dessous) et à tout nouveau compte (cf.
// grantDefaultWorkspace, appelée depuis auth.js /register, admin.js POST
// /users et invitations.js /:token/register — mêmes 3 points que la
// création auto de l'investisseur principal). Volontairement PAS ajouté à
// ENV_LOCAL_TABLES : ce sont des données métier normales (comme
// investissements), pas des réglages propres à un environnement — une
// restauration doit les écraser comme le reste.
db.exec(`
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
nom TEXT NOT NULL,
libelle_menu TEXT NOT NULL,
description TEXT,
actif_global INTEGER NOT NULL DEFAULT 1,
ordre INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_workspaces (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
granted_by_admin INTEGER NOT NULL DEFAULT 0,
active_by_user INTEGER NOT NULL DEFAULT 1,
granted_at TEXT,
PRIMARY KEY (user_id, workspace_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(user_id)');
// 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)
`).run();
/** Accorde + active le workspace par défaut (crowdlending) à un utilisateur.
* Appelée à la création d'un compte (auth.js /register, admin.js POST
* /users, invitations.js /:token/register) — même logique que la création
* 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();
if (!ws) return;
db.prepare(`
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
VALUES (?, ?, 1, 1, datetime('now'))
`).run(userId, ws.id);
}
// Backfill : tout utilisateur existant sans accès au workspace crowdlending
// (comptes créés avant cette migration) le reçoit automatiquement — ne doit
// 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();
if (wsBackfill) {
db.prepare(`
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
SELECT id, ?, 1, 1, datetime('now') FROM users
`).run(wsBackfill.id);
}
}
export default db;
+46 -2
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import db from '../db/index.js';
import db, { grantDefaultWorkspace } from '../db/index.js';
import Database from 'better-sqlite3';
import { HttpError } from '../middleware/errorHandler.js';
import { checkStatutsRetard } from '../jobs/autoStatut.js';
@@ -79,10 +79,17 @@ router.get('/users', (req, res) => {
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE inv.user_id = users.id) AS nb_investissements,
(SELECT COUNT(*) FROM documents d WHERE d.user_id = users.id) AS nb_documents,
(SELECT COALESCE(SUM(d.taille_octets), 0) FROM documents d WHERE d.user_id = users.id) AS taille_documents
(SELECT COALESCE(SUM(d.taille_octets), 0) FROM documents d WHERE d.user_id = users.id) AS taille_documents,
(SELECT GROUP_CONCAT(workspace_id) FROM user_workspaces uw WHERE uw.user_id = users.id AND uw.granted_by_admin = 1) AS workspace_ids
FROM users
ORDER BY id ASC
`).all();
// workspace_ids : GROUP_CONCAT renvoie une chaîne "1,2" ou null — converti ici
// en tableau de nombres pour rester cohérent avec le format attendu côté
// frontend (ManageRightsModal), plutôt que de parser la chaîne à chaque endroit.
for (const u of users) {
u.workspace_ids = u.workspace_ids ? u.workspace_ids.split(',').map(Number) : [];
}
res.json(users);
});
@@ -131,6 +138,10 @@ router.post('/users', (req, res, next) => {
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
// Accorde l'accès au workspace socle (crowdlending), comme tout nouveau compte
// — cf. project_workspaces_transformation.md.
grantDefaultWorkspace(userId);
audit(req, { action: 'user_created', category: 'account', actorId: req.user.id, targetUserId: userId, details: { email: body.email, role: body.role, created_by_admin: true } });
res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role });
} catch (e) { next(e); }
@@ -173,6 +184,39 @@ router.patch('/users/:id/ai-access', (req, res, next) => {
} catch (e) { next(e); }
});
/** Définit les workspaces accordés à un utilisateur (octroi admin) — remplace
* intégralement l'ensemble accordé (granted_by_admin) par la liste fournie.
* L'activation personnelle (active_by_user) n'est jamais touchée pour un
* workspace déjà accordé, pour ne pas écraser un choix déjà fait par
* l'utilisateur ; elle est mise à 1 par défaut pour un nouvel octroi. */
const PatchWorkspacesSchema = z.object({
workspaceIds: z.array(z.number().int()),
});
router.patch('/users/:id/workspaces', (req, res, next) => {
try {
const { workspaceIds } = PatchWorkspacesSchema.parse(req.body);
const targetId = Number(req.params.id);
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId);
if (!target) throw new HttpError(404, 'Utilisateur introuvable');
const applyGrants = db.transaction(() => {
db.prepare('UPDATE user_workspaces SET granted_by_admin = 0 WHERE user_id = ?').run(targetId);
for (const wsId of workspaceIds) {
db.prepare(`
INSERT INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
VALUES (?, ?, 1, 1, datetime('now'))
ON CONFLICT(user_id, workspace_id) DO UPDATE SET granted_by_admin = 1, granted_at = datetime('now')
`).run(targetId, wsId);
}
});
applyGrants();
audit(req, { action: 'workspaces_changed', category: 'workspaces', actorId: req.user.id, targetUserId: targetId, details: { workspace_ids: workspaceIds } });
res.json({ id: targetId, workspaceIds });
} catch (e) { next(e); }
});
/** Modifie le rôle d'un utilisateur */
const PatchRoleSchema = z.object({
role: z.enum(['user', 'admin']),
+93
View File
@@ -0,0 +1,93 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { audit } from '../utils/audit.js';
/* ── Catalogue des workspaces (Administration > Espaces de travail) ──────
* Gère la liste globale des espaces de travail proposés par l'application
* (ex. "Crowdlending", futur "Private Equity"…) — indépendant de l'octroi
* par utilisateur, qui se fait via PATCH /admin/users/:id/workspaces (voir
* admin.js). Voir project_workspaces_transformation.md (mémoire projet)
* pour le plan complet. requireAuth + requireAdmin appliqués dans server.js
* avant ce router. */
const router = Router();
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT w.*,
(SELECT COUNT(*) FROM user_workspaces uw WHERE uw.workspace_id = w.id AND uw.granted_by_admin = 1) AS nb_utilisateurs
FROM workspaces w
ORDER BY w.ordre ASC, w.id ASC
`).all();
res.json(rows);
});
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
const WorkspaceCreateSchema = z.object({
slug: z.string().min(1).max(40).regex(SLUG_RE, 'Slug : minuscules, chiffres et tirets uniquement'),
nom: z.string().min(1),
libelleMenu: z.string().min(1),
description: z.string().optional(),
actifGlobal: z.boolean().default(true),
ordre: z.number().int().default(0),
});
router.post('/', (req, res, next) => {
try {
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é');
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);
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 });
} catch (e) { next(e); }
});
const WorkspaceUpdateSchema = z.object({
nom: z.string().min(1),
libelleMenu: z.string().min(1),
description: z.string().optional(),
actifGlobal: z.boolean(),
ordre: z.number().int(),
});
router.put('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = db.prepare('SELECT id FROM workspaces WHERE id = ?').get(id);
if (!existing) throw new HttpError(404, 'Espace de travail introuvable');
const body = WorkspaceUpdateSchema.parse(req.body);
db.prepare(`
UPDATE workspaces SET nom=?, libelle_menu=?, description=?, actif_global=?, ordre=?, updated_at=datetime('now')
WHERE id=?
`).run(body.nom, body.libelleMenu, body.description || null, body.actifGlobal ? 1 : 0, body.ordre, id);
audit(req, { action: 'workspace_updated', category: 'workspaces', actorId: req.user.id, details: { id } });
res.json({ ok: true });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
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é');
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
audit(req, { action: 'workspace_deleted', category: 'workspaces', actorId: req.user.id, details: { slug: ws.slug } });
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+5 -1
View File
@@ -2,7 +2,7 @@ import { Router } from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import { z } from 'zod';
import db from '../db/index.js';
import db, { grantDefaultWorkspace } from '../db/index.js';
import { signToken, requireAuth } from '../middleware/auth.js';
import { HttpError } from '../middleware/errorHandler.js';
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
@@ -60,6 +60,10 @@ router.post('/register', async (req, res, next) => {
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
// Accorde l'accès au workspace socle (crowdlending) à tout nouveau compte
// — cf. project_workspaces_transformation.md.
grantDefaultWorkspace(userId);
// Si l'utilisateur doit vérifier son email → envoyer l'email de bienvenue
if (!autoVerified) {
const vToken = crypto.randomBytes(32).toString('hex');
+5 -1
View File
@@ -15,7 +15,7 @@ import { Router } from 'express';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import db from '../db/index.js';
import db, { grantDefaultWorkspace } from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
import { audit } from '../utils/audit.js';
@@ -191,6 +191,10 @@ router.post('/:token/register', async (req, res, next) => {
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(pendingUser.id, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
// Accorde l'accès au workspace socle (crowdlending) à tout nouveau compte
// — cf. project_workspaces_transformation.md.
grantDefaultWorkspace(pendingUser.id);
// Marquer l'invitation comme utilisée
db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id);
audit(req, { action: 'invitation_accepted', category: 'invitation', targetUserId: pendingUser.id, details: { email: inv.email, role: inv.role } });
+1
View File
@@ -11,6 +11,7 @@ const ALLOWED_KEYS = new Set([
'pfo_assujetti',
'objectifs_actifs',
'color_theme',
'active_workspace_id',
// Extensible : ajouter ici les futures prefs (font_scale, langue, devise, display_mode…)
]);
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/* ── Espaces de travail de l'utilisateur connecté ─────────────────────────
* Liste les workspaces accordés par l'admin (granted_by_admin=1) avec leur
* état d'activation personnelle (active_by_user) — un workspace non accordé
* n'apparaît jamais ici, même désactivé. Voir aussi adminWorkspaces.js
* (catalogue global) et admin.js (octroi par utilisateur, PATCH
* /admin/users/:id/workspaces). requireAuth appliqué dans server.js. */
const router = Router();
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user
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
ORDER BY w.ordre ASC, w.id ASC
`).all(req.user.id);
res.json(rows);
});
const ActiveSchema = z.object({ active: z.boolean() });
router.patch('/:id/active', (req, res, next) => {
try {
const { active } = ActiveSchema.parse(req.body);
const workspaceId = Number(req.params.id);
const grant = db.prepare(
'SELECT 1 FROM user_workspaces WHERE user_id = ? AND workspace_id = ? AND granted_by_admin = 1'
).get(req.user.id, workspaceId);
if (!grant) throw new HttpError(403, "Vous n'avez pas accès à cet espace de travail");
db.prepare(
'UPDATE user_workspaces SET active_by_user = ? WHERE user_id = ? AND workspace_id = ?'
).run(active ? 1 : 0, req.user.id, workspaceId);
res.json({ id: workspaceId, active });
} catch (e) { next(e); }
});
export default router;
+4
View File
@@ -58,6 +58,8 @@ import apiKeysRouter from './routes/apiKeys.js';
import v1Router from './routes/v1/index.js';
import documentsRouter from './routes/documents.js';
import aiRouter from './routes/ai.js';
import workspacesRouter from './routes/workspaces.js';
import adminWorkspacesRouter from './routes/adminWorkspaces.js';
import { requireApiKey } from './middleware/apiKey.js';
import { swaggerSpec, swaggerUi } from './swagger.js';
import db from './db/index.js';
@@ -170,6 +172,8 @@ app.use('/api/tickets', requireAuth, ticketsRouter);
app.use('/api/api-keys', requireAuth, apiKeysRouter);
app.use('/api/documents', requireAuth, documentsRouter);
app.use('/api/ai', requireAuth, aiRouter);
app.use('/api/workspaces', requireAuth, workspacesRouter);
app.use('/api/admin/workspaces', requireAuth, requireAdmin, adminWorkspacesRouter);
app.use(errorHandler);