diff --git a/.env.sample b/.env.sample index 8eb9fdc..2cbd580 100644 --- a/.env.sample +++ b/.env.sample @@ -44,6 +44,7 @@ NEXT_PUBLIC_SUMMARY_RUN=false # CRON PILO_CRON= SUMMARY_CRON= +USERS_PURGE_CRON= # Debug # NEXT_PUBLIC_DEBUG_MODE=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e5822..2644437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ -## [1.39.0](https://github.com/DNUM-SocialGouv/Medle/compare/release-1.38.0...release-1.39.0) (TODO) +## [1.40.0](https://github.com/DNUM-SocialGouv/Medle/compare/release-1.39.0...release-1.40.0) (TODO) ### Feature +## [1.39.0](https://github.com/DNUM-SocialGouv/Medle/compare/release-1.38.0...release-1.39.0) (2026-08-26) +### Feature + +* MED-112: Correction retours page FAQ +* MED-110: Prévoir un système de purge des utilisateurs + + ## [1.38.0](https://github.com/DNUM-SocialGouv/Medle/compare/release-1.37.0...release-1.38.0) (2026-07-31) ### Feature diff --git a/package.json b/package.json index a761de4..d51cb3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "medle", - "version": "1.38.0", + "version": "1.39.0", "private": true, "engines": { "node": ">=20.9.0" diff --git a/src/__tests__/unit/pages/forgot-password.spec.js b/src/__tests__/unit/pages/forgot-password.spec.js index 99e5631..9fce46c 100644 --- a/src/__tests__/unit/pages/forgot-password.spec.js +++ b/src/__tests__/unit/pages/forgot-password.spec.js @@ -1,13 +1,15 @@ import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import faker from "faker" -import { rest } from "msw" -import { setupServer } from "msw/node" import React from "react" import { API_URL, FORGOT_PWD_ENDPOINT } from "../../../config" import ForgotPasswordPage from "../../../pages/forgot-password" +// Mock isomorphic-unfetch before importing components +jest.mock("isomorphic-unfetch") +import fetch from "isomorphic-unfetch" + const originalWindow = { ...window } const originalConsoleError = { ...console.error } @@ -16,17 +18,40 @@ const foundEmail = "xx" + notFoundEmail // Ensure to have consistently a differe const url = `${API_URL}${FORGOT_PWD_ENDPOINT}` -const server = setupServer( - rest.post(url, (req, res, ctx) => { - if (req.body?.email === notFoundEmail) { - return res(ctx.status(404), ctx.json({ message: `User with email ${notFoundEmail} doesn't exist.`, status: 404 })) +// Setup fetch mock +fetch.mockImplementation((fetchUrl, options) => { + if (fetchUrl === url && options?.method === "POST") { + let body = {} + try { + body = typeof options.body === 'string' ? JSON.parse(options.body) : options.body + } catch (e) { + // If body parsing fails, treat as empty + } + + if (body?.email === notFoundEmail) { + return Promise.resolve({ + ok: false, + status: 404, + json: () => Promise.resolve({ message: `User with email ${notFoundEmail} doesn't exist.`, status: 404 }), + }) } - return res(ctx.status(200), ctx.json({})) - }), -) + + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }) + } + + // Default response for other requests + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }) +}) beforeAll(() => { - server.listen() // Disable window._paq.push used by Matomo. if (!window?._paq?.push) { window._paq = { @@ -37,13 +62,11 @@ beforeAll(() => { }) afterEach(() => { - server.resetHandlers() jest.clearAllMocks() }) afterAll(() => { - server.close() - // eslint-disable-next-line no-global-assign + window = originalWindow console.error = originalConsoleError }) diff --git a/src/__tests__/unit/pages/reset-password.spec.js b/src/__tests__/unit/pages/reset-password.spec.js index b3b2842..c6946a2 100644 --- a/src/__tests__/unit/pages/reset-password.spec.js +++ b/src/__tests__/unit/pages/reset-password.spec.js @@ -1,8 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import faker from "faker" -import { rest } from "msw" -import { setupServer } from "msw/node" import * as nextRouter from "next/router" import React from "react" @@ -11,6 +9,10 @@ import ResetPasswordPage from "../../../pages/reset-password" import { generateToken } from "../../../utils/jwt" import { mockRouterImplementation } from "../../../utils/test-utils" +// Mock isomorphic-unfetch before importing components +jest.mock("isomorphic-unfetch") +import fetch from "isomorphic-unfetch" + const originalWindow = { ...window } const originalConsoleError = { ...console.error } @@ -20,31 +22,47 @@ const user = { email: faker.internet.email() } const correctLoginToken = generateToken(user, { timeout: "1H" }) const incorrectLoginToken = generateToken(user, { timeout: "100ms" }) // Token only valid for 100 ms. -beforeAll(() => { - /* eslint-disable no-import-assign*/ - nextRouter.useRouter.mockImplementation(() => ({ - ...mockRouterImplementation, - query: { loginToken: correctLoginToken }, - })) -}) - -afterAll(() => { - nextRouter.useRouter.mockRestore() -}) - const url = `${API_URL}${RESET_PWD_ENDPOINT}` -const server = setupServer( - rest.patch(url, (req, res, ctx) => { - if (req.body?.loginToken === correctLoginToken) { - return res(ctx.status(200), ctx.json({})) +// Setup fetch mock +fetch.mockImplementation((fetchUrl, options) => { + if (fetchUrl === url && options?.method === "PATCH") { + let body = {} + try { + body = typeof options.body === 'string' ? JSON.parse(options.body) : options.body + } catch (e) { + // If body parsing fails, treat as empty + } + + if (body?.loginToken === correctLoginToken) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }) } - return res(ctx.status(500)) - }), -) + + return Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + }) + } + + // Default response for other requests + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }) +}) beforeAll(() => { - server.listen() + + nextRouter.useRouter.mockImplementation(() => ({ + ...mockRouterImplementation, + query: { loginToken: correctLoginToken }, + })) // Disable window._paq.push used by Matomo. if (!window?._paq?.push) { window._paq = { @@ -55,13 +73,12 @@ beforeAll(() => { }) afterEach(() => { - server.resetHandlers() jest.clearAllMocks() }) afterAll(() => { - server.close() - // eslint-disable-next-line no-global-assign + nextRouter.useRouter.mockRestore() + window = originalWindow console.error = originalConsoleError }) diff --git a/src/clients/app-settings.js b/src/clients/app-settings.js new file mode 100644 index 0000000..eaea6c1 --- /dev/null +++ b/src/clients/app-settings.js @@ -0,0 +1,22 @@ +import fetch from "isomorphic-unfetch" + +import { API_URL, APP_SETTINGS_ENDPOINT } from "../config" +import { handleAPIResponse2 } from "../utils/errors" +import { METHOD_PUT } from "../utils/http" + +const appSettingsEndpoint = API_URL + APP_SETTINGS_ENDPOINT + +export const findAppSettings = async (headers = {}) => { + const response = await fetch(appSettingsEndpoint, { headers }) + return handleAPIResponse2(response) +} + +export const updateAppSettings = async ({ usersPurgeInactivityDays, headers = {} }) => { + const response = await fetch(appSettingsEndpoint, { + body: JSON.stringify({ usersPurgeInactivityDays }), + headers: { ...headers, "Content-Type": "application/json" }, + method: METHOD_PUT, + }) + + return handleAPIResponse2(response) +} \ No newline at end of file diff --git a/src/components/Layout.js b/src/components/Layout.js index 95d60f1..83eafe9 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -417,8 +417,31 @@ Sidebar.propTypes = { page: PropTypes.string, } +const getAdminItemClass = (itemPage, currentPage) => + `list-group-item list-group-item-action ${currentPage === itemPage ? "selected" : "unselected"}` + +const renderAdminLink = (href, label, Icon, pageKey, page, isVisible) => { + if (!isVisible) return null + return ( + + + {label} + + ) +} + const SidebarAdmin = ({ page, currentUser }) => { if (!currentUser) return "" + + const isSuperAdmin = currentUser.role === SUPER_ADMIN + const isAdmin = isAllowed(currentUser.role, ADMIN) + return ( <> { aria-label="Navigation latérale d'administration" className="text-center list-group list-group-flush" > - {isAllowed(currentUser.role, ADMIN) && ( - - - - Utilisateurs - - )} - {currentUser.role === SUPER_ADMIN && ( - - - Établissements - - )} - {currentUser.role === SUPER_ADMIN && ( - - - Demandeurs - - )} - {currentUser.role === SUPER_ADMIN && ( - - - Attentats - - )} - {currentUser.role === SUPER_ADMIN && ( - - - Actes - - )} - {currentUser.role === SUPER_ADMIN && ( - - - Messages - - )} - {currentUser.role === SUPER_ADMIN && ( - - - Logos - - )} + {renderAdminLink("/administration/users", "Utilisateurs", FaceIcon, "users", page, isAdmin)} + {renderAdminLink("/administration/hospitals", "Établissements", ApartmentIcon, "hospitals", page, isSuperAdmin)} + {renderAdminLink("/administration/askers", "Demandeurs", AccountBalanceIcon, "askers", page, isSuperAdmin)} + {renderAdminLink("/administration/attacks", "Attentats", WhatshotIcon, "attacks", page, isSuperAdmin)} + {renderAdminLink("/administration/acts", "Actes", ReceiptIcon, "acts", page, isSuperAdmin)} + {renderAdminLink("/administration/messages", "Messages", AnnouncementIcon, "messages", page, isSuperAdmin)} + {renderAdminLink("/administration/logos", "Logos", ImageIcon, "logos", page, isSuperAdmin)} + {renderAdminLink("/administration/settings", "Paramètres", SettingsIcon, "settings", page, isSuperAdmin)} Retour diff --git a/src/config.js b/src/config.js index 9dd5c0d..2ecc9c1 100644 --- a/src/config.js +++ b/src/config.js @@ -79,6 +79,7 @@ export const EMPLOYMENTS_ENDPOINT = "/employments" export const ATTACKS_ENDPOINT = "/attacks" export const HOSPITALS_ENDPOINT = "/hospitals" export const USERS_ENDPOINT = "/users" +export const APP_SETTINGS_ENDPOINT = "/app-settings" export const GLOBAL_STATISTICS_ENDPOINT = "/statistics/global" export const LIVING_STATISTICS_ENDPOINT = "/statistics/living" diff --git a/src/cron/init-cron.js b/src/cron/init-cron.js index 0c8799c..b7cfd22 100644 --- a/src/cron/init-cron.js +++ b/src/cron/init-cron.js @@ -4,16 +4,19 @@ const { exportPilo } = require("./pilo") const { initPreSummaryActivity } = require("./init-pre-summary-activity") const { initSummaryActivity } = require("./init-summary-activity") const { etpNotif } = require("./etp-notif") +const { purgeDeletedUsers } = require("./purge-users") exports.initCrons = async () => { const piloCronExpression = process.env.PILO_CRON || "0 0 1 * *"; const etpCronExpression = process.env.ETP_NOTIF_CRON || "0 0 1 6,12 *"; const summaryCronExpression = process.env.SUMMARY_CRON || "0 2 1 * *"; + const purgeUsersCronExpression = process.env.USERS_PURGE_CRON || "0 3 1 * *"; console.log("Lancement des CRON"); console.log(`Cron pilo configuré avec les valeurs ${piloCronExpression}`); console.log(`Cron ETP configuré avec les valeurs ${etpCronExpression}`); console.log(`Cron summary configuré avec les valeurs ${summaryCronExpression}`); + console.log(`Cron purge users configuré avec les valeurs ${purgeUsersCronExpression}`); cron .schedule(piloCronExpression, () => { @@ -38,5 +41,13 @@ exports.initCrons = async () => { console.log("Export SUMMARY finished") }) .start() + + cron + .schedule(purgeUsersCronExpression, () => { + console.log("Begin purge users") + purgeDeletedUsers() + console.log("Purge users finished") + }) + .start() console.log("Fin de lancement des CRON"); } diff --git a/src/cron/purge-users.js b/src/cron/purge-users.js new file mode 100644 index 0000000..283e805 --- /dev/null +++ b/src/cron/purge-users.js @@ -0,0 +1,42 @@ +const knexConfig = require("../../knexfile") +const { getUsersPurgeInactivityDays } = require("../services/app-settings") + +const environment = process.env.NODE_ENV || "development" +const knex = require("knex")(knexConfig[environment]) + +exports.purgeDeletedUsers = async () => { + try { + const retentionDays = await getUsersPurgeInactivityDays(knex) + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - retentionDays) + + const usersToPurge = await knex("users") + .where(function () { + this.whereNotNull("deleted_at").orWhere("last_login_at", "<=", cutoffDate) + }) + .select("id") + + const userIdsToDelete = usersToPurge.map(({ id }) => id) + + // Set added_by to null for all acts linked to users being deleted + if (userIdsToDelete.length > 0) { + await knex("acts") + .whereIn("added_by", userIdsToDelete) + .update({ added_by: null }) + } + + // Delete the users completely + await Promise.all( + userIdsToDelete.map((id) => + knex("users") + .where("id", id) + .delete() + ) + ) + + console.log(`${usersToPurge.length} compte(s) utilisateur supprimé(s)`) + return usersToPurge.length + } catch (e) { + console.error("Error Knex Cron purge users :", e) + } +} \ No newline at end of file diff --git a/src/knex/migrations/20260814120000_add_users_last_login_at.js b/src/knex/migrations/20260814120000_add_users_last_login_at.js new file mode 100644 index 0000000..6053dde --- /dev/null +++ b/src/knex/migrations/20260814120000_add_users_last_login_at.js @@ -0,0 +1,14 @@ +exports.up = async function (knex) { + await knex.schema.table("users", function (table) { + table.timestamp("last_login_at", { useTz: true }) + }) + + // Initialize existing users with current timestamp + await knex("users").update({ last_login_at: knex.raw("CURRENT_TIMESTAMP") }) +} + +exports.down = async function (knex) { + await knex.schema.table("users", function (table) { + table.dropColumn("last_login_at") + }) +} \ No newline at end of file diff --git a/src/knex/migrations/20260814121000_create_app_settings.js b/src/knex/migrations/20260814121000_create_app_settings.js new file mode 100644 index 0000000..b1fed4b --- /dev/null +++ b/src/knex/migrations/20260814121000_create_app_settings.js @@ -0,0 +1,18 @@ +exports.up = async function (knex) { + await knex.schema.createTable("app_settings", function (table) { + table.increments("id") + table.timestamp("created_at", { useTz: true }).defaultTo(knex.fn.now()) + table.timestamp("updated_at", { useTz: true }) + table.string("key", 255).notNullable().unique() + table.string("value", 255).notNullable() + }) + + await knex("app_settings").insert({ + key: "users_purge_inactivity_days", + value: "365", + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTable("app_settings") +} \ No newline at end of file diff --git a/src/pages/administration/settings.js b/src/pages/administration/settings.js new file mode 100644 index 0000000..ac181ad --- /dev/null +++ b/src/pages/administration/settings.js @@ -0,0 +1,105 @@ +import Head from "next/head" +import { PropTypes } from "prop-types" +import React, { useState } from "react" +import { Alert, Button, Container, Form, FormGroup, Label, Spinner } from "reactstrap" + +import { findAppSettings, updateAppSettings } from "../../clients/app-settings" +import Layout from "../../components/Layout" +import { InputDarker, Title1 } from "../../components/StyledComponents" +import { buildAuthHeaders, getCurrentUser, isomorphicRedirect, redirectIfUnauthorized, withAuthentication } from "../../utils/auth" +import { preventDefault } from "../../utils/form" +import { logError } from "../../utils/logger" +import { ADMIN, SUPER_ADMIN } from "../../utils/roles" + +const SettingsPage = ({ appSettings = {}, currentUser }) => { + const [usersPurgeInactivityDays, setUsersPurgeInactivityDays] = useState(appSettings.usersPurgeInactivityDays) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [loading, setLoading] = useState(false) + + const onSubmit = preventDefault(async () => { + setError("") + setSuccess("") + + const parsedUsersPurgeInactivityDays = Number.parseInt(usersPurgeInactivityDays, 10) + + if (!Number.isInteger(parsedUsersPurgeInactivityDays) || parsedUsersPurgeInactivityDays <= 0) { + setError("La durée de non connexion doit être un nombre de jours strictement positif.") + return + } + + setLoading(true) + try { + const updatedSettings = await updateAppSettings({ usersPurgeInactivityDays: parsedUsersPurgeInactivityDays }) + setUsersPurgeInactivityDays(updatedSettings.usersPurgeInactivityDays) + setSuccess("Paramètres enregistrés.") + } catch (err) { + logError(err) + setError("Les paramètres n'ont pas pu être enregistrés.") + } finally { + setLoading(false) + } + }) + + return ( + + + Administration des paramètres - Medlé + + + {"Administration des paramètres"} + + + {error && {error}} + {success && {success}} + + + Durée de non connexion avant purge des utilisateurs + setUsersPurgeInactivityDays(event.target.value)} + /> + + + {loading ? : "Enregistrer"} + + + + + ) +} + +SettingsPage.getInitialProps = async (ctx) => { + const headers = buildAuthHeaders(ctx) + const currentUser = getCurrentUser(ctx) + + if (currentUser?.role !== SUPER_ADMIN) { + isomorphicRedirect(ctx, "/permissionError") + return {} + } + + try { + const appSettings = await findAppSettings(headers) + return { appSettings } + } catch (error) { + logError("APP error", error) + redirectIfUnauthorized(error, ctx) + } + + return {} +} + +SettingsPage.propTypes = { + appSettings: PropTypes.object, + currentUser: PropTypes.object.isRequired, +} + +export default withAuthentication(SettingsPage, ADMIN) \ No newline at end of file diff --git a/src/pages/api/app-settings.js b/src/pages/api/app-settings.js new file mode 100644 index 0000000..fed45a9 --- /dev/null +++ b/src/pages/api/app-settings.js @@ -0,0 +1,66 @@ +import Cors from "micro-cors" + +import knex from "../../knex/knex" +import { sendAPIError, sendMethodNotAllowedError } from "../../services/errorHelpers" +import { APIError } from "../../utils/errors" +import { checkIsSuperAdmin, checkValidUserWithPrivilege } from "../../utils/auth" +import { CORS_ALLOW_ORIGIN, METHOD_GET, METHOD_OPTIONS, METHOD_PUT, STATUS_200_OK, STATUS_400_BAD_REQUEST } from "../../utils/http" +import { ADMIN } from "../../utils/roles" +import { logAudit } from "../../utils/logger" + +const { getUsersPurgeInactivityDays, updateUsersPurgeInactivityDays } = require("../../services/app-settings") + +const getCurrentUser = (req, res) => { + const currentUser = checkValidUserWithPrivilege(ADMIN, req, res) + checkIsSuperAdmin(currentUser) + return currentUser +} + +const validateUsersPurgeInactivityDays = (value) => { + const usersPurgeInactivityDays = Number.parseInt(value, 10) + + if (!Number.isInteger(usersPurgeInactivityDays) || usersPurgeInactivityDays <= 0) { + throw new APIError({ + status: STATUS_400_BAD_REQUEST, + message: "La durée de non connexion doit être un nombre de jours strictement positif.", + }) + } + + return usersPurgeInactivityDays +} + +const handler = async (req, res) => { + res.setHeader("Content-Type", "application/json") + res.setHeader("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN) + res.setHeader("Access-Control-Allow-Credentials", "false") + + try { + const currentUser = getCurrentUser(req, res) + + switch (req.method) { + case METHOD_GET: { + const usersPurgeInactivityDays = await getUsersPurgeInactivityDays(knex) + + return res.status(STATUS_200_OK).json({ usersPurgeInactivityDays }) + } + case METHOD_PUT: { + const usersPurgeInactivityDays = validateUsersPurgeInactivityDays(req.body.usersPurgeInactivityDays) + const updatedUsersPurgeInactivityDays = await updateUsersPurgeInactivityDays(knex, usersPurgeInactivityDays) + + logAudit(`${currentUser.email}: Mise à jour du délai de purge des utilisateurs à ${updatedUsersPurgeInactivityDays} jours`) + + return res.status(STATUS_200_OK).json({ usersPurgeInactivityDays: updatedUsersPurgeInactivityDays }) + } + default: + if (req.method !== METHOD_OPTIONS) return sendMethodNotAllowedError(res) + } + } catch (error) { + sendAPIError(error, res) + } +} + +const cors = Cors({ + allowMethods: [METHOD_GET, METHOD_OPTIONS, METHOD_PUT], +}) + +export default cors(handler) \ No newline at end of file diff --git a/src/pages/faq.js b/src/pages/faq.js index feaae31..160c2cd 100644 --- a/src/pages/faq.js +++ b/src/pages/faq.js @@ -118,7 +118,7 @@ const FaqPage = () => { Dois-je enregistrer la prise en charge psychologique ? La prise en charge psychologique des victimes, telle que prévue dans le cadre du schéma directeur et qui est financée par l’assurance maladie à hauteur d’un équivalent temps plein (ETP) par structure de médecine légale du vivant, ne fait pas l’objet d’un recensement dans MedLé. - Victime : précisions sur la rubrique «Types de violence» (sous-rubriques «Nature» et «Contexte») + Victime : précisions sur la rubrique «Types de violence» (sous-rubriques «Nature» et «Contexte») Pour les victimes, le type de violence doit être précisé pour chaque acte. Plusieurs choix sont possibles, vous pouvez donc cocher plusieurs cases à la fois dans «nature de la violence» et dans «contexte de la violence». Voici quelques précisions concernant les items listés dans la rubrique «Types de violence» : diff --git a/src/pages/sitemap.js b/src/pages/sitemap.js index a4dfaf6..46a733e 100644 --- a/src/pages/sitemap.js +++ b/src/pages/sitemap.js @@ -121,6 +121,11 @@ const SiteMapPage = () => { Administration des logos )} + {currentUser.role === SUPER_ADMIN && ( + + Administration des paramètres + + )} {currentUser.role === SUPER_ADMIN && ( Administration des documents du pied de page diff --git a/src/services/app-settings.js b/src/services/app-settings.js new file mode 100644 index 0000000..4c3f3b1 --- /dev/null +++ b/src/services/app-settings.js @@ -0,0 +1,29 @@ +const USERS_PURGE_INACTIVITY_DAYS = "users_purge_inactivity_days" +const DEFAULT_USERS_PURGE_INACTIVITY_DAYS = 365 + +const parsePositiveInteger = (value, defaultValue) => { + const parsedValue = Number.parseInt(value, 10) + return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : defaultValue +} + +const getUsersPurgeInactivityDays = async (knex) => { + const [setting] = await knex("app_settings").where("key", USERS_PURGE_INACTIVITY_DAYS).select("value") + + return parsePositiveInteger(setting?.value, DEFAULT_USERS_PURGE_INACTIVITY_DAYS) +} + +const updateUsersPurgeInactivityDays = async (knex, value) => { + const usersPurgeInactivityDays = parsePositiveInteger(value, null) + + await knex("app_settings") + .where("key", USERS_PURGE_INACTIVITY_DAYS) + .update({ value: String(usersPurgeInactivityDays) }) + + return usersPurgeInactivityDays +} + +module.exports = { + DEFAULT_USERS_PURGE_INACTIVITY_DAYS, + getUsersPurgeInactivityDays, + updateUsersPurgeInactivityDays, +} \ No newline at end of file diff --git a/src/services/authentication.js b/src/services/authentication.js index 23230c0..50b2a31 100644 --- a/src/services/authentication.js +++ b/src/services/authentication.js @@ -48,6 +48,8 @@ export const authenticate = async (email, password) => { */ if (loginDelayConfig && !(await userCanLogin(dbUser, loginDelayConfig))) notifyDelay(loginDelayConfig) + await updateLastLogin(dbUser) + const user = transform(dbUser) const token = user.resetPassword ? generateToken(user, { timeout: "5m" }) : generateToken(user) return { user, token } @@ -138,6 +140,13 @@ const cleanLoginDelay = async (dbUser) => { .update({ login_attempts: null, login_last_attempt_at: null }) } +const updateLastLogin = async (dbUser) => { + await knex("users") + .where("id", dbUser.id) + .whereNull("deleted_at") + .update({ last_login_at: knex.fn.now() }) +} + const notifyDelay = (loginDelayConfig) => { throw new APIError({ status: STATUS_429_TOO_MANY_REQUESTS, diff --git a/src/services/users/create.js b/src/services/users/create.js index a552552..d0a502a 100644 --- a/src/services/users/create.js +++ b/src/services/users/create.js @@ -40,7 +40,7 @@ export const create = async (user, currentUser) => { } let untransformedUser = untransform(user) - untransformedUser = { ...untransformedUser, reset_password: true } + untransformedUser = { ...untransformedUser, reset_password: true, last_login_at: knex.raw("CURRENT_TIMESTAMP") } const [newId] = await knex("users").insert(untransformedUser, "id")
La prise en charge psychologique des victimes, telle que prévue dans le cadre du schéma directeur et qui est financée par l’assurance maladie à hauteur d’un équivalent temps plein (ETP) par structure de médecine légale du vivant, ne fait pas l’objet d’un recensement dans MedLé.
Victime : précisions sur la rubrique «Types de violence» (sous-rubriques «Nature» et «Contexte»)
Pour les victimes, le type de violence doit être précisé pour chaque acte. Plusieurs choix sont possibles, vous pouvez donc cocher plusieurs cases à la fois dans «nature de la violence» et dans «contexte de la violence».
Voici quelques précisions concernant les items listés dans la rubrique «Types de violence» :