Mise en place des workspaces
This commit is contained in:
1 parent
be3c93897b
commit
6b6cad7cd6
19 files changed
+1273
-56
No files matched your search
@@ -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;
|
||||
@@ -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']),
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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…)
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user