diff --git a/README.md b/README.md index 42e905a28..d2d4b41ba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Mano -![Mobile version](https://img.shields.io/badge/mobile%20app%20version-2.36.2-blue) +![Mobile version](https://img.shields.io/badge/mobile%20app%20version-2.37.0-blue) [![Maintainability](https://api.codeclimate.com/v1/badges/223e4185a3e13f1ef5d0/maintainability)](https://codeclimate.com/github/SocialGouv/mano/maintainability) Code source de [Mano](https://mano-app.fabrique.social.gouv.fr/), organisé en plusieurs services : diff --git a/api/package.json b/api/package.json index a98514011..393ac79d3 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "api_mano", "version": "1.283.8", - "mobileAppVersion": "2.36.2", + "mobileAppVersion": "2.37.0", "description": "", "main": "index.js", "scripts": { diff --git a/api/src/controllers/migration.js b/api/src/controllers/migration.js index 0106ff198..0bba2014c 100644 --- a/api/src/controllers/migration.js +++ b/api/src/controllers/migration.js @@ -344,6 +344,29 @@ router.put( } } + if (req.params.migrationName === "integrate-comments-in-actions-history") { + try { + z.array(z.string().regex(looseUuidRegex)).parse(req.body.commentIdsToDelete); + z.array( + z.object({ + _id: z.string().regex(looseUuidRegex), + encrypted: z.string(), + encryptedEntityKey: z.string(), + }) + ).parse(req.body.actionsToUpdate); + } catch (e) { + const error = new Error(`Invalid request in reports-from-real-date-to-date-id migration: ${e}`); + error.status = 400; + throw error; + } + for (const _id of req.body.commentIdsToDelete) { + await Comment.destroy({ where: { _id, organisation: req.user.organisation }, transaction: tx }); + } + for (const { _id, encrypted, encryptedEntityKey } of req.body.actionsToUpdate) { + await Action.update({ encrypted, encryptedEntityKey }, { where: { _id }, transaction: tx, paranoid: false }); + } + } + organisation.set({ migrations: [...(organisation.migrations || []), req.params.migrationName], migrating: false, diff --git a/api/src/middleware/versionCheck.js b/api/src/middleware/versionCheck.js index 5625edbed..3720b0e27 100644 --- a/api/src/middleware/versionCheck.js +++ b/api/src/middleware/versionCheck.js @@ -1,6 +1,6 @@ const { VERSION, MINIMUM_DASHBOARD_VERSION } = require("../config"); -const MINIMUM_MOBILE_APP_VERSION = [2, 35, 0]; +const MINIMUM_MOBILE_APP_VERSION = [2, 37, 0]; module.exports = ({ headers: { version, platform } }, res, next) => { if (platform === "website") return next(); @@ -28,8 +28,7 @@ module.exports = ({ headers: { version, platform } }, res, next) => { inAppMessage: [ `Veuillez mettre à jour votre application\u00A0!`, `Les fonctionnalités de cette nouvelle version sont\u00A0: -- Compatibilité avec les consultations par équipe -- Possibilité de rajouter des commentaires dans les consultations, les traitements et les dossiers médicaux`, +- Compatibilité de l 'historique des actions, consultations, traitements et dossier médical (seulement consultable sur navigateur)`, [{ text: "Télécharger la dernière version", link: `https://mano-app.fabrique.social.gouv.fr/download?ts=${Date.now()}` }], ], }); diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index bf4eb3c5b..52bcf5e20 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -120,8 +120,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled true - versionCode 16 - versionName "2.36.2" + versionCode 17 + versionName "2.37.0" testBuildType System.getProperty('testBuildType', 'debug') // This will later be used to control the test apk build type testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } diff --git a/app/app.json b/app/app.json index dd9d604fb..24b4efcc6 100644 --- a/app/app.json +++ b/app/app.json @@ -2,8 +2,8 @@ "name": "mano", "displayName": "Mano", "version": { - "buildNumber": 16, - "buildName": "2.36.2" + "buildNumber": 17, + "buildName": "2.37.0" }, "bundle": { "android": "com.mano", diff --git a/app/package.json b/app/package.json index c04e68d53..d06f8a2c6 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "mano", - "version": "2.36.2", + "version": "2.37.0", "private": true, "scripts": { "get-ip": "./get-ip.sh", diff --git a/app/src/recoil/actions.js b/app/src/recoil/actions.js index d9381d19d..c8bd50f14 100644 --- a/app/src/recoil/actions.js +++ b/app/src/recoil/actions.js @@ -44,6 +44,19 @@ const encryptedFields = [ 'history', ]; +export const allowedActionFieldsInHistory = [ + { name: 'categories', label: 'Catégorie(s)' }, + { name: 'person', label: 'Personne suivie' }, + { name: 'group', label: 'Action familiale' }, + { name: 'name', label: "Nom de l'action" }, + { name: 'description', label: 'Description' }, + { name: 'teams', label: 'Équipe(s) en charge' }, + { name: 'urgent', label: 'Action urgente' }, + { name: 'completedAt', label: 'Faite le' }, + { name: 'dueAt', label: 'À faire le' }, + { name: 'status', label: 'Status' }, +]; + export const prepareActionForEncryption = (action) => { try { if (!looseUuidRegex.test(action.person)) { diff --git a/app/src/recoil/consultations.js b/app/src/recoil/consultations.js index 4f19f8c63..84a77115e 100644 --- a/app/src/recoil/consultations.js +++ b/app/src/recoil/consultations.js @@ -1,7 +1,8 @@ -import { atom } from 'recoil'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils/regex'; import { capture } from '../services/sentry'; import { Alert } from 'react-native'; +import { organisationState } from './auth'; export const consultationsState = atom({ key: 'consultationsState', @@ -10,6 +11,53 @@ export const consultationsState = atom({ export const encryptedFields = ['name', 'type', 'person', 'user', 'teams', 'documents', 'comments', 'history']; +export const consultationFieldsSelector = selector({ + key: 'consultationFieldsSelector', + get: ({ get }) => { + const organisation = get(organisationState); + return organisation?.consultations || []; + }, +}); + +export const flattenedCustomFieldsConsultationsSelector = selector({ + key: 'flattenedCustomFieldsConsultationsSelector', + get: ({ get }) => { + const customFieldsConsultationsSections = get(consultationFieldsSelector); + const customFieldsConsultations = []; + for (const section of customFieldsConsultationsSections) { + for (const field of section.fields) { + customFieldsConsultations.push(field); + } + } + return customFieldsConsultations; + }, +}); + +/* Other utils selector */ + +export const consultationsFieldsIncludingCustomFieldsSelector = selector({ + key: 'consultationsFieldsIncludingCustomFieldsSelector', + get: ({ get }) => { + const flattenedCustomFieldsConsultations = get(flattenedCustomFieldsConsultationsSelector); + return [ + { name: 'name', label: 'Nom' }, + { name: 'type', label: 'Type' }, + { name: 'onlyVisibleBy', label: 'Seulement visible par moi' }, + { name: 'person', label: 'Personne suivie' }, + { name: 'teams', label: ':Equipe(s) en charge' }, + { name: 'completedAt', label: 'Faite le' }, + { name: 'dueAt', label: 'À faire le' }, + { name: 'status', label: 'Status' }, + ...flattenedCustomFieldsConsultations.map((f) => { + return { + name: f.name, + label: f.label, + }; + }), + ]; + }, +}); + export const prepareConsultationForEncryption = (customFieldsConsultations) => (consultation) => { try { if (!looseUuidRegex.test(consultation.person)) { diff --git a/app/src/recoil/treatments.js b/app/src/recoil/treatments.js index 3507fede9..8fdadd289 100644 --- a/app/src/recoil/treatments.js +++ b/app/src/recoil/treatments.js @@ -10,6 +10,16 @@ export const treatmentsState = atom({ const encryptedFields = ['person', 'user', 'startDate', 'endDate', 'name', 'dosage', 'frequency', 'indication', 'documents', 'comments', 'history']; +export const allowedTreatmentFieldsInHistory = [ + { name: 'person', label: 'Personne suivie' }, + { name: 'name', label: "Nom de l'action" }, + { name: 'startDate', label: 'Faite le' }, + { name: 'endDate', label: 'Faite le' }, + { name: 'dosage', label: 'Faite le' }, + { name: 'frequency', label: 'Faite le' }, + { name: 'indication', label: 'Faite le' }, +]; + export const prepareTreatmentForEncryption = (treatment) => { try { if (!looseUuidRegex.test(treatment.person)) { diff --git a/app/src/scenes/Actions/Action.js b/app/src/scenes/Actions/Action.js index 9df21c7f1..de3a0a556 100644 --- a/app/src/scenes/Actions/Action.js +++ b/app/src/scenes/Actions/Action.js @@ -21,7 +21,7 @@ import ActionCategoriesModalSelect from '../../components/ActionCategoriesModalS import Label from '../../components/Label'; import Tags from '../../components/Tags'; import { MyText } from '../../components/MyText'; -import { actionsState, DONE, CANCEL, TODO, prepareActionForEncryption, mappedIdsToLabels } from '../../recoil/actions'; +import { actionsState, DONE, CANCEL, TODO, prepareActionForEncryption, allowedActionFieldsInHistory } from '../../recoil/actions'; import { useRecoilState, useRecoilValue } from 'recoil'; import { commentsState, prepareCommentForEncryption } from '../../recoil/comments'; import API from '../../services/api'; @@ -205,6 +205,18 @@ const Action = ({ navigation, route }) => { } } delete action.team; + + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in action) { + if (!allowedActionFieldsInHistory.map((field) => field.name).includes(key)) continue; + if (action[key] !== oldAction[key]) historyEntry.data[key] = { oldValue: oldAction[key], newValue: action[key] }; + } + if (!!Object.keys(historyEntry.data).length) action.history = [...(action.history || []), historyEntry]; + const response = await API.put({ path: `/action/${oldAction._id}`, body: prepareActionForEncryption(action), @@ -218,17 +230,6 @@ const Action = ({ navigation, route }) => { }) ); if (!!newAction.completedAt) await createReportAtDateIfNotExist(newAction.completedAt); - if (!statusChanged) return response; - const comment = { - comment: `${user.name} a changé le status de l'action: ${mappedIdsToLabels.find((status) => status._id === newAction.status)?.name}`, - action: actionDB?._id, - team: currentTeam._id, - user: user._id, - organisation: organisation._id, - date: new Date().toISOString(), - }; - const commentResponse = await API.post({ path: '/comment', body: prepareCommentForEncryption(comment) }); - if (commentResponse.ok) setComments((comments) => [commentResponse.decryptedData, ...comments]); return response; } catch (error) { capture(error, { extra: { message: 'error in updating action', action } }); @@ -313,7 +314,7 @@ const Action = ({ navigation, route }) => { setActions((actions) => [response.decryptedData, ...actions]); await createReportAtDateIfNotExist(response.decryptedData.createdAt); - for (let c of comments.filter((c) => c.action === actionDB._id).filter((c) => !c.comment.includes('a changé le status'))) { + for (let c of comments.filter((c) => c.action === actionDB._id)) { const body = { comment: c.comment, action: response.decryptedData._id, diff --git a/app/src/scenes/Persons/Consultation.js b/app/src/scenes/Persons/Consultation.js index 8b7cebf1c..317e286a0 100644 --- a/app/src/scenes/Persons/Consultation.js +++ b/app/src/scenes/Persons/Consultation.js @@ -14,7 +14,12 @@ import DocumentsManager from '../../components/DocumentsManager'; import Spacer from '../../components/Spacer'; import Label from '../../components/Label'; import ActionStatusSelect from '../../components/Selects/ActionStatusSelect'; -import { consultationsState, encryptedFields, prepareConsultationForEncryption } from '../../recoil/consultations'; +import { + consultationsFieldsIncludingCustomFieldsSelector, + consultationsState, + encryptedFields, + prepareConsultationForEncryption, +} from '../../recoil/consultations'; import ConsultationTypeSelect from '../../components/Selects/ConsultationTypeSelect'; import CustomFieldInput from '../../components/CustomFieldInput'; import { currentTeamState, organisationState, userState } from '../../recoil/auth'; @@ -40,6 +45,7 @@ const Consultation = ({ navigation, route }) => { const user = useRecoilValue(userState); const currentTeam = useRecoilValue(currentTeamState); const person = route?.params?.personDB || route?.params?.person; + const consultationsFieldsIncludingCustomFields = useRecoilValue(consultationsFieldsIncludingCustomFieldsSelector); const consultationDB = useMemo(() => { if (route?.params?.consultationDB?._id) { @@ -137,12 +143,28 @@ const Consultation = ({ navigation, route }) => { } else { consultationToSave.completedAt = null; } + + if (!isNew) { + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in consultationToSave) { + if (!consultationsFieldsIncludingCustomFields.map((field) => field.name).includes(key)) continue; + if (consultationToSave[key] !== consultationDB[key]) { + historyEntry.data[key] = { oldValue: consultationDB[key], newValue: consultationToSave[key] }; + } + } + if (!!Object.keys(historyEntry.data).length) consultationToSave.history = [...(consultationDB.history || []), historyEntry]; + } + const body = prepareConsultationForEncryption(organisation.consultations)({ ...consultationToSave, teams: isNew ? [currentTeam._id] : consultationToSave.teams, _id: consultationDB?._id, }); - console.log('body', body); + const consultationResponse = isNew ? await API.post({ path: '/consultation', body }) : await API.put({ path: `/consultation/${consultationDB._id}`, body }); diff --git a/app/src/scenes/Persons/MedicalFile.js b/app/src/scenes/Persons/MedicalFile.js index 62f32cab5..6df333827 100644 --- a/app/src/scenes/Persons/MedicalFile.js +++ b/app/src/scenes/Persons/MedicalFile.js @@ -190,6 +190,20 @@ const MedicalFile = ({ navigation, person, personDB, onUpdatePerson, updating, e const onUpdateRequest = async (latestMedicalFile) => { if (!latestMedicalFile) latestMedicalFile = medicalFile; + + const historyEntry = { + date: new Date(), + user: user._id, + data: {}, + }; + for (const key in latestMedicalFile) { + if (!customFieldsMedicalFile.map((field) => field.name).includes(key)) continue; + if (latestMedicalFile[key] !== medicalFileDB[key]) { + historyEntry.data[key] = { oldValue: medicalFileDB[key], newValue: latestMedicalFile[key] }; + } + } + if (!!Object.keys(historyEntry.data).length) latestMedicalFile.history = [...(medicalFileDB.history || []), historyEntry]; + const response = await API.put({ path: `/medical-file/${medicalFileDB._id}`, body: prepareMedicalFileForEncryption(customFieldsMedicalFile)({ ...medicalFileDB, ...latestMedicalFile }), @@ -309,7 +323,7 @@ const MedicalFile = ({ navigation, person, personDB, onUpdatePerson, updating, e