diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bbd1d6b42..8fbadf170 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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." diff --git a/messages/de.json b/messages/de.json index 2e876a92b..50224cb88 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 704da23e3..255435074 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 13cb2d2cb..a1403afd0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 0c97286b6..3df592639 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 9bb80dbf5..9a5f4506e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index f01fa2c7f..aa0cf4af3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/manage-activity-inventory.test.ts b/src/__tests__/manage-activity-inventory.test.ts new file mode 100644 index 000000000..6dace765f --- /dev/null +++ b/src/__tests__/manage-activity-inventory.test.ts @@ -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(); + const namespaces = new Set(); + 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 { + return JSON.parse( + readFileSync(join(MESSAGES, `${locale}.json`), "utf8"), + ) as Record; +} + +/** The leaf at a dotted path, or undefined. */ +function leaf(root: Record, 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)[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) => + 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"]); + }); +}); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 0d2dc86f8..1a0184f55 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -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(), }); }); diff --git a/src/components/settings/access/__tests__/access-panel.test.tsx b/src/components/settings/access/__tests__/access-panel.test.tsx index d1ce4bbf9..42147f829 100644 --- a/src/components/settings/access/__tests__/access-panel.test.tsx +++ b/src/components/settings/access/__tests__/access-panel.test.tsx @@ -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 { + 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(); 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(); 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(); 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(); // 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(); expect(ended).not.toContain('data-slot="grant-revoke"'); - grantsRef.value = { given: [BASE], received: [] }; + grantsRef.value = grantList({ given: [BASE] }); const live = render(); 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(); 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(); 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(); expect(html).toContain('data-slot="grant-open"'); expect(html).toContain('data-account-id="acct-1"'); diff --git a/src/components/settings/access/__tests__/grant-access-level.test.tsx b/src/components/settings/access/__tests__/grant-access-level.test.tsx index 3e52124a6..d8ee6185a 100644 --- a/src/components/settings/access/__tests__/grant-access-level.test.tsx +++ b/src/components/settings/access/__tests__/grant-access-level.test.tsx @@ -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 { + 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(); 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(); 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(); 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(); 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"); }); }); diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index ae9487fb2..f32e0c5d1 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -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 ( ) => 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(" "); } diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index cb3decceb..41a4d2fb7 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -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", diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts index 7fff2b502..2d7cd6fb9 100644 --- a/src/lib/queries/use-account-grants.ts +++ b/src/lib/queries/use-account-grants.ts @@ -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 { diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index fac5538f5..d4c78eab0 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -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 }); }