mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat: describe managed sharing activity
Every action a manage-level route files now renders as its own sentence in the owner's activity view, in all six locales. A write delegate can only add, so the generic "made a change" was at least the right shape of fact; a manager changes and removes entries the owner made themselves, and the generic line withholds the only part that matters — whether a reading was corrected or deleted, whether a dose history was purged. The inventory reads the routes and calls the map rather than matching `case` in its source, so an action that falls through to the fallback fails, and it follows each resolved key into all six bundles. Every sentence interpolates the actor's name and nothing else: the audit table is not encrypted and a value in a verb line would be a health figure in a second store. The revoke dialog's retention disclosure no longer depends on which of two requests answers first. `retentionDays` rides the grant list, so a client holding a row to revoke holds the window it must state, and the branch that omitted the sentence is gone.
This commit is contained in:
1 parent
25b1b0eab6
commit
c92a80ee4c
15 files changed
+1131
-61
No files matched your search
@@ -37000,9 +37000,18 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AccountGrant"
|
||||
retentionDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: How long the record's activity feed can still attribute an entry to the person who made it, in days.
|
||||
Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a
|
||||
constant. It rides this payload so a client ending an access can state the window in the same breath,
|
||||
without a second request whose timing would decide whether the owner was told.
|
||||
required:
|
||||
- given
|
||||
- received
|
||||
- retentionDays
|
||||
additionalProperties: false
|
||||
description: "Both directions at once: `given` are the grants this account has offered on its own record, `received` are
|
||||
the ones offered to it. Capped at 100 rows per direction, newest first."
|
||||
|
||||
+57
-2
@@ -4461,7 +4461,9 @@
|
||||
"dateMdy": "Monat / Tag / Jahr",
|
||||
"dateYmd": "Jahr / Monat / Tag"
|
||||
},
|
||||
"modules": { "title": "Module" },
|
||||
"modules": {
|
||||
"title": "Module"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"moodReminder": "Stimmungserinnerung aktivieren",
|
||||
@@ -9652,7 +9654,60 @@
|
||||
"medicationSideEffectCreate": "{name} hat eine Nebenwirkung notiert",
|
||||
"medicationCreate": "{name} hat ein Medikament angelegt",
|
||||
"medicationIntake": "{name} hat eine Dosis eingetragen",
|
||||
"medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen"
|
||||
"medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen",
|
||||
"measurementUpdate": "{name} hat einen Messwert geändert",
|
||||
"measurementDelete": "{name} hat einen Messwert gelöscht",
|
||||
"measurementDeleteBulk": "{name} hat mehrere Messwerte auf einmal gelöscht",
|
||||
"measurementRestore": "{name} hat einen Messwert wiederhergestellt",
|
||||
"measurementReminderCreate": "{name} hat eine Messerinnerung angelegt",
|
||||
"measurementReminderUpdate": "{name} hat eine Messerinnerung geändert",
|
||||
"measurementReminderDelete": "{name} hat eine Messerinnerung gelöscht",
|
||||
"measurementReminderComplete": "{name} hat eine Messerinnerung erledigt",
|
||||
"measurementReminderSatisfy": "{name} hat eine Messerinnerung abgehakt",
|
||||
"labResultUpdate": "{name} hat einen Laborwert geändert",
|
||||
"labResultDelete": "{name} hat einen Laborwert gelöscht",
|
||||
"labResultRestore": "{name} hat einen Laborwert wiederhergestellt",
|
||||
"biomarkerUpdate": "{name} hat einen Biomarker geändert",
|
||||
"biomarkerDelete": "{name} hat einen Biomarker gelöscht",
|
||||
"allergyUpdate": "{name} hat eine Allergie geändert",
|
||||
"allergyDelete": "{name} hat eine Allergie gelöscht",
|
||||
"familyHistoryUpdate": "{name} hat einen Eintrag zur Familienanamnese geändert",
|
||||
"familyHistoryDelete": "{name} hat einen Eintrag zur Familienanamnese gelöscht",
|
||||
"illnessEpisodeUpdate": "{name} hat einen Krankheitseintrag geändert",
|
||||
"illnessEpisodeDelete": "{name} hat einen Krankheitseintrag gelöscht",
|
||||
"illnessEpisodeResolve": "{name} hat eine Erkrankung als beendet markiert",
|
||||
"illnessEpisodeRestore": "{name} hat einen Krankheitseintrag wiederhergestellt",
|
||||
"illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst",
|
||||
"medicationUpdate": "{name} hat ein Medikament geändert",
|
||||
"medicationDelete": "{name} hat ein Medikament gelöscht",
|
||||
"medicationGlp1Update": "{name} hat eine GLP-1-Therapie geändert",
|
||||
"medicationScheduleCreate": "{name} hat einen Einnahmeplan angelegt",
|
||||
"medicationScheduleUpdate": "{name} hat einen Einnahmeplan geändert",
|
||||
"medicationScheduleDelete": "{name} hat einen Einnahmeplan gelöscht",
|
||||
"medicationInventoryCreate": "{name} hat eine Packung angelegt",
|
||||
"medicationInventoryUpdate": "{name} hat eine Packung geändert",
|
||||
"medicationInventoryDelete": "{name} hat eine Packung gelöscht",
|
||||
"medicationSideEffectDelete": "{name} hat eine Nebenwirkung gelöscht",
|
||||
"medicationIntakeUpdate": "{name} hat eine Einnahme geändert",
|
||||
"medicationIntakeDelete": "{name} hat eine Einnahme gelöscht",
|
||||
"medicationIntakeBulkDelete": "{name} hat mehrere Einnahmen auf einmal gelöscht",
|
||||
"medicationIntakePurge": "{name} hat die gesamte Einnahmehistorie eines Medikaments gelöscht",
|
||||
"medicationIntakeImport": "{name} hat einen Einnahme-Import für ein Medikament gestartet",
|
||||
"medicationIntakeHistoryImport": "{name} hat einen Import der Einnahmehistorie gestartet",
|
||||
"moodEntryCreate": "{name} hat einen Stimmungseintrag angelegt",
|
||||
"moodEntryUpdate": "{name} hat einen Stimmungseintrag geändert",
|
||||
"moodEntryDelete": "{name} hat einen Stimmungseintrag gelöscht",
|
||||
"moodDeleteBulk": "{name} hat mehrere Stimmungseinträge auf einmal gelöscht",
|
||||
"moodRestore": "{name} hat einen Stimmungseintrag wiederhergestellt",
|
||||
"mentalHealthCreate": "{name} hat ein Fragebogenergebnis erfasst",
|
||||
"cycleDelete": "{name} hat einen Zyklus gelöscht",
|
||||
"cycleDayLogUpsert": "{name} hat einen Zyklustag erfasst",
|
||||
"cycleDayLogUpdate": "{name} hat einen Zyklustag geändert",
|
||||
"cycleDayLogDelete": "{name} hat einen Zyklustag gelöscht",
|
||||
"cyclePeriodBoundary": "{name} hat den Beginn oder das Ende einer Periode geändert",
|
||||
"cycleSymptomCreate": "{name} hat ein Zyklussymptom angelegt",
|
||||
"nutrientWaterWrite": "{name} hat die Trinkmenge erfasst",
|
||||
"healthRecordExport": "{name} hat die Akte exportiert"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+57
-2
@@ -4461,7 +4461,9 @@
|
||||
"dateMdy": "Month / day / year",
|
||||
"dateYmd": "Year / month / day"
|
||||
},
|
||||
"modules": { "title": "Modules" },
|
||||
"modules": {
|
||||
"title": "Modules"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"moodReminder": "Enable mood reminder",
|
||||
@@ -9652,7 +9654,60 @@
|
||||
"medicationSideEffectCreate": "{name} noted a side effect",
|
||||
"medicationCreate": "{name} added a medication",
|
||||
"medicationIntake": "{name} marked a dose",
|
||||
"medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused"
|
||||
"medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused",
|
||||
"measurementUpdate": "{name} changed a reading",
|
||||
"measurementDelete": "{name} removed a reading",
|
||||
"measurementDeleteBulk": "{name} removed several readings at once",
|
||||
"measurementRestore": "{name} restored a reading",
|
||||
"measurementReminderCreate": "{name} added a measurement reminder",
|
||||
"measurementReminderUpdate": "{name} changed a measurement reminder",
|
||||
"measurementReminderDelete": "{name} removed a measurement reminder",
|
||||
"measurementReminderComplete": "{name} completed a measurement reminder",
|
||||
"measurementReminderSatisfy": "{name} cleared a measurement reminder",
|
||||
"labResultUpdate": "{name} changed a lab result",
|
||||
"labResultDelete": "{name} removed a lab result",
|
||||
"labResultRestore": "{name} restored a lab result",
|
||||
"biomarkerUpdate": "{name} changed a biomarker",
|
||||
"biomarkerDelete": "{name} removed a biomarker",
|
||||
"allergyUpdate": "{name} changed an allergy",
|
||||
"allergyDelete": "{name} removed an allergy",
|
||||
"familyHistoryUpdate": "{name} changed a family history entry",
|
||||
"familyHistoryDelete": "{name} removed a family history entry",
|
||||
"illnessEpisodeUpdate": "{name} changed an illness entry",
|
||||
"illnessEpisodeDelete": "{name} removed an illness entry",
|
||||
"illnessEpisodeResolve": "{name} marked an illness as over",
|
||||
"illnessEpisodeRestore": "{name} restored an illness entry",
|
||||
"illnessDayLogUpsert": "{name} recorded an illness day",
|
||||
"medicationUpdate": "{name} changed a medication",
|
||||
"medicationDelete": "{name} removed a medication",
|
||||
"medicationGlp1Update": "{name} changed a GLP-1 course",
|
||||
"medicationScheduleCreate": "{name} added a dosing schedule",
|
||||
"medicationScheduleUpdate": "{name} changed a dosing schedule",
|
||||
"medicationScheduleDelete": "{name} removed a dosing schedule",
|
||||
"medicationInventoryCreate": "{name} added a medication package",
|
||||
"medicationInventoryUpdate": "{name} changed a medication package",
|
||||
"medicationInventoryDelete": "{name} removed a medication package",
|
||||
"medicationSideEffectDelete": "{name} removed a side effect",
|
||||
"medicationIntakeUpdate": "{name} changed a dose",
|
||||
"medicationIntakeDelete": "{name} removed a dose",
|
||||
"medicationIntakeBulkDelete": "{name} removed several doses at once",
|
||||
"medicationIntakePurge": "{name} deleted the whole dose history of a medication",
|
||||
"medicationIntakeImport": "{name} started a dose import for one medication",
|
||||
"medicationIntakeHistoryImport": "{name} started a dose history import",
|
||||
"moodEntryCreate": "{name} added a mood entry",
|
||||
"moodEntryUpdate": "{name} changed a mood entry",
|
||||
"moodEntryDelete": "{name} removed a mood entry",
|
||||
"moodDeleteBulk": "{name} removed several mood entries at once",
|
||||
"moodRestore": "{name} restored a mood entry",
|
||||
"mentalHealthCreate": "{name} recorded a questionnaire result",
|
||||
"cycleDelete": "{name} removed a cycle",
|
||||
"cycleDayLogUpsert": "{name} recorded a cycle day",
|
||||
"cycleDayLogUpdate": "{name} changed a cycle day",
|
||||
"cycleDayLogDelete": "{name} removed a cycle day",
|
||||
"cyclePeriodBoundary": "{name} changed the start or end of a period",
|
||||
"cycleSymptomCreate": "{name} added a cycle symptom",
|
||||
"nutrientWaterWrite": "{name} recorded water intake",
|
||||
"healthRecordExport": "{name} exported the record"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+57
-2
@@ -4461,7 +4461,9 @@
|
||||
"dateMdy": "Mes / día / año",
|
||||
"dateYmd": "Año / mes / día"
|
||||
},
|
||||
"modules": { "title": "Módulos" },
|
||||
"modules": {
|
||||
"title": "Módulos"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"moodReminder": "Activar recordatorio de ánimo",
|
||||
@@ -9652,7 +9654,60 @@
|
||||
"medicationSideEffectCreate": "{name} anotó un efecto secundario",
|
||||
"medicationCreate": "{name} añadió un medicamento",
|
||||
"medicationIntake": "{name} registró una dosis",
|
||||
"medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado"
|
||||
"medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado",
|
||||
"measurementUpdate": "{name} cambió una medición",
|
||||
"measurementDelete": "{name} eliminó una medición",
|
||||
"measurementDeleteBulk": "{name} eliminó varias mediciones a la vez",
|
||||
"measurementRestore": "{name} restauró una medición",
|
||||
"measurementReminderCreate": "{name} añadió un recordatorio de medición",
|
||||
"measurementReminderUpdate": "{name} cambió un recordatorio de medición",
|
||||
"measurementReminderDelete": "{name} eliminó un recordatorio de medición",
|
||||
"measurementReminderComplete": "{name} completó un recordatorio de medición",
|
||||
"measurementReminderSatisfy": "{name} cerró un recordatorio de medición",
|
||||
"labResultUpdate": "{name} cambió un resultado de laboratorio",
|
||||
"labResultDelete": "{name} eliminó un resultado de laboratorio",
|
||||
"labResultRestore": "{name} restauró un resultado de laboratorio",
|
||||
"biomarkerUpdate": "{name} cambió un biomarcador",
|
||||
"biomarkerDelete": "{name} eliminó un biomarcador",
|
||||
"allergyUpdate": "{name} cambió una alergia",
|
||||
"allergyDelete": "{name} eliminó una alergia",
|
||||
"familyHistoryUpdate": "{name} cambió un antecedente familiar",
|
||||
"familyHistoryDelete": "{name} eliminó un antecedente familiar",
|
||||
"illnessEpisodeUpdate": "{name} cambió una enfermedad",
|
||||
"illnessEpisodeDelete": "{name} eliminó una enfermedad",
|
||||
"illnessEpisodeResolve": "{name} marcó una enfermedad como terminada",
|
||||
"illnessEpisodeRestore": "{name} restauró una enfermedad",
|
||||
"illnessDayLogUpsert": "{name} registró un día de enfermedad",
|
||||
"medicationUpdate": "{name} cambió un medicamento",
|
||||
"medicationDelete": "{name} eliminó un medicamento",
|
||||
"medicationGlp1Update": "{name} cambió un tratamiento con GLP-1",
|
||||
"medicationScheduleCreate": "{name} añadió una pauta de dosificación",
|
||||
"medicationScheduleUpdate": "{name} cambió una pauta de dosificación",
|
||||
"medicationScheduleDelete": "{name} eliminó una pauta de dosificación",
|
||||
"medicationInventoryCreate": "{name} añadió un envase de medicamento",
|
||||
"medicationInventoryUpdate": "{name} cambió un envase de medicamento",
|
||||
"medicationInventoryDelete": "{name} eliminó un envase de medicamento",
|
||||
"medicationSideEffectDelete": "{name} eliminó un efecto secundario",
|
||||
"medicationIntakeUpdate": "{name} cambió una dosis",
|
||||
"medicationIntakeDelete": "{name} eliminó una dosis",
|
||||
"medicationIntakeBulkDelete": "{name} eliminó varias dosis a la vez",
|
||||
"medicationIntakePurge": "{name} borró todo el historial de dosis de un medicamento",
|
||||
"medicationIntakeImport": "{name} inició una importación de dosis de un medicamento",
|
||||
"medicationIntakeHistoryImport": "{name} inició una importación del historial de dosis",
|
||||
"moodEntryCreate": "{name} añadió una entrada de ánimo",
|
||||
"moodEntryUpdate": "{name} cambió una entrada de ánimo",
|
||||
"moodEntryDelete": "{name} eliminó una entrada de ánimo",
|
||||
"moodDeleteBulk": "{name} eliminó varias entradas de ánimo a la vez",
|
||||
"moodRestore": "{name} restauró una entrada de ánimo",
|
||||
"mentalHealthCreate": "{name} registró el resultado de un cuestionario",
|
||||
"cycleDelete": "{name} eliminó un ciclo",
|
||||
"cycleDayLogUpsert": "{name} registró un día del ciclo",
|
||||
"cycleDayLogUpdate": "{name} cambió un día del ciclo",
|
||||
"cycleDayLogDelete": "{name} eliminó un día del ciclo",
|
||||
"cyclePeriodBoundary": "{name} cambió el inicio o el final de una regla",
|
||||
"cycleSymptomCreate": "{name} añadió un síntoma del ciclo",
|
||||
"nutrientWaterWrite": "{name} registró la ingesta de agua",
|
||||
"healthRecordExport": "{name} exportó el historial"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+57
-2
@@ -4461,7 +4461,9 @@
|
||||
"dateMdy": "Mois / jour / année",
|
||||
"dateYmd": "Année / mois / jour"
|
||||
},
|
||||
"modules": { "title": "Modules" },
|
||||
"modules": {
|
||||
"title": "Modules"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"moodReminder": "Activer le rappel d’humeur",
|
||||
@@ -9652,7 +9654,60 @@
|
||||
"medicationSideEffectCreate": "{name} a noté un effet indésirable",
|
||||
"medicationCreate": "{name} a ajouté un médicament",
|
||||
"medicationIntake": "{name} a enregistré une prise",
|
||||
"medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé"
|
||||
"medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé",
|
||||
"measurementUpdate": "{name} a modifié une mesure",
|
||||
"measurementDelete": "{name} a supprimé une mesure",
|
||||
"measurementDeleteBulk": "{name} a supprimé plusieurs mesures d'un coup",
|
||||
"measurementRestore": "{name} a restauré une mesure",
|
||||
"measurementReminderCreate": "{name} a ajouté un rappel de mesure",
|
||||
"measurementReminderUpdate": "{name} a modifié un rappel de mesure",
|
||||
"measurementReminderDelete": "{name} a supprimé un rappel de mesure",
|
||||
"measurementReminderComplete": "{name} a terminé un rappel de mesure",
|
||||
"measurementReminderSatisfy": "{name} a clôturé un rappel de mesure",
|
||||
"labResultUpdate": "{name} a modifié un résultat de laboratoire",
|
||||
"labResultDelete": "{name} a supprimé un résultat de laboratoire",
|
||||
"labResultRestore": "{name} a restauré un résultat de laboratoire",
|
||||
"biomarkerUpdate": "{name} a modifié un biomarqueur",
|
||||
"biomarkerDelete": "{name} a supprimé un biomarqueur",
|
||||
"allergyUpdate": "{name} a modifié une allergie",
|
||||
"allergyDelete": "{name} a supprimé une allergie",
|
||||
"familyHistoryUpdate": "{name} a modifié un antécédent familial",
|
||||
"familyHistoryDelete": "{name} a supprimé un antécédent familial",
|
||||
"illnessEpisodeUpdate": "{name} a modifié une maladie",
|
||||
"illnessEpisodeDelete": "{name} a supprimé une maladie",
|
||||
"illnessEpisodeResolve": "{name} a marqué une maladie comme terminée",
|
||||
"illnessEpisodeRestore": "{name} a restauré une maladie",
|
||||
"illnessDayLogUpsert": "{name} a enregistré une journée de maladie",
|
||||
"medicationUpdate": "{name} a modifié un médicament",
|
||||
"medicationDelete": "{name} a supprimé un médicament",
|
||||
"medicationGlp1Update": "{name} a modifié un traitement GLP-1",
|
||||
"medicationScheduleCreate": "{name} a ajouté un schéma de prise",
|
||||
"medicationScheduleUpdate": "{name} a modifié un schéma de prise",
|
||||
"medicationScheduleDelete": "{name} a supprimé un schéma de prise",
|
||||
"medicationInventoryCreate": "{name} a ajouté une boîte de médicament",
|
||||
"medicationInventoryUpdate": "{name} a modifié une boîte de médicament",
|
||||
"medicationInventoryDelete": "{name} a supprimé une boîte de médicament",
|
||||
"medicationSideEffectDelete": "{name} a supprimé un effet indésirable",
|
||||
"medicationIntakeUpdate": "{name} a modifié une prise",
|
||||
"medicationIntakeDelete": "{name} a supprimé une prise",
|
||||
"medicationIntakeBulkDelete": "{name} a supprimé plusieurs prises d'un coup",
|
||||
"medicationIntakePurge": "{name} a effacé tout l'historique des prises d'un médicament",
|
||||
"medicationIntakeImport": "{name} a lancé un import des prises d'un médicament",
|
||||
"medicationIntakeHistoryImport": "{name} a lancé un import de l'historique des prises",
|
||||
"moodEntryCreate": "{name} a ajouté une entrée d'humeur",
|
||||
"moodEntryUpdate": "{name} a modifié une entrée d'humeur",
|
||||
"moodEntryDelete": "{name} a supprimé une entrée d'humeur",
|
||||
"moodDeleteBulk": "{name} a supprimé plusieurs entrées d'humeur d'un coup",
|
||||
"moodRestore": "{name} a restauré une entrée d'humeur",
|
||||
"mentalHealthCreate": "{name} a enregistré le résultat d'un questionnaire",
|
||||
"cycleDelete": "{name} a supprimé un cycle",
|
||||
"cycleDayLogUpsert": "{name} a enregistré un jour du cycle",
|
||||
"cycleDayLogUpdate": "{name} a modifié un jour du cycle",
|
||||
"cycleDayLogDelete": "{name} a supprimé un jour du cycle",
|
||||
"cyclePeriodBoundary": "{name} a modifié le début ou la fin de règles",
|
||||
"cycleSymptomCreate": "{name} a ajouté un symptôme de cycle",
|
||||
"nutrientWaterWrite": "{name} a enregistré la consommation d'eau",
|
||||
"healthRecordExport": "{name} a exporté le dossier"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+57
-2
@@ -4461,7 +4461,9 @@
|
||||
"dateMdy": "Mese / giorno / anno",
|
||||
"dateYmd": "Anno / mese / giorno"
|
||||
},
|
||||
"modules": { "title": "Moduli" },
|
||||
"modules": {
|
||||
"title": "Moduli"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"moodReminder": "Attiva promemoria dell’umore",
|
||||
@@ -9652,7 +9654,60 @@
|
||||
"medicationSideEffectCreate": "{name} ha annotato un effetto collaterale",
|
||||
"medicationCreate": "{name} ha aggiunto un farmaco",
|
||||
"medicationIntake": "{name} ha registrato una dose",
|
||||
"medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato"
|
||||
"medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato",
|
||||
"measurementUpdate": "{name} ha modificato una misurazione",
|
||||
"measurementDelete": "{name} ha eliminato una misurazione",
|
||||
"measurementDeleteBulk": "{name} ha eliminato più misurazioni in una volta",
|
||||
"measurementRestore": "{name} ha ripristinato una misurazione",
|
||||
"measurementReminderCreate": "{name} ha aggiunto un promemoria di misurazione",
|
||||
"measurementReminderUpdate": "{name} ha modificato un promemoria di misurazione",
|
||||
"measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione",
|
||||
"measurementReminderComplete": "{name} ha completato un promemoria di misurazione",
|
||||
"measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione",
|
||||
"labResultUpdate": "{name} ha modificato un risultato di laboratorio",
|
||||
"labResultDelete": "{name} ha eliminato un risultato di laboratorio",
|
||||
"labResultRestore": "{name} ha ripristinato un risultato di laboratorio",
|
||||
"biomarkerUpdate": "{name} ha modificato un biomarcatore",
|
||||
"biomarkerDelete": "{name} ha eliminato un biomarcatore",
|
||||
"allergyUpdate": "{name} ha modificato un'allergia",
|
||||
"allergyDelete": "{name} ha eliminato un'allergia",
|
||||
"familyHistoryUpdate": "{name} ha modificato una voce di anamnesi familiare",
|
||||
"familyHistoryDelete": "{name} ha eliminato una voce di anamnesi familiare",
|
||||
"illnessEpisodeUpdate": "{name} ha modificato una malattia",
|
||||
"illnessEpisodeDelete": "{name} ha eliminato una malattia",
|
||||
"illnessEpisodeResolve": "{name} ha segnato una malattia come conclusa",
|
||||
"illnessEpisodeRestore": "{name} ha ripristinato una malattia",
|
||||
"illnessDayLogUpsert": "{name} ha registrato una giornata di malattia",
|
||||
"medicationUpdate": "{name} ha modificato un farmaco",
|
||||
"medicationDelete": "{name} ha eliminato un farmaco",
|
||||
"medicationGlp1Update": "{name} ha modificato una terapia GLP-1",
|
||||
"medicationScheduleCreate": "{name} ha aggiunto uno schema di assunzione",
|
||||
"medicationScheduleUpdate": "{name} ha modificato uno schema di assunzione",
|
||||
"medicationScheduleDelete": "{name} ha eliminato uno schema di assunzione",
|
||||
"medicationInventoryCreate": "{name} ha aggiunto una confezione di farmaco",
|
||||
"medicationInventoryUpdate": "{name} ha modificato una confezione di farmaco",
|
||||
"medicationInventoryDelete": "{name} ha eliminato una confezione di farmaco",
|
||||
"medicationSideEffectDelete": "{name} ha eliminato un effetto collaterale",
|
||||
"medicationIntakeUpdate": "{name} ha modificato una dose",
|
||||
"medicationIntakeDelete": "{name} ha eliminato una dose",
|
||||
"medicationIntakeBulkDelete": "{name} ha eliminato più dosi in una volta",
|
||||
"medicationIntakePurge": "{name} ha cancellato tutto lo storico delle dosi di un farmaco",
|
||||
"medicationIntakeImport": "{name} ha avviato un'importazione delle dosi di un farmaco",
|
||||
"medicationIntakeHistoryImport": "{name} ha avviato un'importazione dello storico delle dosi",
|
||||
"moodEntryCreate": "{name} ha aggiunto una voce dell'umore",
|
||||
"moodEntryUpdate": "{name} ha modificato una voce dell'umore",
|
||||
"moodEntryDelete": "{name} ha eliminato una voce dell'umore",
|
||||
"moodDeleteBulk": "{name} ha eliminato più voci dell'umore in una volta",
|
||||
"moodRestore": "{name} ha ripristinato una voce dell'umore",
|
||||
"mentalHealthCreate": "{name} ha registrato il risultato di un questionario",
|
||||
"cycleDelete": "{name} ha eliminato un ciclo",
|
||||
"cycleDayLogUpsert": "{name} ha registrato un giorno del ciclo",
|
||||
"cycleDayLogUpdate": "{name} ha modificato un giorno del ciclo",
|
||||
"cycleDayLogDelete": "{name} ha eliminato un giorno del ciclo",
|
||||
"cyclePeriodBoundary": "{name} ha modificato l'inizio o la fine di una mestruazione",
|
||||
"cycleSymptomCreate": "{name} ha aggiunto un sintomo del ciclo",
|
||||
"nutrientWaterWrite": "{name} ha registrato l'assunzione di acqua",
|
||||
"healthRecordExport": "{name} ha esportato la cartella"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
+57
-2
@@ -4461,7 +4461,9 @@
|
||||
"dateMdy": "Miesiąc / dzień / rok",
|
||||
"dateYmd": "Rok / miesiąc / dzień"
|
||||
},
|
||||
"modules": { "title": "Moduły" },
|
||||
"modules": {
|
||||
"title": "Moduły"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"moodReminder": "Włącz przypomnienie nastroju",
|
||||
@@ -9652,7 +9654,60 @@
|
||||
"medicationSideEffectCreate": "{name} zanotował działanie niepożądane",
|
||||
"medicationCreate": "{name} dodał lek",
|
||||
"medicationIntake": "{name} zapisał przyjęcie dawki",
|
||||
"medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)"
|
||||
"medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)",
|
||||
"measurementUpdate": "{name} zmienił pomiar",
|
||||
"measurementDelete": "{name} usunął pomiar",
|
||||
"measurementDeleteBulk": "{name} usunął kilka pomiarów naraz",
|
||||
"measurementRestore": "{name} przywrócił pomiar",
|
||||
"measurementReminderCreate": "{name} dodał przypomnienie o pomiarze",
|
||||
"measurementReminderUpdate": "{name} zmienił przypomnienie o pomiarze",
|
||||
"measurementReminderDelete": "{name} usunął przypomnienie o pomiarze",
|
||||
"measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze",
|
||||
"measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze",
|
||||
"labResultUpdate": "{name} zmienił wynik badania",
|
||||
"labResultDelete": "{name} usunął wynik badania",
|
||||
"labResultRestore": "{name} przywrócił wynik badania",
|
||||
"biomarkerUpdate": "{name} zmienił biomarker",
|
||||
"biomarkerDelete": "{name} usunął biomarker",
|
||||
"allergyUpdate": "{name} zmienił alergię",
|
||||
"allergyDelete": "{name} usunął alergię",
|
||||
"familyHistoryUpdate": "{name} zmienił wpis w wywiadzie rodzinnym",
|
||||
"familyHistoryDelete": "{name} usunął wpis w wywiadzie rodzinnym",
|
||||
"illnessEpisodeUpdate": "{name} zmienił wpis o chorobie",
|
||||
"illnessEpisodeDelete": "{name} usunął wpis o chorobie",
|
||||
"illnessEpisodeResolve": "{name} oznaczył chorobę jako zakończoną",
|
||||
"illnessEpisodeRestore": "{name} przywrócił wpis o chorobie",
|
||||
"illnessDayLogUpsert": "{name} zapisał dzień choroby",
|
||||
"medicationUpdate": "{name} zmienił lek",
|
||||
"medicationDelete": "{name} usunął lek",
|
||||
"medicationGlp1Update": "{name} zmienił terapię GLP-1",
|
||||
"medicationScheduleCreate": "{name} dodał schemat dawkowania",
|
||||
"medicationScheduleUpdate": "{name} zmienił schemat dawkowania",
|
||||
"medicationScheduleDelete": "{name} usunął schemat dawkowania",
|
||||
"medicationInventoryCreate": "{name} dodał opakowanie leku",
|
||||
"medicationInventoryUpdate": "{name} zmienił opakowanie leku",
|
||||
"medicationInventoryDelete": "{name} usunął opakowanie leku",
|
||||
"medicationSideEffectDelete": "{name} usunął działanie niepożądane",
|
||||
"medicationIntakeUpdate": "{name} zmienił przyjęcie dawki",
|
||||
"medicationIntakeDelete": "{name} usunął przyjęcie dawki",
|
||||
"medicationIntakeBulkDelete": "{name} usunął kilka przyjęć dawek naraz",
|
||||
"medicationIntakePurge": "{name} skasował całą historię przyjmowania leku",
|
||||
"medicationIntakeImport": "{name} rozpoczął import przyjęć dla jednego leku",
|
||||
"medicationIntakeHistoryImport": "{name} rozpoczął import historii przyjmowania",
|
||||
"moodEntryCreate": "{name} dodał wpis nastroju",
|
||||
"moodEntryUpdate": "{name} zmienił wpis nastroju",
|
||||
"moodEntryDelete": "{name} usunął wpis nastroju",
|
||||
"moodDeleteBulk": "{name} usunął kilka wpisów nastroju naraz",
|
||||
"moodRestore": "{name} przywrócił wpis nastroju",
|
||||
"mentalHealthCreate": "{name} zapisał wynik kwestionariusza",
|
||||
"cycleDelete": "{name} usunął cykl",
|
||||
"cycleDayLogUpsert": "{name} zapisał dzień cyklu",
|
||||
"cycleDayLogUpdate": "{name} zmienił dzień cyklu",
|
||||
"cycleDayLogDelete": "{name} usunął dzień cyklu",
|
||||
"cyclePeriodBoundary": "{name} zmienił początek lub koniec miesiączki",
|
||||
"cycleSymptomCreate": "{name} dodał objaw cyklu",
|
||||
"nutrientWaterWrite": "{name} zapisał ilość wypitej wody",
|
||||
"healthRecordExport": "{name} wyeksportował kartę"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { revokeBody } from "@/components/settings/access/grants-given-card";
|
||||
import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
|
||||
|
||||
/**
|
||||
* Every MANAGE-admitted action names itself, in all six locales.
|
||||
*
|
||||
* ## What this asks that the sibling guard does not
|
||||
*
|
||||
* `src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts` asks the
|
||||
* same question of the WRITE surface: a delegable write whose audit action has
|
||||
* no sentence renders as "made a change". MANAGE is where that answer stops
|
||||
* being merely vague and starts being wrong. A WRITE delegate can only add,
|
||||
* and "somebody added something" is at least the right shape of fact. A MANAGE
|
||||
* delegate can change and remove entries the owner made themselves, so the
|
||||
* generic line would tell an owner that something happened in their record
|
||||
* while withholding the only part they need — whether a reading was corrected
|
||||
* or deleted, whether a medication was stopped, whether the dose history was
|
||||
* purged. The level ships under "reconstructable or refused", and a feed that
|
||||
* cannot say which verb ran does not reconstruct anything.
|
||||
*
|
||||
* So the inventory here is the MANAGE half of the same sweep, and it goes two
|
||||
* steps further than its sibling:
|
||||
*
|
||||
* 1. it resolves the sentence by CALLING the map rather than by matching
|
||||
* `case "…"` in its source, because a `case` inside a comment matches and
|
||||
* an action whose arm falls through to the fallback does not;
|
||||
* 2. it follows the resolved key into all six bundles, because a key that
|
||||
* exists only in English renders as its own dotted path to everyone else.
|
||||
*
|
||||
* ## PHI
|
||||
*
|
||||
* Two legs, and both are about the same failure. The activity feed reads out
|
||||
* of the audit table, the audit table is not encrypted, and the temptation
|
||||
* whenever a sentence feels thin is to put the value in it — "{name} changed
|
||||
* your weight from 81 to 78". That is a health figure in a second store with
|
||||
* its own retention and no rotation story. So: every verb line interpolates
|
||||
* `name` and nothing else, in every locale, and the feed's own DTO carries no
|
||||
* `details` field for a future caller to reach for.
|
||||
*
|
||||
* ## Why the counts are of MATCHES
|
||||
*
|
||||
* The verdict is an empty list of unmapped actions, and an empty match set
|
||||
* says exactly the same thing. Every leg therefore pins the size of what it
|
||||
* matched — modules walked, MANAGE modules found, one of them named, actions
|
||||
* read, sentences resolved — so a matcher that quietly stopped matching fails
|
||||
* instead of passing. `describe("the matcher sees what it looks for")` proves
|
||||
* both halves against synthetic modules, including the one shape that silenced
|
||||
* the sibling guard: a resolver call prettier broke across lines.
|
||||
*/
|
||||
const API = join(process.cwd(), "src/app/api");
|
||||
const MESSAGES = join(process.cwd(), "messages");
|
||||
const ACTIVITY_ROUTE = join(
|
||||
process.cwd(),
|
||||
"src/app/api/account/activity/route.ts",
|
||||
);
|
||||
const GRANTS_GIVEN_CARD = join(
|
||||
process.cwd(),
|
||||
"src/components/settings/access/grants-given-card.tsx",
|
||||
);
|
||||
|
||||
/** The declaration a MANAGE-level route makes. */
|
||||
const RECORD_RESOLVER = "requireRecordAuth";
|
||||
/** The level this file is the inventory of. */
|
||||
const MANAGE = "manage";
|
||||
/** What an action with no sentence of its own renders as. */
|
||||
const FALLBACK_KEY = "recordSharing.activity.acted";
|
||||
/** The six bundles the product ships. */
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
|
||||
* `*` skips a leading dot and this tree has dot-directories. */
|
||||
function routeFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...routeFiles(full));
|
||||
else if (entry.name === "route.ts") out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ModuleFacts {
|
||||
/** Does the module pass `"manage"` to the record resolver anywhere. */
|
||||
managesRecord: boolean;
|
||||
/**
|
||||
* The action names of the module's AWAITED `auditLog("…")` calls.
|
||||
*
|
||||
* Awaited on purpose: a fire-and-forget breadcrumb on a validation failure
|
||||
* is not something to narrate to the owner.
|
||||
*/
|
||||
awaitedAuditActions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both facts off one parse.
|
||||
*
|
||||
* AST rather than substrings, for the reason the sibling guard learned by
|
||||
* going green while matching nothing: prettier moves a call's argument onto
|
||||
* its own line the moment the call grows a second one, and
|
||||
* `requireRecordAuth("manage"` stops appearing in the source text without
|
||||
* anything about the route changing. The import table resolves a local name
|
||||
* back to its exported one, so an alias and a namespace-qualified call both
|
||||
* count, and a comment naming either symbol counts for nothing.
|
||||
*/
|
||||
export function moduleFacts(source: string, fileName: string): ModuleFacts {
|
||||
const parsed = ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
const namespaces = new Set<string>();
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isImportDeclaration(node)) return;
|
||||
const bindings = node.importClause?.namedBindings;
|
||||
if (!bindings) return;
|
||||
if (ts.isNamespaceImport(bindings)) {
|
||||
namespaces.add(bindings.name.text);
|
||||
return;
|
||||
}
|
||||
for (const spec of bindings.elements) {
|
||||
aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text);
|
||||
}
|
||||
});
|
||||
|
||||
/** The original exported name behind this callee, or null. */
|
||||
const calleeName = (callee: ts.Expression): string | null => {
|
||||
if (ts.isIdentifier(callee)) return aliases.get(callee.text) ?? callee.text;
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
namespaces.has(callee.expression.text)
|
||||
) {
|
||||
return callee.name.text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The first argument when it is a plain string literal. */
|
||||
const firstStringArg = (node: ts.CallExpression): string | null => {
|
||||
const first = node.arguments[0];
|
||||
return first && ts.isStringLiteralLike(first) ? first.text : null;
|
||||
};
|
||||
|
||||
let managesRecord = false;
|
||||
const awaitedAuditActions: string[] = [];
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = calleeName(node.expression);
|
||||
if (name === RECORD_RESOLVER && firstStringArg(node) === MANAGE) {
|
||||
managesRecord = true;
|
||||
}
|
||||
}
|
||||
if (ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)) {
|
||||
const call = node.expression;
|
||||
if (calleeName(call.expression) === "auditLog") {
|
||||
const action = firstStringArg(call);
|
||||
if (action !== null) awaitedAuditActions.push(action);
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
parsed.forEachChild(visit);
|
||||
|
||||
return { managesRecord, awaitedAuditActions };
|
||||
}
|
||||
|
||||
/** The message key one action resolves to, or the fallback. */
|
||||
function verbKeyFor(action: string): { key: string; params: string[] } {
|
||||
let key = "";
|
||||
let params: string[] = [];
|
||||
recordActivityVerbLine(
|
||||
(resolved, given) => {
|
||||
key = resolved;
|
||||
params = Object.keys(given ?? {});
|
||||
return resolved;
|
||||
},
|
||||
action,
|
||||
"Alex",
|
||||
);
|
||||
return { key, params };
|
||||
}
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The leaf at a dotted path, or undefined. */
|
||||
function leaf(root: Record<string, unknown>, key: string): unknown {
|
||||
let node: unknown = root;
|
||||
for (const segment of key.split(".")) {
|
||||
if (typeof node !== "object" || node === null) return undefined;
|
||||
node = (node as Record<string, unknown>)[segment];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The `{placeholders}` a translated sentence interpolates. */
|
||||
function placeholders(sentence: string): string[] {
|
||||
return [...sentence.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
|
||||
}
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
const REFORMATTED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth(
|
||||
"manage",
|
||||
"labs",
|
||||
);
|
||||
await auditLog("thing.delete", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
|
||||
it("matches a resolver call broken across lines", () => {
|
||||
// The shape that silenced the sibling guard: the argument moves onto its
|
||||
// own line and a substring test goes quiet with nothing else changed.
|
||||
expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false);
|
||||
const facts = moduleFacts(REFORMATTED, "r.ts");
|
||||
expect(facts.managesRecord).toBe(true);
|
||||
expect(facts.awaitedAuditActions).toEqual(["thing.delete"]);
|
||||
});
|
||||
|
||||
it("matches an aliased and a namespace-qualified resolver call", () => {
|
||||
const ALIASED = `
|
||||
import { requireRecordAuth as resolveRecord } from "@/lib/api-handler";
|
||||
import { auditLog as record } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await resolveRecord("manage", "labs");
|
||||
await record("thing.aliased");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(ALIASED, "a.ts")).toEqual({
|
||||
managesRecord: true,
|
||||
awaitedAuditActions: ["thing.aliased"],
|
||||
});
|
||||
|
||||
const NAMESPACED = `
|
||||
import * as auth from "@/lib/api-handler";
|
||||
|
||||
export const DELETE = auth.apiHandler(async () => {
|
||||
const { user } = await auth.requireRecordAuth("manage", "labs");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(NAMESPACED, "n.ts").managesRecord).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enrol a write route that only talks about managing", () => {
|
||||
const WRITE_ONLY = `
|
||||
/** Delegable for writes. It must never take requireRecordAuth("manage"). */
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
|
||||
const LEVEL = "manage";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
return apiSuccess({ id: user.id, level: LEVEL });
|
||||
});
|
||||
`;
|
||||
// The raw text carries the exact substring a naive membership test uses.
|
||||
expect(WRITE_ONLY.includes('requireRecordAuth("manage")')).toBe(true);
|
||||
expect(moduleFacts(WRITE_ONLY, "wo.ts").managesRecord).toBe(false);
|
||||
});
|
||||
|
||||
it("reads only the awaited audit calls", () => {
|
||||
const MIXED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
void auditLog("thing.breadcrumb", {});
|
||||
await auditLog("thing.delete", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(MIXED, "m.ts").awaitedAuditActions).toEqual([
|
||||
"thing.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still reports the fallback for an action nobody mapped", () => {
|
||||
// The positive control for the recording translator below. Without it, a
|
||||
// `verbKeyFor` that always returned the same string would report every
|
||||
// action as specific and this file would prove nothing.
|
||||
expect(verbKeyFor("nothing.anybody.mapped").key).toBe(FALLBACK_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("every MANAGE action has a sentence of its own", () => {
|
||||
const routes = routeFiles(API);
|
||||
const manageModules = routes
|
||||
.map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) }))
|
||||
.filter((m) => m.managesRecord);
|
||||
const actions = [
|
||||
...new Set(manageModules.flatMap((m) => m.awaitedAuditActions)),
|
||||
].sort();
|
||||
|
||||
it("matches the MANAGE modules, and not zero of them", () => {
|
||||
// A walk that finds nothing agrees with an inventory that covers nothing.
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
expect(manageModules.length).toBeGreaterThan(0);
|
||||
// Named anchors, so a matcher that collapsed to one stray file fails
|
||||
// rather than clearing the floor. Deleting a reading and purging a dose
|
||||
// history are the two acts the level exists for and the two an owner most
|
||||
// needs named.
|
||||
const found = manageModules.map((m) => relative(process.cwd(), m.file));
|
||||
expect(found).toContain("src/app/api/measurements/[id]/route.ts");
|
||||
expect(found).toContain(
|
||||
"src/app/api/medications/[id]/intake/purge/route.ts",
|
||||
);
|
||||
// And the actions themselves were read, not merely looked for.
|
||||
expect(actions.length).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("gives every one of them a specific verb rather than the fallback", () => {
|
||||
const generic = actions.filter(
|
||||
(action) => verbKeyFor(action).key === FALLBACK_KEY,
|
||||
);
|
||||
|
||||
expect(
|
||||
generic,
|
||||
"a MANAGE action with no verb line renders to the owner as the generic " +
|
||||
"'made a change'. Add a case to activity-verb.ts and a sentence to " +
|
||||
"all six bundles.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves every verb key in all six bundles", () => {
|
||||
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
|
||||
const missing: string[] = [];
|
||||
let resolved = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
const { key } = verbKeyFor(action);
|
||||
for (const locale of LOCALES) {
|
||||
const value = leaf(bundles.get(locale)!, key);
|
||||
if (typeof value === "string" && value.length > 0) resolved += 1;
|
||||
else missing.push(`${locale}: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing.sort()).toEqual([]);
|
||||
// Six locales for every action, and the actions were counted above.
|
||||
expect(resolved).toBe(actions.length * LOCALES.length);
|
||||
});
|
||||
|
||||
it("interpolates the actor's name and nothing else, in every locale", () => {
|
||||
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
|
||||
const leaky: string[] = [];
|
||||
let checked = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
const { key, params } = verbKeyFor(action);
|
||||
// The call site's own parameters first: a sentence cannot leak a value
|
||||
// the map never hands it.
|
||||
if (params.join(",") !== "name") leaky.push(`${key} passes ${params}`);
|
||||
for (const locale of LOCALES) {
|
||||
const value = leaf(bundles.get(locale)!, key);
|
||||
if (typeof value !== "string") continue;
|
||||
checked += 1;
|
||||
const found = placeholders(value);
|
||||
if (found.length !== 1 || found[0] !== "name") {
|
||||
leaky.push(`${locale}: ${key} interpolates ${found.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(leaky.sort()).toEqual([]);
|
||||
expect(checked).toBe(actions.length * LOCALES.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the retention disclosure does not depend on a race", () => {
|
||||
/**
|
||||
* The sentence that bounds attribution — "who entered what is answerable for
|
||||
* the next N days" — used to appear only if a SECOND query had already
|
||||
* answered. The revoke dialog reads the grant list; the number came off the
|
||||
* activity feed. Two independent requests, and whichever lost decided
|
||||
* whether the owner was told. An owner who clicked quickly, or whose
|
||||
* activity read failed, ended a manage grant having read one sentence fewer
|
||||
* than an owner who clicked slowly, with nothing on screen to indicate that
|
||||
* a sentence was missing.
|
||||
*
|
||||
* The number now rides the same payload as the row: if there is a grant to
|
||||
* revoke, `retentionDays` is already in hand. Deterministic means there is
|
||||
* no branch left, which is what both legs below check from opposite sides —
|
||||
* one that the sentence is always composed, one that the second query is
|
||||
* gone.
|
||||
*/
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}(${JSON.stringify(params)})` : key;
|
||||
|
||||
it("states the window on every grant that could have written something", () => {
|
||||
for (const access of ["WRITE", "MANAGE"] as const) {
|
||||
const body = revokeBody({ t, access, name: "Alex", retentionDays: 90 });
|
||||
expect(body).toContain("recordSharing.given.revokeEntriesStay");
|
||||
expect(body).toContain(
|
||||
'recordSharing.given.revokeAttribution({"days":90})',
|
||||
);
|
||||
}
|
||||
// READ keeps neither: a read grant never put anything in the record, so
|
||||
// saying what happens to what they entered would describe an act that
|
||||
// cannot have occurred.
|
||||
const read = revokeBody({
|
||||
t,
|
||||
access: "READ",
|
||||
name: "Alex",
|
||||
retentionDays: 90,
|
||||
});
|
||||
expect(read).not.toContain("recordSharing.given.revokeAttribution");
|
||||
});
|
||||
|
||||
it("takes the number from the read that produced the row", () => {
|
||||
const source = readFileSync(GRANTS_GIVEN_CARD, "utf8");
|
||||
// Positive control: the matcher can see a hook name in this file at all.
|
||||
expect(source.match(/useAccountGrants/g)?.length ?? 0).toBeGreaterThan(0);
|
||||
// And the second query, whose arrival used to decide the copy, is gone.
|
||||
expect(source).not.toContain("useRecordActivity");
|
||||
// The branch it fed is gone with it. A required number cannot be absent,
|
||||
// so there is nothing left for a caller to omit — which is the difference
|
||||
// between "the sentence is usually there" and "there is no path without
|
||||
// it". Both halves are named: the optional marker and the guard.
|
||||
expect(source).not.toContain("retentionDays?:");
|
||||
expect(source).not.toContain("retentionDays !== undefined");
|
||||
expect(source.match(/retentionDays: number/g)?.length ?? 0).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the activity feed carries no audit detail", () => {
|
||||
/**
|
||||
* The DTO the owner's feed publishes, read off the route.
|
||||
*
|
||||
* A field-level assertion rather than a sentence in a comment, because the
|
||||
* pressure to widen it is real and constant: every thin sentence in the feed
|
||||
* looks like it wants the value that would make it specific, and the audit
|
||||
* row usually has one. `details` is a free-form JSON column written by sixty
|
||||
* handlers; publishing it would put whatever any of them decided to file in
|
||||
* front of the record owner, and some of them file a label.
|
||||
*/
|
||||
it("publishes five fields, and `details` is not one of them", () => {
|
||||
const source = readFileSync(ACTIVITY_ROUTE, "utf8");
|
||||
const parsed = ts.createSourceFile(
|
||||
ACTIVITY_ROUTE,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
let members: string[] | null = null;
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isInterfaceDeclaration(node)) return;
|
||||
if (node.name.text !== "RecordActivityEntry") return;
|
||||
members = node.members
|
||||
.map((m) => (m.name && ts.isIdentifier(m.name) ? m.name.text : ""))
|
||||
.filter((n) => n.length > 0)
|
||||
.sort();
|
||||
});
|
||||
|
||||
// The interface was found at all. `toEqual` against null would report the
|
||||
// absence as a mismatch of the list, which reads like the wrong failure.
|
||||
expect(
|
||||
members,
|
||||
"RecordActivityEntry not found in the route",
|
||||
).not.toBeNull();
|
||||
expect(members).toEqual(["accesses", "action", "actor", "createdAt", "id"]);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
|
||||
@@ -102,6 +103,21 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({
|
||||
given: given.map((g) => toGrantView(g, g.grantee, now)),
|
||||
received: received.map((g) => toGrantView(g, g.grantor, now)),
|
||||
// v1.37.0 — the audit window, on the payload that carries the rows.
|
||||
//
|
||||
// The revoke dialog tells the owner how long "who entered what" stays
|
||||
// answerable after the access ends, and it can only say that with the
|
||||
// number the operator configured. It used to read that number off the
|
||||
// activity feed, a second and independent request, so whether the sentence
|
||||
// appeared depended on which of two reads finished first — and an owner
|
||||
// who clicked quickly ended a manage grant having been told less than one
|
||||
// who clicked slowly, with nothing on screen to say a sentence was
|
||||
// missing. Riding it here removes the race by construction: a client
|
||||
// holding a grant to revoke is holding the window it must disclose.
|
||||
//
|
||||
// Same resolver as `/api/account/activity` and as the purge job, so the
|
||||
// two surfaces cannot state different numbers.
|
||||
retentionDays: getAuditLogRetentionDays(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,19 @@ import type {
|
||||
RecordActivity,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
/**
|
||||
* A grant payload, with the audit window every real one carries.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
|
||||
* dialog can state how long attribution survives without waiting on a second
|
||||
* request. A fixture that omitted it would be describing a payload the server
|
||||
* cannot send.
|
||||
*/
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
const activityRef: { value: RecordActivity | undefined } = { value: undefined };
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
@@ -75,7 +87,7 @@ const BASE: GrantRow = {
|
||||
|
||||
describe("who can open my record", () => {
|
||||
it("says so honestly when nobody can", () => {
|
||||
grantsRef.value = { given: [], received: [] };
|
||||
grantsRef.value = grantList();
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("Nobody else can open your record");
|
||||
expect(html).not.toContain('data-slot="grants-given-list"');
|
||||
@@ -86,7 +98,7 @@ describe("who can open my record", () => {
|
||||
// produced. A panel that compared `expiresAt` to the clock would be a
|
||||
// second opinion about somebody's access to a health record, and the two
|
||||
// would disagree first in the dangerous direction.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{ ...BASE, id: "a", state: "ACTIVE" },
|
||||
{ ...BASE, id: "p", state: "PENDING", acceptedAt: null },
|
||||
@@ -98,7 +110,7 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain('data-grant-state="ACTIVE"');
|
||||
expect(html).toContain('data-grant-state="PENDING"');
|
||||
@@ -109,7 +121,7 @@ describe("who can open my record", () => {
|
||||
// The row IS the consent record. "Who had access, from when to when, and
|
||||
// who ended it" is the question the panel exists to answer, and a list
|
||||
// that dropped the ended rows could not answer it.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
@@ -119,14 +131,14 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain('data-grant-state="REVOKED"');
|
||||
expect(html).toContain("You ended it");
|
||||
});
|
||||
|
||||
it("distinguishes a withdrawal from a handover", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
@@ -136,7 +148,7 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
// Two facts, one column. A trail that conflated them would misread an
|
||||
// ordinary handover as a falling-out.
|
||||
@@ -145,20 +157,20 @@ describe("who can open my record", () => {
|
||||
});
|
||||
|
||||
it("offers no way to end a grant that has already ended", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [{ ...BASE, state: "REVOKED", revokedAt: "2026-02-01T09:00:00Z" }],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const ended = render(<GrantsGivenCard />);
|
||||
expect(ended).not.toContain('data-slot="grant-revoke"');
|
||||
|
||||
grantsRef.value = { given: [BASE], received: [] };
|
||||
grantsRef.value = grantList({ given: [BASE] });
|
||||
const live = render(<GrantsGivenCard />);
|
||||
expect(live).toContain('data-slot="grant-revoke"');
|
||||
});
|
||||
|
||||
it("says a live grant has never been used rather than leaving a blank", () => {
|
||||
grantsRef.value = { given: [{ ...BASE, lastUsedAt: null }], received: [] };
|
||||
grantsRef.value = grantList({ given: [{ ...BASE, lastUsedAt: null }] });
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("never");
|
||||
});
|
||||
@@ -166,10 +178,10 @@ describe("who can open my record", () => {
|
||||
|
||||
describe("records I can open", () => {
|
||||
it("offers acceptance on an invitation and nothing else", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [{ ...BASE, state: "PENDING", acceptedAt: null }],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-slot="grant-accept"');
|
||||
// Nothing to open and nothing to hand back: an invitation confers nothing.
|
||||
@@ -178,7 +190,7 @@ describe("records I can open", () => {
|
||||
});
|
||||
|
||||
it("offers opening and handing back once it is active", () => {
|
||||
grantsRef.value = { given: [], received: [BASE] };
|
||||
grantsRef.value = grantList({ received: [BASE] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-slot="grant-open"');
|
||||
expect(html).toContain('data-account-id="acct-1"');
|
||||
|
||||
@@ -21,7 +21,18 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
/**
|
||||
* A grant payload, with the audit window every real one carries.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
|
||||
* dialog can state how long attribution survives without waiting on a second
|
||||
* request. A fixture that omitted it would be describing a payload the server
|
||||
* cannot send.
|
||||
*/
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -154,10 +165,10 @@ describe("offering a level", () => {
|
||||
|
||||
describe("accepting a level", () => {
|
||||
it("states the level and what accepting it means before the button", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [{ ...BASE, access: "WRITE" }],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html).toContain('data-grant-access="WRITE"');
|
||||
@@ -170,7 +181,7 @@ describe("accepting a level", () => {
|
||||
});
|
||||
|
||||
it("asks a manage invitation for its own, heavier consent", () => {
|
||||
grantsRef.value = { given: [], received: [{ ...BASE, access: "MANAGE" }] };
|
||||
grantsRef.value = grantList({ received: [{ ...BASE, access: "MANAGE" }] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html).toContain('data-grant-access="MANAGE"');
|
||||
@@ -188,7 +199,7 @@ describe("accepting a level", () => {
|
||||
});
|
||||
|
||||
it("does not ask a read invitation for a write consent", () => {
|
||||
grantsRef.value = { given: [], received: [{ ...BASE, access: "READ" }] };
|
||||
grantsRef.value = grantList({ received: [{ ...BASE, access: "READ" }] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-grant-access="READ"');
|
||||
expect(html).not.toContain('data-slot="grant-write-consent"');
|
||||
@@ -196,7 +207,7 @@ describe("accepting a level", () => {
|
||||
|
||||
it("stops saying it once the invitation has been answered", () => {
|
||||
// A sentence about a decision already taken is nagging, not consent.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [
|
||||
{
|
||||
@@ -206,7 +217,7 @@ describe("accepting a level", () => {
|
||||
acceptedAt: "2026-01-02T11:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).not.toContain('data-slot="grant-write-consent"');
|
||||
});
|
||||
@@ -254,11 +265,24 @@ describe("ending an access, and what it says about what was entered", () => {
|
||||
expect(body).toContain('revokeAttribution({"days":90})');
|
||||
});
|
||||
|
||||
it("omits the window rather than inventing one", () => {
|
||||
// The number is the operator's setting. A compiled-in 365 here would be
|
||||
// exactly the invented value the sentence exists to avoid.
|
||||
const body = revokeBody({ t, access: "WRITE", name: "Jo" });
|
||||
it("states the operator's window rather than a compiled-in one", () => {
|
||||
// The number is the operator's setting, so an instance running 90 days
|
||||
// says 90 and this file would fail on a hardcoded 365.
|
||||
//
|
||||
// v1.37.0 replaced the case that used to live here. It asserted that the
|
||||
// sentence was OMITTED when the number had not arrived, which was true and
|
||||
// was the defect: the number came off a second query, so whether an owner
|
||||
// was told how long attribution survives depended on which of two requests
|
||||
// landed first. The window rides the grant list now — a caller with a row
|
||||
// to revoke has it — so there is no absent case left to assert.
|
||||
const body = revokeBody({
|
||||
t,
|
||||
access: "WRITE",
|
||||
name: "Jo",
|
||||
retentionDays: 7,
|
||||
});
|
||||
expect(body).toContain("recordSharing.given.revokeEntriesStay");
|
||||
expect(body).not.toContain("revokeAttribution");
|
||||
expect(body).toContain('revokeAttribution({"days":7})');
|
||||
expect(body).not.toContain("365");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useAccountGrants,
|
||||
useRecordActivity,
|
||||
useRevokeGrant,
|
||||
type GrantRow,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
@@ -39,12 +38,6 @@ export function GrantsGivenCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useAccountGrants();
|
||||
const revoke = useRevokeGrant();
|
||||
// Read for one number: how long the activity view can still say who entered
|
||||
// what. The activity card on this same page holds the query, so this is the
|
||||
// cached answer rather than a second request — and when the server has not
|
||||
// answered yet, the sentence that would need the number is left out instead
|
||||
// of being written around a guessed 365.
|
||||
const activity = useRecordActivity();
|
||||
|
||||
const given = data?.given ?? [];
|
||||
|
||||
@@ -95,6 +88,10 @@ export function GrantsGivenCard() {
|
||||
// Only a live grant can be ended. An already-ended row keeps its history
|
||||
// and loses its button, rather than offering an act that would 409.
|
||||
if (grant.state !== "ACTIVE" && grant.state !== "PENDING") return null;
|
||||
// The row and the window arrive together. A grant that can be revoked came
|
||||
// out of a resolved payload, so there is no state in which this control
|
||||
// exists and the number behind its disclosure does not.
|
||||
if (!data) return null;
|
||||
const name = accountLabel(grant.account);
|
||||
return (
|
||||
<ConfirmButton
|
||||
@@ -110,7 +107,7 @@ export function GrantsGivenCard() {
|
||||
t,
|
||||
access: grant.access,
|
||||
name,
|
||||
retentionDays: activity.data?.retentionDays,
|
||||
retentionDays: data.retentionDays,
|
||||
})}
|
||||
confirmLabel={t("recordSharing.given.revokeConfirm")}
|
||||
pending={revoke.isPending}
|
||||
@@ -134,10 +131,17 @@ export function GrantsGivenCard() {
|
||||
* condition is "not READ" rather than "is WRITE", so the level that can also
|
||||
* change and remove entries carries both sentences too — for a manage grant
|
||||
* the question "what happens to what they did" is the sharper one, not the
|
||||
* softer. The last sentence is the honest bound on attribution — the activity
|
||||
* softer. The last sentence is the honest bound on attribution: the activity
|
||||
* view answers "who entered what" for as long as the audit window reaches, and
|
||||
* that window is the operator's setting. It is said with the number the server
|
||||
* resolved, or not said at all.
|
||||
* that window is the operator's setting.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` is required, and that is the whole fix. It used to
|
||||
* be optional and came from a second query, so the disclosure appeared only
|
||||
* when that query happened to have answered: two owners performing the same
|
||||
* act read different paragraphs, and the one who read less had no way to tell.
|
||||
* The number rides the grant list now, so a caller with a grant to revoke
|
||||
* cannot be without it and this function has no branch that omits the
|
||||
* sentence.
|
||||
*/
|
||||
export function revokeBody({
|
||||
t,
|
||||
@@ -148,17 +152,15 @@ export function revokeBody({
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
access: GrantRow["access"];
|
||||
name: string;
|
||||
/** Undefined until the server has said what the window is. */
|
||||
retentionDays?: number;
|
||||
/** The audit window, off the same payload the row came from. */
|
||||
retentionDays: number;
|
||||
}): string {
|
||||
const lines = [t("recordSharing.given.revokeBody", { name })];
|
||||
if (access !== "READ") {
|
||||
lines.push(t("recordSharing.given.revokeEntriesStay"));
|
||||
if (retentionDays !== undefined) {
|
||||
lines.push(
|
||||
t("recordSharing.given.revokeAttribution", { days: retentionDays }),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
t("recordSharing.given.revokeAttribution", { days: retentionDays }),
|
||||
);
|
||||
}
|
||||
return lines.join(" ");
|
||||
}
|
||||
@@ -111,6 +111,12 @@ const grantListResponse = z
|
||||
.object({
|
||||
given: z.array(grantView),
|
||||
received: z.array(grantView),
|
||||
retentionDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"How long the record's activity feed can still attribute an entry to the person who made it, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant. It rides this payload so a client ending an access can state the window in the same breath, without a second request whose timing would decide whether the owner was told.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AccountGrantList",
|
||||
|
||||
@@ -65,6 +65,16 @@ export interface GrantList {
|
||||
given: GrantRow[];
|
||||
/** Grants offered to this account on somebody else's. */
|
||||
received: GrantRow[];
|
||||
/**
|
||||
* How long the activity feed can still say who entered what, in days —
|
||||
* resolved by the server from the same setting the purge job reads.
|
||||
*
|
||||
* On this payload rather than fetched beside it, because the revoke dialog
|
||||
* has to disclose the window and a client holding a grant to revoke is
|
||||
* already holding the answer. Reading it off a second request made the
|
||||
* sentence appear or not depending on which read landed first.
|
||||
*/
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export interface RecordActivityEntry {
|
||||
|
||||
@@ -16,10 +16,34 @@ type Translator = (
|
||||
* mark a dose taken — and "made a change" for seven different things is a way
|
||||
* of answering nothing. An owner reading this at breakfast wants the verb.
|
||||
*
|
||||
* The map keeps a line for allergies, family history and a tracked value even
|
||||
* though a delegate can no longer add any of the three: rows written while
|
||||
* they were admitted sit in the same table and still have to render as
|
||||
* something, which is the same reason the fallback below exists.
|
||||
* The map keeps a line for a tracked value even though a delegate can no
|
||||
* longer add one: rows written while it was admitted sit in the same table and
|
||||
* still have to render as something, which is the same reason the fallback
|
||||
* below exists.
|
||||
*
|
||||
* ## v1.37.0 — the MANAGE half
|
||||
*
|
||||
* A WRITE delegate can only add, so the generic line was at least the right
|
||||
* shape of fact. A MANAGE delegate changes and removes entries the owner made
|
||||
* themselves, and there "somebody made a change in your record" withholds the
|
||||
* only part that matters: whether a reading was corrected or deleted, whether
|
||||
* a medication was stopped, whether a whole dose history was purged. The level
|
||||
* ships under "reconstructable or refused", and a feed that cannot say which
|
||||
* verb ran reconstructs nothing.
|
||||
*
|
||||
* So every action a MANAGE-declared route files has an arm below, and
|
||||
* `src/__tests__/manage-activity-inventory.test.ts` reads the routes to prove
|
||||
* it — by CALLING this function, not by matching `case` in its source, so an
|
||||
* action that quietly falls through to the fallback fails there.
|
||||
*
|
||||
* ## What a line may say
|
||||
*
|
||||
* The verb and the kind of thing, never the value. Every sentence here takes
|
||||
* `name` and nothing else, and the inventory guard holds that across all six
|
||||
* bundles. The activity feed reads out of the audit table, which is not
|
||||
* encrypted and has its own retention: "changed your weight from 81 to 78"
|
||||
* would put a health figure in a second store with no rotation story. What
|
||||
* changed is a question the record itself answers.
|
||||
*
|
||||
* ## Why unknown actions fall back rather than fail
|
||||
*
|
||||
@@ -82,6 +106,156 @@ export function recordActivityVerbLine(
|
||||
// client sent them together.
|
||||
case "measurement.create.batch":
|
||||
return t("recordSharing.activityVerb.measurementCreate", { name });
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* v1.37.0 — MANAGE. Everything below can change or remove something */
|
||||
/* the owner put there themselves. */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
case "measurement.update":
|
||||
return t("recordSharing.activityVerb.measurementUpdate", { name });
|
||||
case "measurement.delete":
|
||||
return t("recordSharing.activityVerb.measurementDelete", { name });
|
||||
// Its own line rather than the singular one. "Removed a reading" for a
|
||||
// bulk delete is the sentence that would let a month of readings go
|
||||
// missing while the feed reported a tidy-up.
|
||||
case "measurement.delete.bulk":
|
||||
return t("recordSharing.activityVerb.measurementDeleteBulk", { name });
|
||||
case "measurement.restore":
|
||||
return t("recordSharing.activityVerb.measurementRestore", { name });
|
||||
case "measurementReminder.create":
|
||||
return t("recordSharing.activityVerb.measurementReminderCreate", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.update":
|
||||
return t("recordSharing.activityVerb.measurementReminderUpdate", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.delete":
|
||||
return t("recordSharing.activityVerb.measurementReminderDelete", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.complete":
|
||||
return t("recordSharing.activityVerb.measurementReminderComplete", {
|
||||
name,
|
||||
});
|
||||
case "measurementReminder.satisfy":
|
||||
return t("recordSharing.activityVerb.measurementReminderSatisfy", {
|
||||
name,
|
||||
});
|
||||
|
||||
case "labResult.update":
|
||||
return t("recordSharing.activityVerb.labResultUpdate", { name });
|
||||
case "labResult.delete":
|
||||
return t("recordSharing.activityVerb.labResultDelete", { name });
|
||||
case "labResult.restore":
|
||||
return t("recordSharing.activityVerb.labResultRestore", { name });
|
||||
case "biomarker.update":
|
||||
return t("recordSharing.activityVerb.biomarkerUpdate", { name });
|
||||
case "biomarker.delete":
|
||||
return t("recordSharing.activityVerb.biomarkerDelete", { name });
|
||||
|
||||
case "allergy.update":
|
||||
return t("recordSharing.activityVerb.allergyUpdate", { name });
|
||||
case "allergy.delete":
|
||||
return t("recordSharing.activityVerb.allergyDelete", { name });
|
||||
case "family-history.update":
|
||||
return t("recordSharing.activityVerb.familyHistoryUpdate", { name });
|
||||
case "family-history.delete":
|
||||
return t("recordSharing.activityVerb.familyHistoryDelete", { name });
|
||||
|
||||
case "illness.episode.update":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeUpdate", { name });
|
||||
case "illness.episode.delete":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeDelete", { name });
|
||||
case "illness.episode.resolve":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeResolve", { name });
|
||||
case "illness.episode.restore":
|
||||
return t("recordSharing.activityVerb.illnessEpisodeRestore", { name });
|
||||
case "illness.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.illnessDayLogUpsert", { name });
|
||||
|
||||
case "medication.update":
|
||||
return t("recordSharing.activityVerb.medicationUpdate", { name });
|
||||
case "medication.delete":
|
||||
return t("recordSharing.activityVerb.medicationDelete", { name });
|
||||
case "medication.glp1.update":
|
||||
return t("recordSharing.activityVerb.medicationGlp1Update", { name });
|
||||
case "medication.schedule_revision.created":
|
||||
return t("recordSharing.activityVerb.medicationScheduleCreate", { name });
|
||||
case "medication.schedule_revision.updated":
|
||||
return t("recordSharing.activityVerb.medicationScheduleUpdate", { name });
|
||||
case "medication.schedule_revision.deleted":
|
||||
return t("recordSharing.activityVerb.medicationScheduleDelete", { name });
|
||||
case "medication.inventory.create":
|
||||
return t("recordSharing.activityVerb.medicationInventoryCreate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.update":
|
||||
return t("recordSharing.activityVerb.medicationInventoryUpdate", {
|
||||
name,
|
||||
});
|
||||
case "medication.inventory.delete":
|
||||
return t("recordSharing.activityVerb.medicationInventoryDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.sideEffect.delete":
|
||||
return t("recordSharing.activityVerb.medicationSideEffectDelete", {
|
||||
name,
|
||||
});
|
||||
case "medication.intake.update":
|
||||
return t("recordSharing.activityVerb.medicationIntakeUpdate", { name });
|
||||
case "medication.intake.delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeDelete", { name });
|
||||
case "medication.intake.bulk_delete":
|
||||
return t("recordSharing.activityVerb.medicationIntakeBulkDelete", {
|
||||
name,
|
||||
});
|
||||
// The one that empties a medication's whole history in one request. It
|
||||
// says so, because "removed several doses" would read as a correction.
|
||||
case "medication.intake.purge":
|
||||
return t("recordSharing.activityVerb.medicationIntakePurge", { name });
|
||||
case "medication.intake.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeImport", { name });
|
||||
case "medication.intake.history.import.kickoff":
|
||||
return t("recordSharing.activityVerb.medicationIntakeHistoryImport", {
|
||||
name,
|
||||
});
|
||||
|
||||
case "moodEntry.create":
|
||||
return t("recordSharing.activityVerb.moodEntryCreate", { name });
|
||||
case "moodEntry.update":
|
||||
return t("recordSharing.activityVerb.moodEntryUpdate", { name });
|
||||
case "moodEntry.delete":
|
||||
return t("recordSharing.activityVerb.moodEntryDelete", { name });
|
||||
case "mood.delete.bulk":
|
||||
return t("recordSharing.activityVerb.moodDeleteBulk", { name });
|
||||
case "mood.restore":
|
||||
return t("recordSharing.activityVerb.moodRestore", { name });
|
||||
case "mental-health.create":
|
||||
return t("recordSharing.activityVerb.mentalHealthCreate", { name });
|
||||
|
||||
case "cycle.cycle.delete":
|
||||
return t("recordSharing.activityVerb.cycleDelete", { name });
|
||||
case "cycle.day-log.upsert":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
|
||||
case "cycle.day-log.update":
|
||||
return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
|
||||
case "cycle.day-log.delete":
|
||||
return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
|
||||
case "cycle.period.boundary":
|
||||
return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
|
||||
case "cycle.symptom.custom.create":
|
||||
return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
|
||||
|
||||
case "nutrient.water.write":
|
||||
return t("recordSharing.activityVerb.nutrientWaterWrite", { name });
|
||||
|
||||
// Not a change to the record, and the one act on this list that takes a
|
||||
// copy of all of it out of the instance. Named plainly for that reason.
|
||||
case "health-record.export":
|
||||
return t("recordSharing.activityVerb.healthRecordExport", { name });
|
||||
|
||||
default:
|
||||
return t("recordSharing.activity.acted", { name });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user