diff --git a/packages/api/server/controllers/action/getHistory/action.getHistory.route.ts b/packages/api/server/controllers/action/getHistory/action.getHistory.route.ts new file mode 100644 index 0000000000..56d1ec70dc --- /dev/null +++ b/packages/api/server/controllers/action/getHistory/action.getHistory.route.ts @@ -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, + }); +} diff --git a/packages/api/server/controllers/action/getHistory/action.getHistory.ts b/packages/api/server/controllers/action/getHistory/action.getHistory.ts new file mode 100644 index 0000000000..91e5202adb --- /dev/null +++ b/packages/api/server/controllers/action/getHistory/action.getHistory.ts @@ -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); + } +} diff --git a/packages/api/server/models/_common/types/UserInfoTypes.d.ts b/packages/api/server/models/_common/types/UserInfoTypes.d.ts new file mode 100644 index 0000000000..63a2db39b4 --- /dev/null +++ b/packages/api/server/models/_common/types/UserInfoTypes.d.ts @@ -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; diff --git a/packages/api/server/models/actionModel/_common/getDiff.ts b/packages/api/server/models/actionModel/_common/getDiff.ts new file mode 100644 index 0000000000..7c92108724 --- /dev/null +++ b/packages/api/server/models/actionModel/_common/getDiff.ts @@ -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; +} diff --git a/packages/api/server/models/actionModel/_common/serializeAction.ts b/packages/api/server/models/actionModel/_common/serializeAction.ts new file mode 100644 index 0000000000..f580e343cb --- /dev/null +++ b/packages/api/server/models/actionModel/_common/serializeAction.ts @@ -0,0 +1,120 @@ +import permissionUtils from '#server/utils/permission'; +import { CreatorInfo, EditorInfo } from '#server/models/_common/types/UserInfoTypes.d'; +import Action from '#root/types/resources/Action.d'; +import { User } from '#root/types/resources/User.d'; + +const { can } = permissionUtils; + +export type ActionRow = { + hid?: number, + action_id: number, + action_ref: string | null, + name: string, + started_at: string | Date, + ended_at: string | Date | null, + goals: string | null, + departement_name: string, + departement_code: string, + region_name: string, + region_code: string, + location_type: string, + address: string | null, + latitude: number | null, + longitude: number | null, + eti_fk_city: string | null, + location_other: string | null, + topics?: Array<{ uid: string, name: string }>, + managers?: Array, + operators?: Array, + shantytowns?: Array, + finances?: { [key: number]: any[] }, + metrics?: Array, +} & CreatorInfo & EditorInfo; + +function fromDateToTimestamp(date: string | Date | null): number | null { + if (date === null) { + return null; + } + + if (typeof date === 'string') { + return new Date(date).getTime(); + } + + return date.getTime(); +} + +export default function serializeAction(action: ActionRow, user: User): Action { + const location = { + type: 'departement' as const, + city: null, + epci: null, + departement: { + code: action.departement_code, + name: action.departement_name, + }, + region: { + code: action.region_code, + name: action.region_name, + }, + }; + + const serializedAction: Action = { + type: 'action', + id: action.action_id, + displayId: action.action_ref || (() => { + const createdYear = new Date(action.created_at).getFullYear(); + const paddedActionId = String(action.action_id).padStart(4, '0'); + return `ID${action.departement_code}${createdYear}${paddedActionId}`; + })(), + name: action.name, + started_at: fromDateToTimestamp(action.started_at), + ended_at: fromDateToTimestamp(action.ended_at), + goals: action.goals, + topics: action.topics || [], + location, + location_type: action.location_type as any, + eti: action.location_type === 'eti' ? { + address: action.address, + latitude: action.latitude, + longitude: action.longitude, + citycode: action.eti_fk_city, + } : null, + location_other: action.location_other, + location_shantytowns: action.shantytowns || [], + managers: action.managers || [], + operators: action.operators || [], + metrics: action.metrics || [], + metrics_updated_at: null, + hasDihalFinancing: false, + comments: [], + created_at: action.created_at.getTime(), + created_by: { + id: action.creator_id, + first_name: action.creator_first_name, + last_name: action.creator_last_name, + organization: { + id: action.creator_organization_id, + name: action.creator_organization_name, + abbreviation: action.creator_organization_abbreviation, + }, + }, + updated_at: action.updated_at?.getTime() || null, + updated_by: action.editor_id === null ? null : { + id: action.editor_id, + first_name: action.editor_first_name, + last_name: action.editor_last_name, + organization: { + id: action.editor_organization_id, + name: action.editor_organization_name, + abbreviation: action.editor_organization_abbreviation, + }, + }, + }; + + // Ajouter les finances uniquement si l'utilisateur a la permission + if (can(user).do('access', 'action_finances').on(location)) { + serializedAction.finances = action.finances || {}; + } + + return serializedAction; +} diff --git a/packages/api/server/models/actionModel/fetch/fetchActions.ts b/packages/api/server/models/actionModel/fetch/fetchActions.ts index 6cfc532068..38ae142363 100644 --- a/packages/api/server/models/actionModel/fetch/fetchActions.ts +++ b/packages/api/server/models/actionModel/fetch/fetchActions.ts @@ -1,5 +1,6 @@ import { QueryTypes, Transaction } from 'sequelize'; import { sequelize } from '#db/sequelize'; +import { CreatorInfo, EditorInfo } from '#server/models/_common/types/UserInfoTypes.d'; import ActionLocationType from '#root/types/resources/ActionLocationType.d'; import enrichWhere from './enrichWhere'; @@ -20,21 +21,7 @@ export type ActionSelectRow = { longitude: number | null, eti_fk_city: string | null, location_other: string | null, - 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, - 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, -}; +} & CreatorInfo & EditorInfo; export default function fetchActions(actionIds: number[] = null, clauseGroup: object = {}, transaction?: Transaction): Promise { const where = []; diff --git a/packages/api/server/models/actionModel/fetch/fetchMetrics.ts b/packages/api/server/models/actionModel/fetch/fetchMetrics.ts index ccb3e348d2..bf384517c0 100644 --- a/packages/api/server/models/actionModel/fetch/fetchMetrics.ts +++ b/packages/api/server/models/actionModel/fetch/fetchMetrics.ts @@ -1,5 +1,6 @@ import { QueryTypes, Transaction } from 'sequelize'; import { sequelize } from '#db/sequelize'; +import { CreatorInfo } from '#server/models/_common/types/UserInfoTypes.d'; import enrichWhere from './enrichWhere'; export type ActionMetricsRow = { @@ -24,14 +25,7 @@ export type ActionMetricsRow = { scolaire_nombre_college: number | null, scolaire_nombre_lycee: number | null, scolaire_nombre_autre: number | null, - created_at: Date, - creator_id: number, - creator_first_name: string, - creator_last_name: string, - creator_organization_id: number, - creator_organization_name: string, - creator_organization_abbreviation: string | null, -}; +} & CreatorInfo; export default function fetchMetrics(actionIds: number[] = null, clauseGroup: object = {}, transaction?: Transaction): Promise { const where = []; diff --git a/packages/api/server/models/actionModel/fetchComments/ActionCommentRow.d.ts b/packages/api/server/models/actionModel/fetchComments/ActionCommentRow.d.ts index 5e7e3286f0..d59ed7b35d 100644 --- a/packages/api/server/models/actionModel/fetchComments/ActionCommentRow.d.ts +++ b/packages/api/server/models/actionModel/fetchComments/ActionCommentRow.d.ts @@ -1,14 +1,9 @@ +import { CreatorInfo } from '#server/models/_common/types/UserInfoTypes.d'; + export type ActionRowComment = { action_id: number, id: number, description: string, - created_at: Date, - creator_id: number, - creator_first_name: string, - creator_last_name: string, creator_user_role: string, - creator_organization_id: number, - creator_organization_name: string, - creator_organization_abbreviation: string | null, attachments: string[], -}; +} & CreatorInfo; diff --git a/packages/api/server/models/actionModel/getHistory/getHistory.ts b/packages/api/server/models/actionModel/getHistory/getHistory.ts new file mode 100644 index 0000000000..653ca96946 --- /dev/null +++ b/packages/api/server/models/actionModel/getHistory/getHistory.ts @@ -0,0 +1,476 @@ +import { sequelize } from '#db/sequelize'; +import { QueryTypes } from 'sequelize'; +import permissionUtils from '#server/utils/permission'; +import serializeAction, { ActionRow } from '../_common/serializeAction'; +import getDiff from '../_common/getDiff'; +import { User } from '#root/types/resources/User.d'; +import Action from '#root/types/resources/Action.d'; + +const { can } = permissionUtils; + +type ActionHistoryRow = ActionRow & { + hid: number, + author_first_name: string, + author_last_name: string, + author_organization_id: number, + author_organization_name: string, + author_organization_abbreviation: string | null, +}; + +export type ActionActivity = { + entity: 'action', + action: 'creation' | 'update', + date: number, + author: { + first_name: string, + last_name: string, + organization: { + id: number, + name: string, + abbreviation: string | null, + }, + }, + actionEntity: { + id: number, + name: string, + }, + diff?: Array<{ + fieldKey: string, + field: string, + oldValue: string, + newValue: string, + }>, +}; + +export default async function getHistory(user: User, actionId: number): Promise { + // Récupérer d'abord l'action pour vérifier les permissions sur sa vraie localisation + const actionCheck = await sequelize.query( + `SELECT a.action_id, d.code as departement_code, d.name as departement_name, r.code as region_code, r.name as region_name + FROM actions a + LEFT JOIN departements d ON a.fk_departement = d.code + LEFT JOIN regions r ON d.fk_region = r.code + WHERE a.action_id = :actionId`, + { + type: QueryTypes.SELECT, + replacements: { actionId }, + }, + ); + + if (actionCheck.length === 0) { + return []; + } + + const actionData: any = actionCheck[0]; + const location = { + type: 'departement' as const, + city: null, + epci: null, + departement: { + code: actionData.departement_code, + name: actionData.departement_name, + }, + region: { + code: actionData.region_code, + name: actionData.region_name, + }, + }; + + if (!can(user).do('read', 'action').on(location)) { + return []; + } + + const canAccessFinances = can(user).do('access', 'action_finances').on(location); + + const activitiesRaw: any[] = await sequelize.query( + ` + ( + WITH + action_topics_agg AS ( + SELECT + ath.fk_action AS hid, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'uid', t.uid, + 'name', t.name + ) + ) FILTER (WHERE t.uid IS NOT NULL), + '[]'::jsonb + ) AS topics + FROM action_topics_history ath + LEFT JOIN topics t ON ath.fk_topic = t.uid + GROUP BY ath.fk_action + ), + action_managers_agg AS ( + SELECT + amh.fk_action AS hid, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', o.organization_id, + 'name', o.name, + 'abbreviation', o.abbreviation + ) + ) FILTER (WHERE o.organization_id IS NOT NULL), + '[]'::jsonb + ) AS managers + FROM action_managers_history amh + LEFT JOIN users u ON amh.fk_user = u.user_id + LEFT JOIN organizations o ON u.fk_organization = o.organization_id + GROUP BY amh.fk_action + ), + action_operators_agg AS ( + SELECT + aoh.fk_action AS hid, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', o.organization_id, + 'name', o.name, + 'abbreviation', o.abbreviation + ) + ) FILTER (WHERE o.organization_id IS NOT NULL), + '[]'::jsonb + ) AS operators + FROM action_operators_history aoh + LEFT JOIN users u ON aoh.fk_user = u.user_id + LEFT JOIN organizations o ON u.fk_organization = o.organization_id + GROUP BY aoh.fk_action + ), + action_shantytowns_agg AS ( + SELECT + ash.fk_action AS hid, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', s.shantytown_id, + 'name', s.name, + 'usename', COALESCE(s.name, s.address, CONCAT(c.name, ' (', s.address, ')')) + ) + ) FILTER (WHERE s.shantytown_id IS NOT NULL), + '[]'::jsonb + ) AS shantytowns + FROM action_shantytowns_history ash + LEFT JOIN shantytowns s ON ash.fk_shantytown = s.shantytown_id + LEFT JOIN cities c ON s.fk_city = c.code + GROUP BY ash.fk_action + ) + ${canAccessFinances ? `, + action_finances_agg AS ( + SELECT + afh.fk_action AS hid, + COALESCE( + jsonb_object_agg( + afh.year::text, + afh.year_finances + ) FILTER (WHERE afh.year IS NOT NULL), + '{}'::jsonb + ) AS finances + FROM ( + SELECT + afh2.fk_action, + afh2.year, + jsonb_agg( + jsonb_build_object( + 'type', jsonb_build_object( + 'uid', aft.uid, + 'name', aft.name + ), + 'amount', afh2.amount, + 'real_amount', afh2.real_amount, + 'comments', afh2.comments + ) + ) AS year_finances + FROM action_finances_history afh2 + LEFT JOIN action_finance_types aft ON afh2.fk_action_finance_type = aft.uid + GROUP BY afh2.fk_action, afh2.year + ) afh + GROUP BY afh.fk_action + )` : ''} + + SELECT + ah.hid, + ah.action_id, + ah.action_ref, + ah.name, + ah.started_at, + ah.ended_at, + ah.goals, + d.name AS departement_name, + d.code AS departement_code, + r.name AS region_name, + r.code AS region_code, + ah.location_type, + ah.address, + ah.latitude, + ah.longitude, + ah.eti_fk_city, + ah.location_other, + creator.user_id AS creator_id, + creator.first_name AS creator_first_name, + creator.last_name AS creator_last_name, + creator.fk_organization AS creator_organization, + editor.user_id AS editor_id, + editor.first_name AS editor_first_name, + editor.last_name AS editor_last_name, + editor.fk_organization AS editor_organization, + ah.created_by, + ah.created_at, + ah.updated_by, + COALESCE(ah.updated_at, ah.created_at) AS updated_at, + COALESCE(ah.updated_by, ah.created_by) AS authorId, + author.first_name AS author_first_name, + author.last_name AS author_last_name, + author.fk_organization AS author_organization_id, + author_org.name AS author_organization_name, + author_org.abbreviation AS author_organization_abbreviation, + COALESCE(topics.topics, '[]'::jsonb) AS topics, + COALESCE(managers.managers, '[]'::jsonb) AS managers, + COALESCE(operators.operators, '[]'::jsonb) AS operators, + COALESCE(shantytowns.shantytowns, '[]'::jsonb) AS shantytowns + ${canAccessFinances ? ', COALESCE(finances.finances, \'{}\'::jsonb) AS finances' : ''} + FROM actions_history ah + LEFT JOIN departements d ON ah.fk_departement = d.code + LEFT JOIN regions r ON d.fk_region = r.code + LEFT JOIN users creator ON ah.created_by = creator.user_id + LEFT JOIN organizations creator_org ON creator.fk_organization = creator_org.organization_id + LEFT JOIN users editor ON ah.updated_by = editor.user_id + LEFT JOIN organizations editor_org ON editor.fk_organization = editor_org.organization_id + LEFT JOIN users author ON COALESCE(ah.updated_by, ah.created_by) = author.user_id + LEFT JOIN organizations author_org ON author.fk_organization = author_org.organization_id + LEFT JOIN action_topics_agg topics ON ah.hid = topics.hid + LEFT JOIN action_managers_agg managers ON ah.hid = managers.hid + LEFT JOIN action_operators_agg operators ON ah.hid = operators.hid + LEFT JOIN action_shantytowns_agg shantytowns ON ah.hid = shantytowns.hid + ${canAccessFinances ? 'LEFT JOIN action_finances_agg finances ON ah.hid = finances.hid' : ''} + WHERE ah.action_id = :actionId + ) + UNION + ( + WITH + action_topics_agg AS ( + SELECT + :actionId AS hid, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'uid', t.uid, + 'name', t.name + ) + ) + FROM action_topics at + LEFT JOIN topics t ON at.fk_topic = t.uid + WHERE at.fk_action = :actionId + ), + '[]'::jsonb + ) AS topics + ), + action_managers_agg AS ( + SELECT + :actionId AS hid, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id', o.organization_id, + 'name', o.name, + 'abbreviation', o.abbreviation + ) + ) + FROM action_managers am + LEFT JOIN users u ON am.fk_user = u.user_id + LEFT JOIN organizations o ON u.fk_organization = o.organization_id + WHERE am.fk_action = :actionId + ), + '[]'::jsonb + ) AS managers + ), + action_operators_agg AS ( + SELECT + :actionId AS hid, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id', o.organization_id, + 'name', o.name, + 'abbreviation', o.abbreviation + ) + ) + FROM action_operators ao + LEFT JOIN users u ON ao.fk_user = u.user_id + LEFT JOIN organizations o ON u.fk_organization = o.organization_id + WHERE ao.fk_action = :actionId + ), + '[]'::jsonb + ) AS operators + ), + action_shantytowns_agg AS ( + SELECT + :actionId AS hid, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id', s.shantytown_id, + 'name', s.name, + 'usename', COALESCE(s.name, s.address, CONCAT(c.name, ' (', s.address, ')')) + ) + ) + FROM action_shantytowns ash + LEFT JOIN shantytowns s ON ash.fk_shantytown = s.shantytown_id + LEFT JOIN cities c ON s.fk_city = c.code + WHERE ash.fk_action = :actionId + ), + '[]'::jsonb + ) AS shantytowns + ) + ${canAccessFinances ? `, + action_finances_agg AS ( + SELECT + :actionId AS hid, + COALESCE( + ( + SELECT jsonb_object_agg( + af.year::text, + af.year_finances + ) + FROM ( + SELECT + af2.year, + jsonb_agg( + jsonb_build_object( + 'type', jsonb_build_object( + 'uid', aft.uid, + 'name', aft.name + ), + 'amount', af2.amount, + 'real_amount', af2.real_amount, + 'comments', af2.comments + ) + ) AS year_finances + FROM action_finances af2 + LEFT JOIN action_finance_types aft ON af2.fk_action_finance_type = aft.uid + WHERE af2.fk_action = :actionId + GROUP BY af2.year + ) af + ), + '{}'::jsonb + ) AS finances + )` : ''} + + SELECT + 0 AS hid, + a.action_id, + a.action_ref, + a.name, + a.started_at, + a.ended_at, + a.goals, + d.name AS departement_name, + d.code AS departement_code, + r.name AS region_name, + r.code AS region_code, + a.location_type::text::enum_actions_history_location_type AS location_type, + a.address, + a.latitude, + a.longitude, + a.eti_fk_city, + a.location_other, + creator.user_id AS creator_id, + creator.first_name AS creator_first_name, + creator.last_name AS creator_last_name, + creator.fk_organization AS creator_organization, + editor.user_id AS editor_id, + editor.first_name AS editor_first_name, + editor.last_name AS editor_last_name, + editor.fk_organization AS editor_organization, + a.created_by, + a.created_at, + a.updated_by, + COALESCE(a.updated_at, a.created_at) AS updated_at, + COALESCE(a.updated_by, a.created_by) AS authorId, + author.first_name AS author_first_name, + author.last_name AS author_last_name, + author.fk_organization AS author_organization_id, + author_org.name AS author_organization_name, + author_org.abbreviation AS author_organization_abbreviation, + COALESCE(topics.topics, '[]'::jsonb) AS topics, + COALESCE(managers.managers, '[]'::jsonb) AS managers, + COALESCE(operators.operators, '[]'::jsonb) AS operators, + COALESCE(shantytowns.shantytowns, '[]'::jsonb) AS shantytowns + ${canAccessFinances ? ', COALESCE(finances.finances, \'{}\'::jsonb) AS finances' : ''} + FROM actions a + LEFT JOIN departements d ON a.fk_departement = d.code + LEFT JOIN regions r ON d.fk_region = r.code + LEFT JOIN users creator ON a.created_by = creator.user_id + LEFT JOIN organizations creator_org ON creator.fk_organization = creator_org.organization_id + LEFT JOIN users editor ON a.updated_by = editor.user_id + LEFT JOIN organizations editor_org ON editor.fk_organization = editor_org.organization_id + LEFT JOIN users author ON COALESCE(a.updated_by, a.created_by) = author.user_id + LEFT JOIN organizations author_org ON author.fk_organization = author_org.organization_id + LEFT JOIN action_topics_agg topics ON a.action_id = topics.hid + LEFT JOIN action_managers_agg managers ON a.action_id = managers.hid + LEFT JOIN action_operators_agg operators ON a.action_id = operators.hid + LEFT JOIN action_shantytowns_agg shantytowns ON a.action_id = shantytowns.hid + ${canAccessFinances ? 'LEFT JOIN action_finances_agg finances ON a.action_id = finances.hid' : ''} + WHERE a.action_id = :actionId + ) + ORDER BY updated_at ASC, hid DESC + `, + { + type: QueryTypes.SELECT, + replacements: { actionId }, + }, + ); + + // Parser les colonnes JSONB qui peuvent être retournées comme des strings + const activities: ActionHistoryRow[] = activitiesRaw.map((row: any) => ({ + ...row, + topics: typeof row.topics === 'string' ? JSON.parse(row.topics) : row.topics, + managers: typeof row.managers === 'string' ? JSON.parse(row.managers) : row.managers, + operators: typeof row.operators === 'string' ? JSON.parse(row.operators) : row.operators, + shantytowns: typeof row.shantytowns === 'string' ? JSON.parse(row.shantytowns) : row.shantytowns, + finances: row.finances && typeof row.finances === 'string' ? JSON.parse(row.finances) : row.finances, + })); + + const previousVersions: { [key: number]: Action } = {}; + + return activities + .map((activity: ActionHistoryRow) => { + const serializedAction = serializeAction(activity, user); + const previousVersion = previousVersions[activity.action_id] ?? null; + previousVersions[activity.action_id] = serializedAction; + + const base = { + entity: 'action' as const, + date: activity.updated_at.getTime() / 1000, + author: { + first_name: activity.author_first_name, + last_name: activity.author_last_name, + organization: { + id: activity.author_organization_id, + name: activity.author_organization_name, + abbreviation: activity.author_organization_abbreviation, + }, + }, + actionEntity: { + id: activity.action_id, + name: activity.name, + }, + }; + + if (previousVersion === null) { + return { ...base, action: 'creation' as const }; + } + + const diff = getDiff(previousVersion, serializedAction); + if (diff.length === 0) { + return null; + } + + return { ...base, action: 'update' as const, diff }; + }) + .filter(activity => activity !== null) as ActionActivity[]; +} diff --git a/packages/api/server/models/actionModel/index.ts b/packages/api/server/models/actionModel/index.ts index 1f761ed2f4..8203984b1f 100644 --- a/packages/api/server/models/actionModel/index.ts +++ b/packages/api/server/models/actionModel/index.ts @@ -9,6 +9,7 @@ import findActionFinancesReaders from './findActionFinancesReaders/findActionFin import findActionFinancesReadersByAction from './findActionFinancesReaders/findActionFinancesReadersByAction'; import findActionFinancesReadersByManagers from './findActionFinancesReaders/findActionFinancesReadersByManagers'; import getCommentHistory from './getCommentHistory/getCommentHistory'; +import getHistory from './getHistory/getHistory'; import update from './update/update'; export default { @@ -23,5 +24,6 @@ export default { findActionFinancesReadersByAction, findActionFinancesReadersByManagers, getCommentHistory, + getHistory, update, }; diff --git a/packages/api/server/services/action/actionService.ts b/packages/api/server/services/action/actionService.ts index 9174319904..01e51a6c05 100644 --- a/packages/api/server/services/action/actionService.ts +++ b/packages/api/server/services/action/actionService.ts @@ -5,6 +5,7 @@ import fetch from './fetch'; import findActionFinancesReadersByAction from './findActionFinancesReadersByAction'; import findActionFinancesReadersByManagers from './findActionFinancesReadersByManagers'; import getCommentReport from './getCommentReport'; +import getHistory from './getHistory'; import requestPilot from './requestPilot'; import update from './update'; @@ -16,6 +17,7 @@ export default { findActionFinancesReadersByAction, findActionFinancesReadersByManagers, getCommentReport, + getHistory, requestPilot, update, }; diff --git a/packages/api/server/services/action/getHistory.spec.ts b/packages/api/server/services/action/getHistory.spec.ts new file mode 100644 index 0000000000..abc2e35fe6 --- /dev/null +++ b/packages/api/server/services/action/getHistory.spec.ts @@ -0,0 +1,154 @@ +import chai from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { rewiremock } from '#test/rewiremock'; +import ServiceError from '#server/errors/ServiceError'; + +const { expect } = chai; +chai.use(sinonChai); + +// stubs +const sandbox = sinon.createSandbox(); +const stubs = { + getHistoryModel: sandbox.stub(), +}; + +rewiremock('#server/models/actionModel/index').with({ + getHistory: stubs.getHistoryModel, +}); + +rewiremock.enable(); +// eslint-disable-next-line import/newline-after-import, import/first +import getHistory from './getHistory'; +rewiremock.disable(); + +describe('services/action', () => { + describe('getHistory()', () => { + afterEach(() => { + sandbox.reset(); + }); + + it(' retourne l\'historique de l\'action', async () => { + const fakeUser = { id: 1, email: 'test@example.com' }; + const fakeActionId = 186; + const fakeHistory = [ + { + entity: 'action', + action: 'update', + date: 1711287440, + author: { + name: 'John Doe', + organization: 1, + }, + actionEntity: { + id: 186, + name: 'Test Action', + }, + diff: [ + { + fieldKey: 'finances', + field: 'Financements', + oldValue: '2022: 10000€', + newValue: '2022: 10000€, 2023: 11000€', + }, + ], + }, + ]; + + stubs.getHistoryModel.withArgs(fakeUser, fakeActionId).resolves(fakeHistory); + + const result = await getHistory(fakeUser as any, fakeActionId); + + expect(stubs.getHistoryModel).to.have.been.calledOnceWith(fakeUser, fakeActionId); + expect(result).to.deep.equal(fakeHistory); + }); + + it(' lance une ServiceError si le modèle échoue', async () => { + const fakeUser = { id: 1, email: 'test@example.com' }; + const fakeActionId = 186; + const fakeError = new Error('Database error'); + + stubs.getHistoryModel.withArgs(fakeUser, fakeActionId).rejects(fakeError); + + let error; + try { + await getHistory(fakeUser as any, fakeActionId); + } catch (e) { + error = e; + } + + expect(error).to.be.instanceOf(ServiceError); + expect(error.code).to.equal('fetch_failed'); + expect(error.nativeError).to.equal(fakeError); + }); + + it(' retourne un tableau vide si l\'utilisateur n\'a pas les permissions', async () => { + const fakeUser = { id: 1, email: 'test@example.com' }; + const fakeActionId = 186; + + stubs.getHistoryModel.withArgs(fakeUser, fakeActionId).resolves([]); + + const result = await getHistory(fakeUser as any, fakeActionId); + + expect(result).to.deep.equal([]); + }); + + it(' retourne l\'historique avec plusieurs modifications', async () => { + const fakeUser = { id: 1, email: 'test@example.com' }; + const fakeActionId = 186; + const fakeHistory = [ + { + entity: 'action', + action: 'update', + date: 1711287440, + author: { + name: 'John Doe', + organization: 1, + }, + actionEntity: { + id: 186, + name: 'Test Action', + }, + diff: [ + { + fieldKey: 'name', + field: 'Nom de l\'action', + oldValue: 'Ancien nom', + newValue: 'Nouveau nom', + }, + ], + }, + { + entity: 'action', + action: 'update', + date: 1711373840, + author: { + name: 'Jane Smith', + organization: 2, + }, + actionEntity: { + id: 186, + name: 'Test Action', + }, + diff: [ + { + fieldKey: 'finances', + field: 'Financements', + oldValue: 'non renseignés', + newValue: '2022: 10000€', + }, + ], + }, + ]; + + stubs.getHistoryModel.withArgs(fakeUser, fakeActionId).resolves(fakeHistory); + + const result = await getHistory(fakeUser as any, fakeActionId); + + expect(result).to.have.lengthOf(2); + expect(result[0].diff[0].fieldKey).to.equal('name'); + expect(result[1].diff[0].fieldKey).to.equal('finances'); + }); + }); +}); diff --git a/packages/api/server/services/action/getHistory.ts b/packages/api/server/services/action/getHistory.ts new file mode 100644 index 0000000000..db2ef874bb --- /dev/null +++ b/packages/api/server/services/action/getHistory.ts @@ -0,0 +1,13 @@ +import actionModel from '#server/models/actionModel/index'; +import { ActionActivity } from '#server/models/actionModel/getHistory/getHistory'; +import ServiceError from '#server/errors/ServiceError'; +import { User } from '#root/types/resources/User.d'; + +export default async function getHistory(user: User, actionId: number): Promise { + try { + const history = await actionModel.getHistory(user, actionId); + return history; + } catch (error) { + throw new ServiceError('fetch_failed', error); + } +} diff --git a/packages/frontend/webapp/src/api/actions.api.js b/packages/frontend/webapp/src/api/actions.api.js index 8f4a412276..36dd52f0c1 100644 --- a/packages/frontend/webapp/src/api/actions.api.js +++ b/packages/frontend/webapp/src/api/actions.api.js @@ -60,3 +60,7 @@ export function getActionFinancementsReadersByAction(actionId) { export function requestPilot(actionId) { return axios.get(`/actions/${encodeURI(actionId)}/requestPilot`); } + +export function getActionHistory(actionId) { + return axios.get(`/actions/${encodeURI(actionId)}/history`); +} diff --git a/packages/frontend/webapp/src/components/FicheAction/FicheAction.filter.js b/packages/frontend/webapp/src/components/FicheAction/FicheAction.filter.js new file mode 100644 index 0000000000..da24db81b1 --- /dev/null +++ b/packages/frontend/webapp/src/components/FicheAction/FicheAction.filter.js @@ -0,0 +1,27 @@ +export default { + categories: [ + { value: "caracteristiques", label: "Intervention" }, + { value: "localisation", label: "Lieu" }, + { value: "contacts", label: "Contacts" }, + { value: "financements", label: "Financements" }, + { value: "indicateurs", label: "Indicateurs" }, + { value: "sites", label: "Sites concernés" }, + { value: "thematiques", label: "Thématiques" }, + ], + fields: { + caracteristiques: ["name", "started_at", "ended_at", "goals"], + localisation: [ + "location.departement.name", + "location_type", + "eti.address", + "eti.latitude", + "eti.longitude", + "location_other", + ], + contacts: ["managers", "operators"], + financements: ["finances"], + indicateurs: ["metrics"], + sites: ["location_shantytowns"], + thematiques: ["topics"], + }, +}; diff --git a/packages/frontend/webapp/src/components/FicheAction/FicheAction.vue b/packages/frontend/webapp/src/components/FicheAction/FicheAction.vue index 4910c86c3f..8e065aafd2 100644 --- a/packages/frontend/webapp/src/components/FicheAction/FicheAction.vue +++ b/packages/frontend/webapp/src/components/FicheAction/FicheAction.vue @@ -1,5 +1,5 @@ diff --git a/packages/frontend/webapp/src/components/FicheAction/FicheActionAbsenceIndicateurs/FicheActionAbsenceIndicateurs.vue b/packages/frontend/webapp/src/components/FicheAction/FicheActionAbsenceIndicateurs/FicheActionAbsenceIndicateurs.vue index 3435d27f3f..fd079748c5 100644 --- a/packages/frontend/webapp/src/components/FicheAction/FicheActionAbsenceIndicateurs/FicheActionAbsenceIndicateurs.vue +++ b/packages/frontend/webapp/src/components/FicheAction/FicheActionAbsenceIndicateurs/FicheActionAbsenceIndicateurs.vue @@ -1,5 +1,10 @@