Implementation de la notion d'espace d'investissement
This commit is contained in:
1 parent
6b6cad7cd6
commit
4b7df66862
54 files changed
+6782
-288
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user