Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
90593d6
Feat: créer la structure de base pour l'historique des actions
ch-benard Mar 24, 2026
e05e491
Feat: inclure l'état actuel dans l'historique via UNION
ch-benard Mar 24, 2026
ee37fff
Feat: différencier les types de financement dans l'historique
ch-benard Mar 24, 2026
1eebf71
Feat: créer l'interface d'affichage de l'historique
ch-benard Mar 24, 2026
4b00b29
Feat: intégrer l'historique dans la fiche action
ch-benard Mar 24, 2026
9d86b0c
Refactor: ajouter provide/inject pour les sections de la fiche
ch-benard Mar 24, 2026
b23357e
Fix: Ajouter la propriété hasDihalFinancing: false manquante
ch-benard Mar 26, 2026
fe1908d
Fix: Nommer la route
ch-benard Mar 26, 2026
6af84f7
Fix: Nommer la fonction fléchée et typer les paramètres
ch-benard Mar 26, 2026
2772ea9
Fix: Garantir un tri alphabétique fiable des années
ch-benard Mar 26, 2026
8098909
Fix: Nommer la fonction fléchée
ch-benard Mar 26, 2026
5833b19
Fix: Inverser une condition ternaire négative
ch-benard Mar 26, 2026
8044c11
Fix: Nommer la fonction fléchée
ch-benard Mar 26, 2026
8b91eed
Fix: Nommer la fonction fléchée
ch-benard Mar 26, 2026
3a68729
Fix: Remplacer l'alternation (-| ) par une classe de caractères ([- ])
ch-benard Mar 26, 2026
265be78
Fix: Préfixer `parseInt` par `Number.`
ch-benard Mar 26, 2026
e3a57d1
Fix: Fournir une fonction de tri
ch-benard Mar 26, 2026
8d11d85
Refactor: Créer des types réutilisables pour les informations utilisa…
ch-benard Mar 26, 2026
31573ba
Refactor: Créer des types réutilisables pour les informations utilisa…
ch-benard Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type ApplicationWithCustomRoutes } from '#server/loaders/customRouteMethodsLoader';
import controller from './action.getHistory';

export default function getHistoryRoute(app: ApplicationWithCustomRoutes): void {
app.customRoutes.get('/actions/:id/history', controller, undefined, {
authenticate: true,
multipart: false,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Request, Response } from 'express';
import actionService from '#server/services/action/actionService';
import { User } from '#root/types/resources/User.d';

const ERRORS = {
undefined: { code: 500, message: 'Une erreur inconnue est survenue' },
fetch_failed: { code: 500, message: 'Une erreur est survenue lors de la récupération de l\'historique' },
};

interface ActionHistoryRequest extends Request {
user: User,
params: { id: string; },
}

export default async function getHistory(req: ActionHistoryRequest, res: Response, next: (arg0: any) => any) {
try {
const history = await actionService.getHistory(req.user, Number.parseInt(req.params.id, 10));
return res.status(200).send(history);
} catch (error) {
const { code, message } = ERRORS[error?.code] ?? ERRORS.undefined;
res.status(code).send({
user_message: message,
});

return next(error?.nativeError ?? error);
}
}
47 changes: 47 additions & 0 deletions packages/api/server/models/_common/types/UserInfoTypes.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Types réutilisables pour les informations d'utilisateur et d'organisation
* dans les requêtes SQL du modèle Action
*/

/**
* Informations de base d'un utilisateur (créateur ou éditeur)
*/
export type UserInfo = {
id: number,
first_name: string,
last_name: string,
organization_id: number,
organization_name: string,
organization_abbreviation: string | null,
};

/**
* Informations de créateur (toujours présentes)
*/
export type CreatorInfo = {
creator_id: number,
creator_first_name: string,
creator_last_name: string,
creator_organization_id: number,
creator_organization_name: string,
creator_organization_abbreviation: string | null,
created_at: Date,
};

/**
* Informations d'éditeur (optionnelles)
*/
export type EditorInfo = {
editor_id: number | null,
editor_first_name: string | null,
editor_last_name: string | null,
editor_organization_id: number | null,
editor_organization_name: string | null,
editor_organization_abbreviation: string | null,
updated_at: Date,
};

/**
* Combinaison créateur + éditeur (pour les entités complètes)
*/
export type AuditInfo = CreatorInfo & EditorInfo;
229 changes: 229 additions & 0 deletions packages/api/server/models/actionModel/_common/getDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import dateUtils from '#server/utils/date';
import Action from '#root/types/resources/Action.d';

const { fromTsToFormat } = dateUtils;

function formatAmount(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
useGrouping: true,
}).format(amount);
}

export type Diff = {
fieldKey: string,
field: string,
oldValue: string,
newValue: string
};

function getDeepProperty(obj: any, path: string): any {
return path.split('.').reduce((acc, curr) => acc?.[curr], obj);
}

export default function getDiff(oldVersion: Action, newVersion: Action): Diff[] {
const baseProcessors = {
default(value: any): string {
if (value === null || value === '' || value === undefined) {
return 'non renseigné';
}
return String(value);
},
date(ts: number | null): string {
if (ts === null) {
return 'non renseignée';
}
// Les dates sont en millisecondes (depuis serializeAction), fromTsToFormat attend des secondes
return fromTsToFormat(ts / 1000, 'd M Y');
},
user(user: any): string {
if (!user) {
return 'non renseigné';
}
return `${user.first_name} ${user.last_name} (${user.organization.name})`;
},
userList(users: any[]): string {
if (!users || users.length === 0) {
return 'non renseigné';
}
return users.map(u => `${u.name || u.abbreviation || 'Organisation inconnue'}`).join(', ');
},
topicList(topics: any[]): string {
if (!topics || topics.length === 0) {
return 'non renseignées';
}
return topics.map(t => t.name).join(', ');
},
shantytownList(shantytowns: any[]): string {
if (!shantytowns || shantytowns.length === 0) {
return 'non renseignés';
}
return shantytowns.map(s => s.usename || s.name || `Site #${s.id}`).join(', ');
},
finances(finances: any): string {
if (!finances || Object.keys(finances).length === 0) {
return 'non renseignés';
}
const years = Object.keys(finances).sort((a, b) => a.localeCompare(b));
return years.map((year) => {
const yearFinances = finances[year];
const total = yearFinances.reduce((sum: number, f: any) => sum + (f.amount || 0), 0);
// Arrondir à 2 décimales pour éviter les problèmes de précision
const roundedTotal = Math.round(total * 100) / 100;
return `${year}: ${roundedTotal}€`;
}).join(', ');
},
metrics(metrics: any[]): string {
if (!metrics || metrics.length === 0) {
return 'non renseignés';
}
return `${metrics.length} saisie${metrics.length > 1 ? 's' : ''}`;
},
};

const toDiff: { [key: string]: { label: string, processor?: (value: any) => string } } = {
name: {
label: "Nom de l'action",
},
started_at: {
label: 'Date de début',
processor: baseProcessors.date,
},
ended_at: {
label: 'Date de fin',
processor: baseProcessors.date,
},
goals: {
label: 'Objectifs',
},
'location.departement.name': {
label: 'Département',
},
location_type: {
label: 'Type de localisation',
processor(value: string): string {
const types: { [key: string]: string } = {
logement: 'Logement',
eti: 'Établissement Temporaire d\'Insertion',
sur_site: 'Sur site',
autre: 'Autre',
};
return types[value] || value;
},
},
'eti.address': {
label: 'Adresse',
},
'eti.latitude': {
label: 'Latitude',
},
'eti.longitude': {
label: 'Longitude',
},
location_other: {
label: 'Autre localisation',
},
topics: {
label: 'Thématiques',
processor: baseProcessors.topicList,
},
managers: {
label: 'Pilotes',
processor: baseProcessors.userList,
},
operators: {
label: 'Intervenants',
processor: baseProcessors.userList,
},
location_shantytowns: {
label: 'Sites concernés',
processor: baseProcessors.shantytownList,
},
finances: {
label: 'Financements',
processor: baseProcessors.finances,
},
metrics: {
label: 'Indicateurs',
processor: baseProcessors.metrics,
},
};

const result: Diff[] = [];

Object.keys(toDiff).forEach((serializedKey) => {
const config = toDiff[serializedKey];
const processor = config.processor ?? baseProcessors.default;

const oldValue = getDeepProperty(oldVersion, serializedKey);
const newValue = getDeepProperty(newVersion, serializedKey);

// Traitement spécial pour les finances : comparer type par type, année par année
if (serializedKey === 'finances') {
const oldFinances = oldValue || {};
const newFinances = newValue || {};
const oldYears = Object.keys(oldFinances);
const newYears = Object.keys(newFinances);
const allYears = [...new Set([...oldYears, ...newYears])].sort((a, b) => a.localeCompare(b));

allYears.forEach((year) => {
const oldYearFinances = oldFinances[year] || [];
const newYearFinances = newFinances[year] || [];

// Créer un map des financements par type
const oldByType = new Map();
oldYearFinances.forEach((f: any) => {
const typeUid = f.type?.uid || 'unknown';
oldByType.set(typeUid, {
amount: f.amount || 0,
typeName: f.type?.name || 'Type inconnu',
});
});

const newByType = new Map();
newYearFinances.forEach((f: any) => {
const typeUid = f.type?.uid || 'unknown';
newByType.set(typeUid, {
amount: f.amount || 0,
typeName: f.type?.name || 'Type inconnu',
});
});

// Comparer type par type
const allTypes = new Set([...oldByType.keys(), ...newByType.keys()]);
allTypes.forEach((typeUid) => {
const oldFinance = oldByType.get(typeUid);
const newFinance = newByType.get(typeUid);

const oldAmount = oldFinance ? Math.round(oldFinance.amount * 100) / 100 : 0;
const newAmount = newFinance ? Math.round(newFinance.amount * 100) / 100 : 0;
const typeName = newFinance?.typeName || oldFinance?.typeName || 'Type inconnu';

if (oldAmount !== newAmount) {
result.push({
fieldKey: `finances.${year}.${typeUid}`,
field: `Financement ${year} - ${typeName}`,
oldValue: oldAmount > 0 ? `${formatAmount(oldAmount)} €` : 'non renseigné',
newValue: newAmount > 0 ? `${formatAmount(newAmount)} €` : 'non renseigné',
});
}
});
});
} else {
const oldProcessed = processor(oldValue);
const newProcessed = processor(newValue);

if (oldProcessed !== newProcessed) {
result.push({
fieldKey: serializedKey,
field: config.label,
oldValue: oldProcessed,
newValue: newProcessed,
});
}
}
});

return result;
}
Loading
Loading