Implementation de la notion d'espace d'investissement

This commit is contained in:
ocroguennec committed 2026-09-17 18:25:42 +02:00
1 parent 6b6cad7cd6
commit 4b7df66862
54 files changed
+6782 -288

No files matched your search

+110 -3
View File
@@ -4,6 +4,7 @@ import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { createZip, readZip } from '../utils/zip.js';
import { deleteDocumentsForEntity } from './documents.js';
import { getUserActiveWorkspaceIds } from '../middleware/workspaceScope.js';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
@@ -65,6 +66,8 @@ const Schema = z.object({
type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(),
freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(),
referentiel_id: z.number().int().positive().nullable().optional(),
// Espaces de travail auxquels cette plateforme utilisateur est affectée (au moins un requis)
workspace_ids: z.array(z.number().int()).min(1, 'Au moins un espace de travail doit être sélectionné'),
});
// Champs hérités du référentiel (comparés pour calculer overridden_fields)
@@ -223,6 +226,58 @@ function syncCategories(platId, catIds) {
})(platId, catIds);
}
/** Espaces de travail auxquels chaque plateforme UTILISATEUR est affectée */
function attachWorkspaces(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT pw.plateforme_id, w.id AS workspace_id, w.slug, w.nom
FROM plateforme_workspaces pw
JOIN workspaces w ON w.id = pw.workspace_id
WHERE pw.plateforme_id IN (${ids.map(() => '?').join(',')})
ORDER BY w.ordre, w.id
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.plateforme_id]) map[l.plateforme_id] = [];
map[l.plateforme_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
}
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
}
/** Espaces de travail auxquels chaque entrée du RÉFÉRENTIEL est affectée (pour filtrer l'éligibilité) */
function attachReferentielWorkspaces(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rw.referentiel_id, w.id AS workspace_id, w.slug, w.nom
FROM referentiel_workspaces rw
JOIN workspaces w ON w.id = rw.workspace_id
WHERE rw.referentiel_id IN (${ids.map(() => '?').join(',')})
ORDER BY w.ordre, w.id
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.referentiel_id]) map[l.referentiel_id] = [];
map[l.referentiel_id].push({ id: l.workspace_id, slug: l.slug, nom: l.nom });
}
return rows.map(r => ({ ...r, workspaces: map[r.id] || [] }));
}
/** Workspaces que l'utilisateur a le droit d'utiliser : accordés par l'admin ET activés par lui-même
* (Settings > Mes espaces de travail) — seuls ceux-là sont sélectionnables pour une NOUVELLE
* affectation de plateforme. Une affectation déjà existante reste conservée même hors de cet
* ensemble (cf. validation dans PUT /:id). */
// getUserActiveWorkspaceIds : déplacé le 17/09/26 dans middleware/workspaceScope.js
// (réutilisé désormais par depots_retraits/imports/remboursements, cf. import ci-dessus).
function syncWorkspaces(platId, workspaceIds) {
db.prepare('DELETE FROM plateforme_workspaces WHERE plateforme_id = ?').run(platId);
if (!workspaceIds || workspaceIds.length === 0) return;
const ins = db.prepare('INSERT OR IGNORE INTO plateforme_workspaces (plateforme_id, workspace_id) VALUES (?,?)');
for (const id of workspaceIds) ins.run(platId, id);
}
// ── Lecture référentiel (tous users authentifiés) ─────────────────────────
router.get('/referentiel-list', (_req, res) => {
const rows = db.prepare(`
@@ -232,7 +287,9 @@ router.get('/referentiel-list', (_req, res) => {
FROM plateformes_referentiel pr
ORDER BY pr.nom
`).all();
res.json(rows);
// workspaces attachés pour permettre au frontend de filtrer les entrées éligibles
// à l'espace de travail choisi par l'utilisateur lors de l'ajout (PlatPickerModal)
res.json(attachReferentielWorkspaces(rows));
});
router.get('/', (req, res) => {
@@ -261,16 +318,26 @@ router.get('/', (req, res) => {
}));
const withCats = attachCategories(req.user.id, enriched);
const withCatsInv = attachCategoriesInv(withCats);
const withAll = attachSecteursInv(withCatsInv).map(r => {
const withStripped = attachSecteursInv(withCatsInv).map(r => {
const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r;
return rest;
});
const withAll = attachWorkspaces(withStripped);
res.json(withAll);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
// On ne peut affecter, à la CRÉATION, qu'à des espaces de travail auxquels on a
// accès et qu'on a activés (Settings > Mes espaces de travail).
const allowedWsCreate = new Set(getUserActiveWorkspaceIds(req.user.id));
const invalidWsCreate = body.workspace_ids.filter(id => !allowedWsCreate.has(id));
if (invalidWsCreate.length > 0) {
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
}
let fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite;
let taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null;
let typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT';
@@ -305,6 +372,7 @@ router.post('/', (req, res, next) => {
);
const id = r.lastInsertRowid;
syncCategories(id, body.categories);
syncWorkspaces(id, body.workspace_ids);
res.status(201).json({
id, ...body,
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,
@@ -335,6 +403,7 @@ router.get('/export', (req, res, next) => {
`).all(req.user.id);
rows = attachCategoriesInv(rows);
rows = attachSecteursInv(rows);
rows = attachWorkspaces(rows);
// Attach legacy categories
const platIds = rows.map(r => r.id);
@@ -377,6 +446,7 @@ router.get('/export', (req, res, next) => {
categories: legacyMap[r.id] || [],
categories_inv: (r.categories_inv || []).map(c => c.nom),
secteurs_inv: (r.secteurs_inv || []).map(s => s.nom),
workspace_slugs: (r.workspaces || []).map(w => w.slug),
}));
entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) });
@@ -417,7 +487,8 @@ router.get('/:id/export', (req, res, next) => {
if (!p) throw new HttpError(404, 'Plateforme introuvable');
const [withCatsInv] = attachCategoriesInv([p]);
const [withAll] = attachSecteursInv([withCatsInv]);
const [withSects] = attachSecteursInv([withCatsInv]);
const [withAll] = attachWorkspaces([withSects]);
const catsLegacy = db.prepare(`
SELECT c.nom FROM plateforme_categories pc
@@ -443,6 +514,7 @@ router.get('/:id/export', (req, res, next) => {
categories: catsLegacy,
categories_inv: (withAll.categories_inv || []).map(c => c.nom),
secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom),
workspace_slugs: (withAll.workspaces || []).map(w => w.slug),
};
entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) });
@@ -541,6 +613,25 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
return ids;
}
// Résoudre des slugs de workspaces vers leurs IDs — restreint à ceux auxquels
// l'utilisateur important a effectivement accès (accordé + activé). Un slug
// inconnu ou hors d'accès est ignoré ; si la liste résultante est vide (vieux ZIP
// sans workspace_slugs, ou accès différent), fallback sur "crowdlending" si accessible,
// pour ne jamais importer une plateforme sans aucune affectation.
const userAllowedWs = new Set(getUserActiveWorkspaceIds(req.user.id));
function resolveWorkspaceIdsForUser(slugs) {
const ids = [];
for (const slug of (slugs || [])) {
const row = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(slug);
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();
if (cl && userAllowedWs.has(cl.id)) ids.push(cl.id);
}
return ids;
}
const imageMap = {};
for (const e of zipEntries) {
if (e.name.startsWith('logos/') && e.name.length > 6) imageMap[path.basename(e.name)] = e.data;
@@ -557,6 +648,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv');
const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv');
const legacyCatIds = resolveLegacyCatIds(p.categories);
const wsIds = resolveWorkspaceIdsForUser(p.workspace_slugs);
const existing = db.prepare('SELECT id FROM plateformes WHERE nom = ? AND user_id = ?').get(p.nom, req.user.id);
let platId;
@@ -601,6 +693,8 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => {
// Sync secteurs_inv
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(platId);
for (const id of sectsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?,?)').run(platId, id);
// Sync workspaces
syncWorkspaces(platId, wsIds);
// Images
for (const fname of [p.logo_filename, p.icone_filename]) {
@@ -627,6 +721,18 @@ router.put('/:id', (req, res, next) => {
.get(req.params.id, req.user.id);
if (!current) throw new HttpError(404, 'Not found');
// Validation des workspaces : on ne peut AJOUTER que des workspaces auxquels on a
// accès et qu'on a activés (Settings > Mes espaces de travail) ; une affectation déjà
// présente reste autorisée même si l'accès a depuis été retiré (jamais retirée
// silencieusement par cette route — seul l'utilisateur peut la décocher lui-même).
const currentWsIds = db.prepare('SELECT workspace_id FROM plateforme_workspaces WHERE plateforme_id = ?')
.all(req.params.id).map(r => r.workspace_id);
const allowedWsEdit = new Set([...getUserActiveWorkspaceIds(req.user.id), ...currentWsIds]);
const invalidWsEdit = body.workspace_ids.filter(id => !allowedWsEdit.has(id));
if (invalidWsEdit.length > 0) {
throw new HttpError(400, "Vous ne pouvez affecter cette plateforme qu'aux espaces de travail auxquels vous avez accès et que vous avez activés.");
}
// Calculer overridden_fields si la plateforme est liée à un référentiel
let overriddenFields = JSON.parse(current.overridden_fields || '[]');
if (current.referentiel_id) {
@@ -664,6 +770,7 @@ router.put('/:id', (req, res, next) => {
);
if (r.changes === 0) throw new HttpError(404, 'Not found');
syncCategories(Number(req.params.id), body.categories);
syncWorkspaces(Number(req.params.id), body.workspace_ids);
res.json({
id: Number(req.params.id), ...body,
fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal,