Merge branch 'main' into refactor/settings-admin-one-piece

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 05:20:04 +02:00
commit c6246092ff
63 files changed
+4992 -633

No files matched your search

+29 -3
View File
@@ -15,9 +15,19 @@ concurrency:
cancel-in-progress: true
jobs:
e2e:
# The suite outgrew the runner the same way the integration suite did: the
# single job passed at ~18 minutes against a 20-minute cap, and the settings
# geometry measurements pushed it over. Same remedy, split rather than raise:
# two Playwright shards, each with its own database and production build, and
# a gate job below that carries the exact name branch protection requires.
shard:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2]
name: e2e shard ${{ matrix.shard }}/2
services:
postgres:
image: postgres:16-alpine
@@ -87,7 +97,7 @@ jobs:
# chart-runtime boundary (a duplicated ~92 KB gz recharts chunk).
- run: pnpm bundle-check
- run: pnpm exec playwright test
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/2
env:
E2E_BASE_URL: http://localhost:3000
@@ -95,7 +105,23 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
if-no-files-found: ignore
# Branch protection requires a check named exactly "e2e". This job carries
# that name and answers for both shards: it fails when any shard failed or
# was cancelled, so a half-green matrix cannot satisfy the gate.
e2e:
runs-on: ubuntu-latest
needs: shard
if: always()
timeout-minutes: 5
steps:
- name: Every shard finished green
run: |
if [ "${{ needs.shard.result }}" != "success" ]; then
echo "shard result: ${{ needs.shard.result }}"
exit 1
fi
+4
View File
@@ -28,6 +28,10 @@ regexes = [
# Coach cadence-suggestion label keys are dotted message keys, not secrets;
# one trips the generic-api-key entropy heuristic.
'''coach\.reminderSuggestion\.cadence''',
# Level-A mood dimension label and anchor keys are dotted message keys, not
# secrets; they sit on fields named `labelKey` / `lowAnchorKey`, and the
# generic-api-key rule reads any `*Key: "<high-entropy string>"` as a token.
'''mood\.dimension\.''',
]
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
+25
View File
@@ -2,6 +2,31 @@
## [Unreleased]
### Added
- A mood entry now records five separate values instead of one. The quick
check-in is unchanged: pick a face, save, done in a tap, and its answer maps
onto a new pleasantness scale so nothing about that entry is second class.
Underneath it, a section that stays closed until you open it holds five
sliders running 0 to 10, each with wording at both ends: how the day felt,
how much strain it carried, how much energy there was, how connected you
were, and how able to act you felt. Anything you leave alone stays empty
rather than landing in the middle, and clearing a slider takes the answer
back. Entries you have already logged carry the mapped value for the first
scale and nothing invented for the other four.
- The mood page trends all five, over seven, thirty or ninety days, each drawn
the way you answered it. A scale you have never filled in is named as not
recorded rather than drawn flat through the middle. The daily briefing and
the assistant read all five as well, each with its own age, so a value from
last week is stated with its date instead of as today's.
- Factor ratings are no longer offered on a new entry. A one-to-five score for
work stress and a nought-to-ten stress scale are two answers to the same
question, and only the second is asked now. Nothing was removed: ratings
already logged still show, still edit, and still feed the breakdowns that
read them.
### Security
- The PDF renderer behind the document vault moves to a release that closes a
+1 -1
View File
@@ -64,7 +64,7 @@ For more details, see the [security documentation](https://docs.healthlog.dev/se
HealthLog Docker images are built and published from this repository's CI:
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — publishes on every `v*` tag; pull requests build the image without publishing it
- **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog)
- **Architectures**: `linux/amd64` and `linux/arm64`
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
+14 -12
View File
@@ -3,15 +3,15 @@
The admin login overview shows a coarse `City, CC` location and a carrier
next to every sign-in. Two tiers can produce that:
- **Online.** A third-party lookup, `https://ipwho.is` by default. Free,
keyless, and on by default — which also means every login IP address of
every account on the instance leaves the host.
- **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read
from a local directory. The ASN database is the authoritative source
for the carrier and the AS number. Note that the resolver is
online-first: having the databases in place is not by itself enough to
stop the outbound lookup, see "Turning the online lookup off entirely"
below.
for the carrier and the AS number. The resolver reads these first: an
address the local databases can place never leaves the host.
- **Online.** A third-party lookup, `https://ipwho.is` by default. Free,
keyless, and the only tier on a host without the databases — which
means every login IP address of every account on that instance leaves
the host. With the databases mounted it sees only the addresses they
could not place, and you can switch it off entirely (below).
The offline tier is optional and absent by default. The published image
only carries the databases when the image build had a MaxMind licence key
@@ -73,8 +73,8 @@ is the subject of this page.
## Turning the online lookup off entirely
Once the offline tier resolves, the third-party lookup is redundant. To
stop all IP egress:
With the databases mounted, the third-party lookup already only sees
addresses they could not place. To remove that remainder too:
```env
IP_GEO_LOOKUP_DISABLED="1"
@@ -127,6 +127,8 @@ the value is in `.env` but not on the `environment:` whitelist — compare
against `docker-compose.yml`.
**The carrier column is empty.** The carrier also comes from the online
provider's ISP field, so it is normally filled even without the ASN
database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no
`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty.
provider's ISP field, but the online lookup only runs for an address the
City database could not place. On a host with `GeoLite2-City.mmdb` and no
`GeoLite2-ASN.mmdb`, a location that resolves locally has no carrier to
go with it — mount the ASN database too. With neither database, the
online provider fills both, unless `IP_GEO_LOOKUP_DISABLED="1"` is set.
+1
View File
@@ -126,6 +126,7 @@ const eslintConfig = defineConfig([
"src/components/cycle/bbt-chart.tsx",
"src/components/insights/derived/delta-sparkline.tsx",
"src/components/insights/intraday-pulse-chart.tsx",
"src/components/insights/mood/mood-dimension-trends.tsx",
"src/components/insights/mood/mood-distribution-chart.tsx",
"src/components/insights/mood/mood-time-of-day-chart.tsx",
"src/components/insights/mood/mood-weekday-chart.tsx",
+45 -6
View File
@@ -887,11 +887,42 @@
"iconGroupMisc": "Sonstiges"
},
"sectionMoreTags": "Weitere Tags",
"sectionFactors": "Faktoren",
"sectionNoteDetails": "Notiz & Details",
"quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt",
"quickTagsExpand": "Weitere",
"loadError": "Stimmungseinträge konnten nicht geladen werden."
"loadError": "Stimmungseinträge konnten nicht geladen werden.",
"sectionLevelA": "Fünf Skalen",
"sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.",
"dimensionNotAnswered": "nicht angegeben",
"dimensionClear": "leeren",
"dimensionValueText": "{value} von {max}: {low} bis {high}",
"dimension": {
"a1": {
"label": "Stimmung",
"low": "sehr unangenehm",
"high": "sehr angenehm"
},
"a2": {
"label": "Stress",
"low": "vollkommen entspannt",
"high": "extrem belastet"
},
"a3": {
"label": "Energie",
"low": "erschöpft",
"high": "energiegeladen"
},
"a4": {
"label": "Verbundenheit",
"low": "isoliert",
"high": "verstanden und unterstützt"
},
"a5": {
"label": "Stabilität",
"low": "vollständig überfordert",
"high": "sehr handlungsfähig"
}
}
},
"medications": {
"title": "Medikamente",
@@ -3613,7 +3644,15 @@
"statLabel": "Wie das berechnet wurde",
"explainerLabel": "Wie das berechnet wurde"
},
"inTargetTitle": "Im Zielbereich"
"inTargetTitle": "Im Zielbereich",
"dimensions": {
"title": "Fünf Skalen",
"none": "Für diese fünf Skalen ist noch nichts erfasst.",
"window": "{days} Tage",
"noneInWindow": "nichts im Zeitraum",
"higherIsMore": "höher = {anchor}",
"notRecorded": "Nicht erfasst: {list}."
}
},
"derived": {
"coverage": {
@@ -6485,13 +6524,13 @@
"outcomeFailed": "Fehlgeschlagen",
"auditDetails": "Details",
"deleteAllData": "Alle Daten global löschen",
"deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Nutzer.",
"deleteAllDescription": "Löscht alle Datensätze aller Konten; die Konten selbst und ihre Zugangsdaten bleiben erhalten.",
"deleteAllConfirm": "Alle Daten wirklich löschen?",
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Nutzer unwiderruflich. Nutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.",
"deleteAllConfirmDescription": "Dieser Vorgang löscht unwiderruflich alle Datensätze aller Konten in der gesamten Datenbank, nicht eine Auswahl von Tabellen. Erhalten bleiben: die Nutzerkonten und ihre Anmelde-Zugangsdaten, die Konfiguration der Instanz und die Berechtigungen, über die betreute Profile erreichbar bleiben. Ein Beleg für diesen Vorgang wird danach ins Audit-Log geschrieben; die Audit-Historie davor geht mit allem anderen.",
"deleteButton": "Alle Daten löschen",
"deleteAllPromptLabel": "Tippe {token} zum Fortfahren",
"finalDelete": "Endgültig löschen",
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.",
"deletedResult": "Gelöscht: {rows} Zeilen in {tables} Tabellen. Nutzerkonten und Anmelde-Zugangsdaten bleiben erhalten.",
"authRegister": "Registrierung",
"authLogin": "Anmeldung",
"authLoginPasskey": "Passkey-Anmeldung",
+45 -6
View File
@@ -887,11 +887,42 @@
"iconGroupMisc": "Other"
},
"sectionMoreTags": "More tags",
"sectionFactors": "Factors",
"sectionNoteDetails": "Note & details",
"quickTagsHelp": "Your frequent tags — one tap",
"quickTagsExpand": "More",
"loadError": "Could not load your mood entries."
"loadError": "Could not load your mood entries.",
"sectionLevelA": "Five scales",
"sectionLevelAHelp": "Optional. Anything you leave alone stays empty.",
"dimensionNotAnswered": "not recorded",
"dimensionClear": "clear",
"dimensionValueText": "{value} of {max}: {low} to {high}",
"dimension": {
"a1": {
"label": "Mood",
"low": "very unpleasant",
"high": "very pleasant"
},
"a2": {
"label": "Stress",
"low": "completely relaxed",
"high": "extremely strained"
},
"a3": {
"label": "Energy",
"low": "drained",
"high": "full of energy"
},
"a4": {
"label": "Connectedness",
"low": "isolated",
"high": "understood and supported"
},
"a5": {
"label": "Stability",
"low": "completely overwhelmed",
"high": "fully able to act"
}
}
},
"medications": {
"title": "Medications",
@@ -3613,7 +3644,15 @@
"statLabel": "How this was computed",
"explainerLabel": "How this was computed"
},
"inTargetTitle": "In your range"
"inTargetTitle": "In your range",
"dimensions": {
"title": "Five scales",
"none": "Nothing recorded for these five scales yet.",
"window": "{days} days",
"noneInWindow": "nothing in this range",
"higherIsMore": "higher = {anchor}",
"notRecorded": "Not recorded: {list}."
}
},
"derived": {
"coverage": {
@@ -6485,13 +6524,13 @@
"outcomeFailed": "Failed",
"auditDetails": "Details",
"deleteAllData": "Delete all data globally",
"deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users.",
"deleteAllDescription": "Deletes every record every account holds; the accounts and their sign-in credentials are kept.",
"deleteAllConfirm": "Really delete all data?",
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.",
"deleteAllConfirmDescription": "This permanently deletes every account's records across the whole database, not a selection of tables. Kept: the user accounts and the credentials that sign into them, the instance configuration, and the delegation grants that keep managed profiles reachable. A receipt for this wipe is written to the audit log afterwards; the audit history before it goes with everything else.",
"deleteButton": "Delete all data",
"deleteAllPromptLabel": "Type {token} to continue",
"finalDelete": "Delete permanently",
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Deleted {rows} rows across {tables} tables. User accounts and sign-in credentials were kept.",
"authRegister": "Registration",
"authLogin": "Login",
"authLoginPasskey": "Passkey login",
+45 -6
View File
@@ -887,11 +887,42 @@
"iconGroupMisc": "Otros"
},
"sectionMoreTags": "Más etiquetas",
"sectionFactors": "Factores",
"sectionNoteDetails": "Nota y detalles",
"quickTagsHelp": "Tus etiquetas frecuentes: un toque",
"quickTagsExpand": "Más",
"loadError": "No se pudieron cargar tus registros de estado de ánimo."
"loadError": "No se pudieron cargar tus registros de estado de ánimo.",
"sectionLevelA": "Cinco escalas",
"sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.",
"dimensionNotAnswered": "sin indicar",
"dimensionClear": "borrar",
"dimensionValueText": "{value} de {max}: {low} a {high}",
"dimension": {
"a1": {
"label": "Estado de ánimo",
"low": "muy desagradable",
"high": "muy agradable"
},
"a2": {
"label": "Estrés",
"low": "completamente relajado",
"high": "extremadamente tenso"
},
"a3": {
"label": "Energía",
"low": "agotado",
"high": "lleno de energía"
},
"a4": {
"label": "Conexión",
"low": "aislado",
"high": "comprendido y apoyado"
},
"a5": {
"label": "Estabilidad",
"low": "completamente desbordado",
"high": "con plena capacidad de acción"
}
}
},
"medications": {
"title": "Medicamentos",
@@ -3613,7 +3644,15 @@
"statLabel": "Cómo se calculó esto",
"explainerLabel": "Cómo se calculó esto"
},
"inTargetTitle": "En tu rango"
"inTargetTitle": "En tu rango",
"dimensions": {
"title": "Cinco escalas",
"none": "Todavía no hay nada registrado en estas cinco escalas.",
"window": "{days} días",
"noneInWindow": "nada en este periodo",
"higherIsMore": "más alto = {anchor}",
"notRecorded": "Sin registrar: {list}."
}
},
"derived": {
"coverage": {
@@ -6485,13 +6524,13 @@
"outcomeFailed": "Fallido",
"auditDetails": "Detalles",
"deleteAllData": "Eliminar todos los datos globalmente",
"deleteAllDescription": "Elimina todos los datos de mediciones, medicación, integraciones y registros de todos los usuarios.",
"deleteAllDescription": "Elimina todos los registros de todas las cuentas; las cuentas y sus credenciales de inicio de sesión se conservan.",
"deleteAllConfirm": "¿Eliminar realmente todos los datos?",
"deleteAllConfirmDescription": "Esto eliminará permanentemente todas las mediciones, medicamentos, eventos de toma, tokens de API, conexiones de Withings, retos de autenticación abiertos, canales de notificación (Telegram, ntfy, Web Push), suscripciones push y eliminaciones de mensajes de Telegram en cola de todos los usuarios. Las cuentas de usuario, el registro de auditoría y los comentarios enviados se conservan para que el borrado siga siendo rastreable y los informes de errores se puedan seguir clasificando.",
"deleteAllConfirmDescription": "Esto elimina permanentemente todos los registros de todas las cuentas en toda la base de datos, no una selección de tablas. Se conservan: las cuentas de usuario y sus credenciales de inicio de sesión, la configuración de la instancia y las concesiones de acceso que mantienen accesibles los perfiles gestionados. Después se escribe un comprobante de este borrado en el registro de auditoría; el historial anterior se va con todo lo demás.",
"deleteButton": "Eliminar todos los datos",
"deleteAllPromptLabel": "Escribe {token} para continuar",
"finalDelete": "Eliminar permanentemente",
"deletedResult": "Deleted: {measurements} measurements, {medications} Medicamentos,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Eliminadas {rows} filas en {tables} tablas. Se conservaron las cuentas de usuario y las credenciales de inicio de sesión.",
"authRegister": "Registro",
"authLogin": "Iniciar sesión",
"authLoginPasskey": "Inicio de sesión con passkey",
+45 -6
View File
@@ -887,11 +887,42 @@
"iconGroupMisc": "Autres"
},
"sectionMoreTags": "Plus de tags",
"sectionFactors": "Facteurs",
"sectionNoteDetails": "Note et détails",
"quickTagsHelp": "Vos tags fréquents — en un geste",
"quickTagsExpand": "Plus",
"loadError": "Impossible de charger vos entrées d'humeur."
"loadError": "Impossible de charger vos entrées d'humeur.",
"sectionLevelA": "Cinq échelles",
"sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.",
"dimensionNotAnswered": "non renseigné",
"dimensionClear": "effacer",
"dimensionValueText": "{value} sur {max} : {low} à {high}",
"dimension": {
"a1": {
"label": "Humeur",
"low": "très désagréable",
"high": "très agréable"
},
"a2": {
"label": "Stress",
"low": "parfaitement détendu",
"high": "extrêmement tendu"
},
"a3": {
"label": "Énergie",
"low": "épuisé",
"high": "plein d'énergie"
},
"a4": {
"label": "Lien social",
"low": "isolé",
"high": "compris et soutenu"
},
"a5": {
"label": "Stabilité",
"low": "complètement dépassé",
"high": "pleinement capable d'agir"
}
}
},
"medications": {
"title": "Médicaments",
@@ -3613,7 +3644,15 @@
"statLabel": "Comment ceci est calculé",
"explainerLabel": "Comment ceci est calculé"
},
"inTargetTitle": "Dans votre plage"
"inTargetTitle": "Dans votre plage",
"dimensions": {
"title": "Cinq échelles",
"none": "Rien n'est encore enregistré pour ces cinq échelles.",
"window": "{days} jours",
"noneInWindow": "rien sur cette période",
"higherIsMore": "plus haut = {anchor}",
"notRecorded": "Non renseigné : {list}."
}
},
"derived": {
"coverage": {
@@ -6485,13 +6524,13 @@
"outcomeFailed": "Échec",
"auditDetails": "Détails",
"deleteAllData": "Supprimer toutes les données globalement",
"deleteAllDescription": "Supprime toutes les données de mesures, de médicaments, d'intégrations et de journaux pour tous les utilisateurs.",
"deleteAllDescription": "Supprime tous les enregistrements de tous les comptes ; les comptes et leurs identifiants de connexion sont conservés.",
"deleteAllConfirm": "Vraiment supprimer toutes les données ?",
"deleteAllConfirmDescription": "Cette action supprimera définitivement toutes les mesures, médicaments, événements de prise, jetons d'API, connexions Withings, défis d'authentification ouverts, canaux de notification (Telegram, ntfy, Web Push), abonnements push et suppressions de messages Telegram en file d'attente pour tous les utilisateurs. Les comptes utilisateurs, le journal d'audit et les retours soumis sont conservés afin que l'effacement reste traçable et que les rapports de bug restent triables.",
"deleteAllConfirmDescription": "Cette action supprime définitivement tous les enregistrements de tous les comptes dans l'ensemble de la base de données, et non une sélection de tables. Sont conservés : les comptes utilisateurs et leurs identifiants de connexion, la configuration de l'instance et les délégations qui gardent les profils gérés accessibles. Un reçu de cet effacement est ensuite écrit dans le journal d'audit ; l'historique antérieur part avec le reste.",
"deleteButton": "Supprimer toutes les données",
"deleteAllPromptLabel": "Tapez {token} pour continuer",
"finalDelete": "Supprimer définitivement",
"deletedResult": "Deleted: {measurements} measurements, {medications} Médicaments,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "{rows} lignes supprimées dans {tables} tables. Les comptes utilisateurs et les identifiants de connexion ont été conservés.",
"authRegister": "Inscription",
"authLogin": "Connexion",
"authLoginPasskey": "Connexion par passkey",
+45 -6
View File
@@ -887,11 +887,42 @@
"iconGroupMisc": "Altro"
},
"sectionMoreTags": "Altri tag",
"sectionFactors": "Fattori",
"sectionNoteDetails": "Nota e dettagli",
"quickTagsHelp": "I tuoi tag frequenti: un tocco",
"quickTagsExpand": "Altro",
"loadError": "Impossibile caricare le voci dell'umore."
"loadError": "Impossibile caricare le voci dell'umore.",
"sectionLevelA": "Cinque scale",
"sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.",
"dimensionNotAnswered": "non indicato",
"dimensionClear": "cancella",
"dimensionValueText": "{value} su {max}: da {low} a {high}",
"dimension": {
"a1": {
"label": "Umore",
"low": "molto spiacevole",
"high": "molto piacevole"
},
"a2": {
"label": "Stress",
"low": "completamente rilassato",
"high": "estremamente sotto pressione"
},
"a3": {
"label": "Energia",
"low": "esausto",
"high": "pieno di energia"
},
"a4": {
"label": "Connessione",
"low": "isolato",
"high": "capito e sostenuto"
},
"a5": {
"label": "Stabilità",
"low": "completamente sopraffatto",
"high": "pienamente capace di agire"
}
}
},
"medications": {
"title": "Farmaci",
@@ -3613,7 +3644,15 @@
"statLabel": "Come è stato calcolato",
"explainerLabel": "Come è stato calcolato"
},
"inTargetTitle": "Nel tuo intervallo"
"inTargetTitle": "Nel tuo intervallo",
"dimensions": {
"title": "Cinque scale",
"none": "Per queste cinque scale non c'è ancora nulla.",
"window": "{days} giorni",
"noneInWindow": "nulla in questo periodo",
"higherIsMore": "più alto = {anchor}",
"notRecorded": "Non registrato: {list}."
}
},
"derived": {
"coverage": {
@@ -6485,13 +6524,13 @@
"outcomeFailed": "Non riuscito",
"auditDetails": "Dettagli",
"deleteAllData": "Elimina tutti i dati globalmente",
"deleteAllDescription": "Elimina tutti i dati di misurazioni, farmaci, integrazioni e log di tutti gli utenti.",
"deleteAllDescription": "Elimina tutti i record di tutti gli account; gli account e le credenziali di accesso vengono conservati.",
"deleteAllConfirm": "Eliminare davvero tutti i dati?",
"deleteAllConfirmDescription": "Questa operazione eliminerà definitivamente tutte le misurazioni, i farmaci, gli eventi di assunzione, i token API, le connessioni Withings, le challenge di autenticazione aperte, i canali di notifica (Telegram, ntfy, Web Push), le sottoscrizioni push e le eliminazioni di messaggi Telegram in coda di tutti gli utenti. Gli account utente, il log di audit e i feedback inviati vengono conservati affinché la cancellazione resti tracciabile e le segnalazioni di bug restino gestibili.",
"deleteAllConfirmDescription": "Questa operazione elimina definitivamente tutti i record di tutti gli account nell'intero database, non una selezione di tabelle. Vengono conservati: gli account utente e le loro credenziali di accesso, la configurazione dell'istanza e le deleghe che mantengono raggiungibili i profili gestiti. Al termine nel log di audit viene scritta una ricevuta di questa cancellazione; la cronologia precedente se ne va con tutto il resto.",
"deleteButton": "Elimina tutti i dati",
"deleteAllPromptLabel": "Digita {token} per continuare",
"finalDelete": "Elimina definitivamente",
"deletedResult": "Deleted: {measurements} measurements, {medications} Farmaci,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Eliminate {rows} righe in {tables} tabelle. Account utente e credenziali di accesso conservati.",
"authRegister": "Registrazione",
"authLogin": "Accedi",
"authLoginPasskey": "Accesso con passkey",
+45 -6
View File
@@ -887,11 +887,42 @@
"iconGroupMisc": "Inne"
},
"sectionMoreTags": "Więcej tagów",
"sectionFactors": "Czynniki",
"sectionNoteDetails": "Notatka i szczegóły",
"quickTagsHelp": "Twoje częste tagi — jedno dotknięcie",
"quickTagsExpand": "Więcej",
"loadError": "Nie udało się załadować wpisów nastroju."
"loadError": "Nie udało się załadować wpisów nastroju.",
"sectionLevelA": "Pięć skal",
"sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.",
"dimensionNotAnswered": "nie podano",
"dimensionClear": "wyczyść",
"dimensionValueText": "{value} z {max}: od {low} do {high}",
"dimension": {
"a1": {
"label": "Nastrój",
"low": "bardzo nieprzyjemnie",
"high": "bardzo przyjemnie"
},
"a2": {
"label": "Stres",
"low": "całkowicie odprężony",
"high": "skrajnie obciążony"
},
"a3": {
"label": "Energia",
"low": "wyczerpany",
"high": "pełen energii"
},
"a4": {
"label": "Więź",
"low": "odizolowany",
"high": "rozumiany i wspierany"
},
"a5": {
"label": "Stabilność",
"low": "całkowicie przytłoczony",
"high": "w pełni zdolny do działania"
}
}
},
"medications": {
"title": "Leki",
@@ -3613,7 +3644,15 @@
"statLabel": "Jak to obliczono",
"explainerLabel": "Jak to obliczono"
},
"inTargetTitle": "W Twoim zakresie"
"inTargetTitle": "W Twoim zakresie",
"dimensions": {
"title": "Pięć skal",
"none": "Dla tych pięciu skal nie ma jeszcze żadnych danych.",
"window": "{days} dni",
"noneInWindow": "brak w tym okresie",
"higherIsMore": "wyżej = {anchor}",
"notRecorded": "Nie zapisano: {list}."
}
},
"derived": {
"coverage": {
@@ -6485,13 +6524,13 @@
"outcomeFailed": "Nieudane",
"auditDetails": "Szczegóły",
"deleteAllData": "Usuń wszystkie dane globalnie",
"deleteAllDescription": "Usuwa wszystkie dane pomiarów, leków, integracji i logów wszystkich użytkowników.",
"deleteAllDescription": "Usuwa wszystkie rekordy wszystkich kont; konta i dane logowania zostają zachowane.",
"deleteAllConfirm": "Na pewno usunąć wszystkie dane?",
"deleteAllConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich pomiarów, leków, zdarzeń przyjęcia, tokenów API, połączeń Withings, otwartych wyzwań uwierzytelniania, kanałów powiadomień (Telegram, ntfy, Web Push), subskrypcji push oraz zakolejkowanych usunięć wiadomości Telegram wszystkich użytkowników. Konta użytkowników, dziennik audytu i przesłane opinie zostają zachowane, aby samo usunięcie pozostało możliwe do prześledzenia, a zgłoszenia błędów — do obsłużenia.",
"deleteAllConfirmDescription": "Ta operacja trwale usuwa wszystkie rekordy wszystkich kont w całej bazie danych, a nie wybrane tabele. Zachowane zostają: konta użytkowników i ich dane logowania, konfiguracja instancji oraz uprawnienia, dzięki którym profile zarządzane pozostają dostępne. Po zakończeniu do dziennika audytu trafia potwierdzenie tego usunięcia; wcześniejsza historia znika razem z resztą.",
"deleteButton": "Usuń wszystkie dane",
"deleteAllPromptLabel": "Wpisz {token}, aby kontynuować",
"finalDelete": "Usuń trwale",
"deletedResult": "Deleted: {measurements} measurements, {medications} Leki,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
"deletedResult": "Usunięto {rows} wierszy w {tables} tabelach. Konta użytkowników i dane logowania zostały zachowane.",
"authRegister": "Rejestracja",
"authLogin": "Zaloguj",
"authLoginPasskey": "Logowanie kluczem passkey",
@@ -0,0 +1,49 @@
-- v1.37 — the five level-A self-state values on a mood entry.
--
-- A mood entry has carried one 1-5 value since the first release. That value
-- keeps its scale and its meaning; these five ride beside it, each 0-10 with
-- its own anchors: pleasantness, stress, energy, connectedness and stability.
--
-- All five are nullable, which is what makes this an additive change rather
-- than a table rewrite, and nullable is also the honest shape: only the first
-- one can be derived from what a row already carries, and the other four stay
-- empty until somebody answers them. A default would fill a hundred thousand
-- historical rows with a number nobody chose.
--
-- No CHECK constraint on the range. Bounds are enforced server-side, the same
-- way `mood_entry_tag_links.rating` documents: a future change to the scale
-- must not strand rows written under the old one.
ALTER TABLE "mood_entries"
ADD COLUMN "mood_a1" INTEGER,
ADD COLUMN "stress_a2" INTEGER,
ADD COLUMN "energy_a3" INTEGER,
ADD COLUMN "connection_a4" INTEGER,
ADD COLUMN "stability_a5" INTEGER;
-- Map every entry written before these columns existed onto the pleasantness
-- scale. The five-point label is the only thing a historical row can answer
-- from, so it is the only column filled: stress, energy, connectedness and
-- stability stay NULL, because there is nothing to derive them from and an
-- invented number would be indistinguishable from one somebody gave.
--
-- The anchors are 1/3/5/7/9, matching `MOOD_A1_MAP` in
-- `src/lib/validations/mood.ts` — the runtime map and these arms are compared
-- to each other by a test, so the two cannot drift apart. Not 0 and 10 at the
-- ends: the five-point endpoints mean as bad or as good as that instrument
-- can express, not as bad or as good as a person can feel, and pinning them
-- to the extremes would make every mapped row read as more extreme than every
-- hand-set one.
--
-- `WHERE mood_a1 IS NULL` makes a re-run a no-op instead of an overwrite. A
-- migration re-applied against a database that has since taken real answers
-- must not replace them with the mapped value.
UPDATE mood_entries
SET mood_a1 = CASE mood
WHEN 'LAUSIG' THEN 1
WHEN 'SCHLECHT' THEN 3
WHEN 'OKAY' THEN 5
WHEN 'GUT' THEN 7
WHEN 'SUPER_GUT' THEN 9
END
WHERE mood_a1 IS NULL
AND mood IN ('LAUSIG', 'SCHLECHT', 'OKAY', 'GUT', 'SUPER_GUT');
+52
View File
@@ -4016,6 +4016,48 @@ model MoodEntry {
date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin
mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG
score Int // 1-5
/// v1.37 — the five level-A self-state values, each 0-10 with its own
/// anchors. `MOOD_DIMENSIONS` (`src/lib/mood/dimensions.ts`) is the one
/// table binding a column to its label, its anchors and its orientation.
///
/// `score` above stays the 1-5 legacy axis and is not re-scaled: eleven
/// surfaces derive numbers from it on that assumption, three of them into
/// prose an assistant then states as fact. The two axes coexist by design.
/// `moodA1` is derived from the five-point label on every write, so a quick
/// check-in is a full entry rather than a degraded one; A2 to A5 are only
/// ever set by hand, and stay NULL when nobody answered them. Absence reads
/// as absence — a fabricated middle value would poison both the trend and
/// any later model trained on these columns.
///
/// Queryable plaintext, deliberately — the same trade `CycleDayLog`
/// documents at its `basalBodyTempC`. Encrypting them would remove the
/// trend read, the factor crosstab and every aggregate at once, and the
/// values are self-ratings on a fixed scale rather than prose. The free-text
/// note beside them stays encrypted, because that is where the prose is.
///
/// `stressA2` is stored literally, exactly as the user set it against the
/// anchors they read, so a higher value means more stress and a worse day.
/// A surface that needs "up is better" flips the sign through the `inverse`
/// flag on the dimension table, never inline. The same rule already governs
/// `MoodTag.inverse`, and it exists because a stored value that disagrees
/// with the label the user saw cannot be recovered afterwards.
///
/// A delegate holding a READ grant sees these columns, because a grant is
/// whole-record by design and carries no per-domain scope. That is
/// deliberate, not an oversight: withholding part of a record from a
/// delegate would be a new capability, not a field select.
///
/// None of these reaches the health score, and that too is a decision. The
/// WELLBEING pillar reads PHQ-9, GAD-7 and WHO-5 and cites the papers its
/// thresholds come from; a daily self-rated slider is a different kind of
/// evidence, and averaging the two would weaken the line that makes the
/// score worth showing to a doctor. The reasoning is written out at
/// `src/lib/analytics/score/wellbeing.ts`.
moodA1 Int? @map("mood_a1") // 0-10, pleasantness
stressA2 Int? @map("stress_a2") // 0-10, inverse-oriented
energyA3 Int? @map("energy_a3") // 0-10
connectionA4 Int? @map("connection_a4") // 0-10
stabilityA5 Int? @map("stability_a5") // 0-10
tags String? // JSON array of tag keys
/// v1.4.30 — first-class free-text note. Replaces the
/// `tags: ["note:<text>"]` workaround the iOS app shipped through
@@ -4178,6 +4220,16 @@ model MoodTag {
/// mood *factor* the user scores per entry (e.g. work, social, sleep
/// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one
/// taxonomy + one ingestion path for both flavours.
///
/// v1.37 — `RATED` is frozen, not removed. The capture sheet stopped
/// offering factor ratings when the level-A columns arrived on `MoodEntry`:
/// a 1-5 score for "work stress" and a 0-10 stress self-state are different
/// questions, and asking both on one entry invites two answers to one
/// thing. Nothing was deleted — no tag row, no link, no rating — so every
/// existing score keeps rendering, keeps editing and keeps feeding the
/// factor crosstab. Re-enabling the offer on the create sheet is a decision
/// about what to ask, not a bug fix; do not restore it because it looks
/// like an omission.
kind String @default("BINARY") @map("kind") // "BINARY" | "RATED"
/// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale
/// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags.
+8 -3
View File
@@ -77,14 +77,19 @@ describe("Playwright CI determinism", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const upload = workflow.jobs.e2e?.steps?.find((step) =>
// The suite runs as a shard matrix since the single job outgrew its
// timeout; the running job is `shard`, while `e2e` is the gate that
// carries the required check name and runs no browser. The report
// therefore lives on the shard job, named per shard so the two
// artifacts cannot clobber each other.
const upload = workflow.jobs.shard?.steps?.find((step) =>
step.uses?.startsWith("actions/upload-artifact@"),
);
expect(upload).toBeDefined();
expect(upload?.if).toBe("always()");
expect(upload?.with).toMatchObject({
name: "playwright-report",
name: "playwright-report-shard-${{ matrix.shard }}",
path: "playwright-report/",
"retention-days": 7,
"if-no-files-found": "ignore",
@@ -95,7 +100,7 @@ describe("Playwright CI determinism", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const env = workflow.jobs.e2e?.env;
const env = workflow.jobs.shard?.env;
expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/);
expect(env?.SESSION_COOKIE_SECURE).toBe("false");
@@ -0,0 +1,170 @@
/**
* Structural guard binding `prisma/schema.prisma` to the ADMIN wipe.
*
* The per-account wipe grew a derived plan and a completeness test after it
* was found deleting thirteen tables out of eighty-odd. The admin wipe — the
* one an operator runs before handing a box on, "Deletes all data for all
* users" — kept its inline list of nine `deleteMany` calls, written when nine
* was most of the schema. It was still nine against a hundred and twenty-two
* models: laboratory results, documents, cycle logs, journals, mood, workouts,
* sleep and Coach conversations all survived a confirmation the operator typed
* by hand. Nothing failed, because nothing was asking.
*
* So the route reads `ADMIN_WIPE_MODELS` — `WIPE_MODELS` minus the entries in
* `ADMIN_WIPE_EXEMPT` — and this file asks the question its sibling asks for
* the account wipe.
*
* What it proves:
* 1. every model carrying a `userId` column is deleted by the admin wipe or
* excused by a named list with a written reason;
* 2. every model without one either reaches an admin-wiped model through a
* chain of required `onDelete: Cascade` relations or is declared
* instance-scoped;
* 3. every admin-only exemption names a real model that the per-account wipe
* DOES delete, so the entry is a deliberate subtraction rather than a
* stale name nobody re-read;
* 4. the route carries no table list of its own — it iterates the plan.
*
* Its honest limit is the same as its sibling's: it proves classification, not
* judgement. It cannot tell that a model was excused for a bad reason, only
* that somebody had to write the reason down. And rule 4 is source-shaped: it
* reads the route file for `deleteMany` calls on a literal delegate, so a
* future inline list built some other way would slip it.
*
* Mutation check: remove any entry from `WIPE_MODELS` and this goes red naming
* that model as unclassified; put an inline `tx.measurement.deleteMany({})`
* back into the route and rule 4 goes red naming it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
parseWipeSchema,
reachedByCascade,
} from "@/__tests__/helpers/wipe-schema-shape";
import {
ADMIN_WIPE_EXEMPT,
ADMIN_WIPE_MODELS,
INSTANCE_SCOPED,
WIPE_EXEMPT,
WIPE_MODELS,
} from "@/lib/data-wipe/wipe-plan";
const ROUTE = join(
process.cwd(),
"src",
"app",
"api",
"admin",
"data",
"route.ts",
);
const models = parseWipeSchema();
const adminWipeSet = new Set<string>(ADMIN_WIPE_MODELS);
/** Every list that is allowed to answer "why did this model survive?". */
function survivorReason(model: string): string | null {
return (
ADMIN_WIPE_EXEMPT[model] ??
WIPE_EXEMPT[model] ??
INSTANCE_SCOPED[model] ??
null
);
}
describe("admin data-wipe completeness", () => {
it("deletes essentially the whole user-scoped schema", () => {
// A floor, not a target. The defect this file exists for was a wipe that
// covered nine tables while reporting success; a silent shrink back
// towards that number fails here even if every survivor is classified.
expect(ADMIN_WIPE_MODELS.length).toBeGreaterThan(80);
});
it("every model with a userId column is wiped or excused with a reason", () => {
const userScoped = [...models]
.filter(([, shape]) => shape.scalars.includes("userId"))
.map(([name]) => name);
expect(userScoped.length).toBeGreaterThan(80);
const unclassified = userScoped.filter(
(name) => !adminWipeSet.has(name) && survivorReason(name) === null,
);
if (unclassified.length > 0) {
throw new Error(
`${unclassified.length} user-scoped model(s) survive the admin wipe with nothing saying why:\n` +
unclassified.map((n) => ` ❌ ${n}`).join("\n") +
"\n\nAn operator who types DELETE ALL before handing this server on " +
"expects these rows gone. Add each to WIPE_MODELS, or to " +
"ADMIN_WIPE_EXEMPT / WIPE_EXEMPT / INSTANCE_SCOPED with the reason " +
"keeping it is not keeping somebody's record.",
);
}
});
it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
const covered = reachedByCascade(models, adminWipeSet);
const orphans = [...models]
.filter(([, shape]) => !shape.scalars.includes("userId"))
.map(([name]) => name)
.filter((name) => !covered.has(name) && survivorReason(name) === null);
if (orphans.length > 0) {
throw new Error(
`${orphans.length} model(s) carry no userId, are not removed by any cascade from an admin-wiped row, and are not declared instance-scoped:\n` +
orphans.map((n) => ` ❌ ${n}`).join("\n"),
);
}
});
it("excuses only models the per-account wipe does delete, each with a reason", () => {
for (const [name, reason] of Object.entries(ADMIN_WIPE_EXEMPT)) {
expect(
models.has(name),
`ADMIN_WIPE_EXEMPT names "${name}", which is not a model`,
).toBe(true);
// The list exists to record where the two wipes DIFFER. A name the
// account wipe does not delete either is not a difference; it is a
// stale entry hiding behind a plausible reason.
expect(
(WIPE_MODELS as readonly string[]).includes(name),
`ADMIN_WIPE_EXEMPT names "${name}", which the per-account wipe does not delete either — the entry records no difference`,
).toBe(true);
expect(
reason.length,
`ADMIN_WIPE_EXEMPT.${name} needs a real reason`,
).toBeGreaterThan(30);
}
});
it("derives the model set instead of listing tables in the route", () => {
const source = readFileSync(ROUTE, "utf8");
expect(
source.includes("ADMIN_WIPE_MODELS"),
"the admin wipe route no longer reads the shared plan",
).toBe(true);
// `tx.measurement.deleteMany(` and friends — the shape the inline list
// had. The loop calls `resolveGlobalWipeDelegate(tx, model).deleteMany`,
// which names no table and does not match.
const inline = [
...source.matchAll(
/\b(?:tx|prisma)\s*\.\s*(\w+)\s*\.\s*deleteMany\s*\(/g,
),
].map((m) => m[1]);
expect(
inline,
`the admin wipe names tables inline again:\n${inline.join("\n")}\n\n` +
"That list is how it drifted to nine tables: it was correct the day " +
"it was written and nothing noticed when the schema moved past it. " +
"Delete through ADMIN_WIPE_MODELS.",
).toEqual([]);
});
});
+6 -73
View File
@@ -35,11 +35,12 @@
* that model; add a column to `model User` without classifying it and it goes
* red naming the column.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
parseWipeSchema,
reachedByCascade,
} from "@/__tests__/helpers/wipe-schema-shape";
import {
INSTANCE_SCOPED,
USER_KEPT_FIELDS,
@@ -51,61 +52,7 @@ import {
wipeWhere,
} from "@/lib/data-wipe/wipe-plan";
const SCHEMA = join(process.cwd(), "prisma", "schema.prisma");
interface Relation {
/** Target model name. */
target: string;
cascade: boolean;
/** `true` when the relation field is optional (`Model?`). */
optional: boolean;
}
interface ModelShape {
scalars: string[];
relations: Relation[];
}
function parseSchema(schema: string): Map<string, ModelShape> {
const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
(m) => ({ name: m[1], body: m[2] }),
);
const modelNames = new Set(blocks.map((b) => b.name));
const out = new Map<string, ModelShape>();
for (const block of blocks) {
const scalars: string[] = [];
const relations: Relation[] = [];
for (const rawLine of block.body.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line);
if (!match) continue;
const [, field, type, isList, isOptional] = match;
if (modelNames.has(type)) {
// A list side of a relation carries no column and cannot cascade.
if (isList) continue;
relations.push({
target: type,
cascade: line.includes("onDelete: Cascade"),
optional: Boolean(isOptional),
});
continue;
}
if (isList && type === type.toUpperCase()) {
// Scalar / enum list column — still a column.
scalars.push(field);
continue;
}
scalars.push(field);
}
out.set(block.name, { scalars, relations });
}
return out;
}
const schemaText = readFileSync(SCHEMA, "utf8");
const models = parseSchema(schemaText);
const models = parseWipeSchema();
const wipeSet = new Set<string>(WIPE_MODELS);
describe("data-wipe completeness", () => {
@@ -158,21 +105,7 @@ describe("data-wipe completeness", () => {
it("every model without a userId column is reached by cascade or declared instance-scoped", () => {
// Fixpoint over required cascade relations: a model is covered when the
// database removes it as a consequence of a row the wipe deletes.
const covered = new Set<string>(wipeSet);
for (;;) {
let grew = false;
for (const [name, shape] of models) {
if (covered.has(name)) continue;
const reached = shape.relations.some(
(r) => r.cascade && !r.optional && covered.has(r.target),
);
if (reached) {
covered.add(name);
grew = true;
}
}
if (!grew) break;
}
const covered = reachedByCascade(models, wipeSet);
const orphans = [...models]
.filter(([, shape]) => !shape.scalars.includes("userId"))
+102
View File
@@ -0,0 +1,102 @@
/**
* The shape of `prisma/schema.prisma` the two wipe guards read.
*
* Both of them ask the same two questions — which models carry a `userId`, and
* which models the database removes on its own when a row they do delete goes
* — so the parser answering them lives in one place. Two copies would drift,
* and a guard that answers from its own private idea of the schema is a guard
* that can be green for the wrong reason.
*
* Deliberately regex-shaped rather than a real parser: it runs inside
* `pnpm test` with no database and no Prisma engine, and model blocks plus
* field lines are the most stable syntax in the file. Every consumer asserts a
* floor on how much it found, so a parser that silently stops matching fails
* loudly instead of passing vacuously.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
export interface Relation {
/** Target model name. */
target: string;
cascade: boolean;
/** `true` when the relation field is optional (`Model?`). */
optional: boolean;
}
export interface ModelShape {
scalars: string[];
relations: Relation[];
}
export const WIPE_SCHEMA_PATH = join(process.cwd(), "prisma", "schema.prisma");
export function parseWipeSchema(
schemaPath: string = WIPE_SCHEMA_PATH,
): Map<string, ModelShape> {
const schema = readFileSync(schemaPath, "utf8");
const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map(
(m) => ({ name: m[1], body: m[2] }),
);
const modelNames = new Set(blocks.map((b) => b.name));
const out = new Map<string, ModelShape>();
for (const block of blocks) {
const scalars: string[] = [];
const relations: Relation[] = [];
for (const rawLine of block.body.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line);
if (!match) continue;
const [, field, type, isList, isOptional] = match;
if (modelNames.has(type)) {
// A list side of a relation carries no column and cannot cascade.
if (isList) continue;
relations.push({
target: type,
cascade: line.includes("onDelete: Cascade"),
optional: Boolean(isOptional),
});
continue;
}
if (isList && type === type.toUpperCase()) {
// Scalar / enum list column — still a column.
scalars.push(field);
continue;
}
scalars.push(field);
}
out.set(block.name, { scalars, relations });
}
return out;
}
/**
* Every model the database itself removes once the seed models are deleted.
*
* A fixpoint over REQUIRED cascade relations: an optional relation survives
* its parent, so it proves nothing about the row going away. The result
* includes the seeds.
*/
export function reachedByCascade(
models: Map<string, ModelShape>,
seeds: Iterable<string>,
): Set<string> {
const covered = new Set<string>(seeds);
for (;;) {
let grew = false;
for (const [name, shape] of models) {
if (covered.has(name)) continue;
const reached = shape.relations.some(
(r) => r.cascade && !r.optional && covered.has(r.target),
);
if (reached) {
covered.add(name);
grew = true;
}
}
if (!grew) break;
}
return covered;
}
@@ -53,6 +53,12 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
const DYNAMIC_ALLOWLIST_PREFIXES = [
"mood.tag",
"mood.tagCategory",
// `mood.dimension`: the five level-A dimensions are a table
// (`src/lib/mood/dimensions.ts`) whose rows carry their own label and anchor
// keys, so every leaf is resolved from a value rather than written as a
// literal. The table is the reason the keys cannot drift apart from the
// columns they describe.
"mood.dimension",
// The whole namespace is a runtime lookup table: the client resolves an
// error envelope's `meta.errorCode` through it (`localizedApiError`), so no
// leaf ever appears as a literal at a call site. Adding a key here is only
@@ -774,6 +774,19 @@ const handler = apiHandler(
date: entry.date,
mood: entry.mood,
score: entry.score,
// The five level-A values, carried the same way `syncVersion`
// is: absent in the file means the file predates the columns,
// and an upsert whose update arm stated them would wipe live
// answers off every matched row when an older backup is
// restored over a going concern. A file that has them writes
// them, including an explicit null; a file that does not says
// nothing. Never derived — filling one in here would put a
// number the person never gave into a restored row.
...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }),
...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }),
...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }),
...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }),
...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }),
tags: entry.tags ?? null,
// The note comes back the way every other dual-column note
// does: ciphertext verbatim when the file carries it, and a
+63 -50
View File
@@ -6,12 +6,36 @@ import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
import {
ADMIN_WIPE_MODELS,
resolveGlobalWipeDelegate,
USER_RESET,
wipeDelegateKey,
WIPE_TRANSACTION_MAX_WAIT_MS,
WIPE_TRANSACTION_TIMEOUT_MS,
} from "@/lib/data-wipe/wipe-plan";
export const dynamic = "force-dynamic";
/**
* Admin-only global wipe of all health/integration data.
* Admin-only global wipe of every account's data.
* Keeps users/passkeys so access to the app remains possible.
*
* The set of tables is not written here. It is declared once in
* `@/lib/data-wipe/wipe-plan` — the same declaration the per-account wipe
* reads — and held against `prisma/schema.prisma` by
* `src/__tests__/data-wipe-completeness.test.ts` plus its admin-scope sibling,
* so a model added to the schema cannot quietly land outside this wipe. It had
* done exactly that: the inline list this route used to carry named nine
* tables, written when nine was most of the schema, and stayed at nine while
* the schema grew past a hundred. Laboratory results, documents, cycle logs,
* journals, mood, workouts, sleep and Coach conversations all survived the
* wipe an operator runs before handing the box on.
*
* `ADMIN_WIPE_MODELS` is `WIPE_MODELS` minus the entries in
* `ADMIN_WIPE_EXEMPT`, and every survivor — sign-in credentials, instance
* configuration, the delegation grants that keep managed profiles reachable —
* carries its reason in that file.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
@@ -59,72 +83,61 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
}
// Audit-log the *intent* before the transaction begins so the trail
// describing "an admin is about to wipe data" survives even if the
// operation crashes midway. AuditLog rows are intentionally NOT
// deleted as part of the wipe — the entire point of an audit trail
// is to outlive the data it documents.
// describing "an admin is about to wipe data" survives a crash midway or a
// rollback. On success the transaction takes this row with the rest of the
// history — an audit trail is a record of who did what from where, which is
// exactly the personal data an operator clearing a box means to remove — and
// the receipt written after the commit replaces it.
await auditLog("admin.data.clear.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { confirm: "DELETE ALL" },
});
const result = await prisma.$transaction(async (tx) => {
const measurements = await tx.measurement.deleteMany({});
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
const medications = await tx.medication.deleteMany({});
const apiTokens = await tx.apiToken.deleteMany({});
const withingsConnections = await tx.withingsConnection.deleteMany({});
const authChallenges = await tx.authChallenge.deleteMany({});
const counts = await prisma.$transaction(
async (tx) => {
const perModel: Record<string, number> = {};
for (const model of ADMIN_WIPE_MODELS) {
const { count } = await resolveGlobalWipeDelegate(tx, model).deleteMany(
{},
);
if (count > 0) perModel[wipeDelegateKey(model)] = count;
}
// v1.4.6 left these out, so encrypted Telegram bot tokens
// (NotificationChannel.config) and Web Push endpoints survived a
// wipe. Cascading via NotificationPreference is handled by the
// schema's onDelete: Cascade on the channel FK.
const notificationChannels = await tx.notificationChannel.deleteMany({});
const pushSubscriptions = await tx.pushSubscription.deleteMany({});
const telegramScheduledDeletions =
await tx.telegramScheduledDeletion.deleteMany({});
// Every account row survives; the personal data carried on its own
// columns does not. Same classification contract as the model list.
await tx.user.updateMany({ data: USER_RESET });
await tx.user.updateMany({
data: {
heightCm: null,
dateOfBirth: null,
gender: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
onboardingCompletedAt: null,
},
});
return perModel;
},
{
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
},
);
return {
measurements: measurements.count,
intakeEvents: intakeEvents.count,
medications: medications.count,
apiTokens: apiTokens.count,
withingsConnections: withingsConnections.count,
authChallenges: authChallenges.count,
notificationChannels: notificationChannels.count,
pushSubscriptions: pushSubscriptions.count,
telegramScheduledDeletions: telegramScheduledDeletions.count,
};
});
const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
// Written after the transaction commits, so the row cannot be removed by the
// AuditLog delete inside it. The instance's whole audit history goes; this
// one row stays as the receipt for the erasure the operator asked for.
await auditLog("admin.data.clear", {
userId: user.id,
ipAddress: getClientIp(request),
details: result,
details: { deletedRows, models: counts },
});
annotate({
meta: {
deleted_rows: deletedRows,
models_wiped: ADMIN_WIPE_MODELS.length,
models_with_rows: Object.keys(counts).length,
},
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
return apiSuccess({ cleared: true, deletedRows, models: counts });
});
+11
View File
@@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { deriveA1 } from "@/lib/mood/level-a";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
// Derived from canonical enum so round-trip export → import covers every
@@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
date: e.date,
mood: e.mood,
score: e.score,
// v1.37 — pleasantness derived from the row's own label, not
// from the file's `score`. This is the one writer whose score
// comes out of the file rather than out of the label, so the
// two can disagree; the column that records a self-assessment
// has to agree with the label the user actually picked.
moodA1: deriveA1(e.mood),
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
@@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
date: e.date,
mood: e.mood,
score: e.score,
moodA1: deriveA1(e.mood),
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
@@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
date: e.date,
mood: e.mood,
score: e.score,
// See the upsert arm above: derived from the label, because
// an imported file's `score` is its own field.
moodA1: deriveA1(e.mood),
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
+33 -2
View File
@@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
@@ -57,7 +58,11 @@ export const GET = apiHandler(
meta: { moodEntryId: id },
});
return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) });
return apiSuccess({
// v1.37 — level-A values under the keys the write path takes.
...shapeLevelA(shapeMoodNote(entry)),
tags: parseTags(entry.tags),
});
},
);
@@ -120,7 +125,32 @@ export const PUT = apiHandler(
if (data.mood !== undefined) {
updateData.mood = data.mood;
updateData.score = getScoreForMood(data.mood);
// v1.37 — pleasantness follows the label the same way `score` does, so
// an entry corrected from "okay" to "bad" cannot keep yesterday's A1.
// Only when the label actually moved, though: the edit dialog sends the
// whole entry back on every save, and re-deriving on an unchanged label
// would overwrite a value the person set by hand every time they
// corrected the timestamp. An explicit `a1` below overrides either way.
if (data.mood !== existing.mood) {
updateData.moodA1 = deriveA1(data.mood);
}
}
// v1.37 — level-A values are per-field on this path: omitted keeps the
// stored value, an explicit number replaces it, an explicit null clears
// it. Built one at a time from the parsed body, never spread.
//
// Pleasantness is the exception, and it is the same exception the create
// path makes: an entry always carries a five-point label, so it always
// implies a pleasantness value, and a null falls back to the derivation
// rather than emptying the column. One rule on both routes — the capture
// surfaces therefore offer no clear control for it.
if (data.a1 !== undefined) {
updateData.moodA1 = data.a1 ?? deriveA1(data.mood ?? existing.mood);
}
if (data.a2 !== undefined) updateData.stressA2 = data.a2;
if (data.a3 !== undefined) updateData.energyA3 = data.a3;
if (data.a4 !== undefined) updateData.connectionA4 = data.a4;
if (data.a5 !== undefined) updateData.stabilityA5 = data.a5;
if (data.moodLoggedAt !== undefined) {
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
// displayTimezone. Also refresh the `tz` column so the row's
@@ -290,7 +320,8 @@ export const PUT = apiHandler(
}
return apiSuccess({
...entry,
// v1.37 — level-A values under the keys the write path takes.
...shapeLevelA(entry),
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the update response renders the tag set without a
+8
View File
@@ -51,6 +51,7 @@ import {
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { deriveA1 } from "@/lib/mood/level-a";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
@@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
const date = moodDateKey(entry.moodLoggedAt, tz);
const score = getScoreForMood(entry.mood);
// v1.37 — pleasantness derived from the same label the score comes from.
// The batch request shape is unchanged: this path carries no level-A
// input, so A2 to A5 are left alone rather than written as nulls, and a
// row that already carries hand-set values keeps them across a re-import.
const moodA1 = deriveA1(entry.mood);
try {
// v1.12.1 — when the entry carries a source-stable `externalId`,
@@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
tz,
mood: entry.mood,
score,
moodA1,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
@@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// the same row.
mood: entry.mood,
score,
moodA1,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
+31 -2
View File
@@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import {
@@ -125,7 +126,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
...shapeMoodNote(e),
// v1.37 — the five level-A values under the keys the create path takes,
// so the edit form reads back what it wrote instead of mapping column
// names to wire names by hand. The column spelling is stripped, not
// joined: one number under two names is a choice nobody made.
...shapeLevelA(shapeMoodNote(e)),
tags: parseTags(e.tags),
// v1.8.5 — flat list of binary structured-tag keys attached to the
// entry (rated factors are surfaced separately below).
@@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) {
source,
externalId,
} = parsed.data;
// v1.37 — the five level-A values, built field by field from the parsed
// body (never spread) and resolved once for every branch below. A1 falls
// back to the derivation from `mood`; the other four are whatever the user
// set, or nothing.
const levelA = levelAForWrite(mood, {
a1: parsed.data.a1,
a2: parsed.data.a2,
a3: parsed.data.a3,
a4: parsed.data.a4,
a5: parsed.data.a5,
});
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -265,6 +281,7 @@ async function postMoodEntry(request: NextRequest) {
tz,
mood,
score,
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
@@ -272,11 +289,19 @@ async function postMoodEntry(request: NextRequest) {
externalId,
moodLoggedAt,
},
// A re-post restates pleasantness, because the label it carries
// always implies one and a stale A1 beside a changed mood would
// contradict the entry's own face. It does NOT restate the other
// four: `levelA` carries them only when this request did, so a
// client re-posting without sliders — the phone, whose build has
// none — leaves the stress and energy somebody answered on the
// web exactly where they were. An explicit null clears one.
update: {
date,
tz,
mood,
score,
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
@@ -290,6 +315,9 @@ async function postMoodEntry(request: NextRequest) {
tz,
mood,
score,
// A fresh row: an omitted dimension lands as NULL, which is
// the same thing the upsert's arms mean by omitting it.
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
@@ -374,7 +402,8 @@ async function postMoodEntry(request: NextRequest) {
return apiSuccess(
{
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
...shapeMoodNote(entry),
// v1.37 — level-A values under their wire keys (see the list read).
...shapeLevelA(shapeMoodNote(entry)),
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the create response renders the tag set without a
+9 -18
View File
@@ -47,16 +47,14 @@ export function DangerZoneSection() {
// Idempotency-Key prevents a double-submit from re-running the
// destructive transaction, matching the Backups restore path.
const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`;
// The server returns one count per table that actually held rows. It
// used to return nine fixed names, which is how nobody noticed the
// wipe had stopped covering the schema: the result line read the same
// whether it had cleared nine tables or ninety.
return apiDelete<{
measurements: number;
intakeEvents: number;
medications: number;
apiTokens: number;
withingsConnections: number;
authChallenges: number;
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
cleared: boolean;
deletedRows: number;
models: Record<string, number>;
}>(
"/api/admin/data",
{ confirm: WIPE_CONFIRM_TOKEN },
@@ -67,15 +65,8 @@ export function DangerZoneSection() {
queryClient.invalidateQueries();
setWipeMsg(
t("admin.deletedResult", {
measurements: data.measurements,
medications: data.medications,
intakeEvents: data.intakeEvents,
apiTokens: data.apiTokens,
withingsConnections: data.withingsConnections,
authChallenges: data.authChallenges,
notificationChannels: data.notificationChannels,
pushSubscriptions: data.pushSubscriptions,
telegramScheduledDeletions: data.telegramScheduledDeletions,
rows: data.deletedRows,
tables: Object.keys(data.models).length,
}),
);
},
+1
View File
@@ -33,6 +33,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
export { BbtChart } from "@/components/cycle/bbt-chart";
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends";
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
@@ -0,0 +1,232 @@
"use client";
import { useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import {
MOOD_DIMENSIONS,
MOOD_DIMENSION_WINDOWS,
type MoodDimensionWindow,
} from "@/lib/mood/dimensions";
import { cn } from "@/lib/utils";
/**
* v1.37 — the five level-A dimensions over time.
*
* Each dimension is drawn in the orientation the person answered it in and
* under the label they read, so a high stress line means a stressful stretch
* rather than a good one. Nothing is flipped for the picture's convenience;
* the legend says which way each scale runs.
*
* A dimension nobody has answered is not drawn and is named underneath as not
* recorded. Drawing it flat through the middle would put five answers on the
* chart for a question that was asked once.
*/
export interface MoodDimensionPointData {
date: string;
value: number;
samples: number;
}
export interface MoodDimensionSummaryData {
key: string;
present: boolean;
inverse: boolean;
min: number;
max: number;
count: number;
avg7: number | null;
avg30: number | null;
avg90: number | null;
latest: number | null;
latestDate: string | null;
newestDaysAgo: number | null;
series: MoodDimensionPointData[];
}
/** One colour per dimension, from the same semantic set the other charts use. */
const COLOR_BY_KEY: Record<string, string> = {
a1: "var(--chart-1)",
a2: "var(--destructive)",
a3: "var(--dracula-orange)",
a4: "var(--info)",
a5: "var(--success)",
};
function meanForWindow(
summary: MoodDimensionSummaryData,
window: MoodDimensionWindow,
): number | null {
if (window === 7) return summary.avg7;
if (window === 30) return summary.avg30;
return summary.avg90;
}
export function MoodDimensionTrends({
dimensions,
}: {
dimensions: MoodDimensionSummaryData[];
}) {
const { t } = useTranslations();
const [window, setWindow] = useState<MoodDimensionWindow>(30);
const present = dimensions.filter((d) => d.present);
const missing = dimensions.filter((d) => !d.present);
const chartData = useMemo(() => {
const byDate = new Map<string, Record<string, number | string>>();
for (const summary of present) {
for (const point of summary.series) {
const row = byDate.get(point.date) ?? { date: point.date };
row[summary.key] = point.value;
byDate.set(point.date, row);
}
}
const rows = [...byDate.values()].sort((a, b) =>
String(a.date).localeCompare(String(b.date)),
);
// The series carry the longest window; the shorter ones are a slice of the
// same points rather than a second read.
return rows.slice(Math.max(0, rows.length - window));
}, [present, window]);
const labelFor = (key: string) => {
const dimension = MOOD_DIMENSIONS.find((d) => d.key === key);
return dimension ? t(dimension.labelKey) : key;
};
if (present.length === 0) {
return (
<p className="text-muted-foreground text-sm">
{t("insights.mood.dimensions.none")}
</p>
);
}
return (
<div className="space-y-3" data-slot="mood-dimension-trends">
<div className="flex flex-wrap items-center gap-1.5">
{MOOD_DIMENSION_WINDOWS.map((option) => (
<button
key={option}
type="button"
onClick={() => setWindow(option)}
aria-pressed={window === option}
className={cn(
"min-h-9 rounded-full border px-3 text-xs transition-colors",
window === option
? "border-primary bg-primary/15 text-primary"
: "border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{t("insights.mood.dimensions.window", { days: option })}
</button>
))}
</div>
<div className="h-[clamp(160px,34vh,220px)] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 4, right: 8, bottom: 0, left: -20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickFormatter={(value: string) => value.slice(5)}
minTickGap={24}
/>
<YAxis
domain={[0, 10]}
ticks={[0, 5, 10]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
/>
<Tooltip
contentStyle={{
background: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
labelStyle={{ color: "var(--muted-foreground)" }}
formatter={(value, name) => [
String(value ?? ""),
labelFor(String(name)),
]}
/>
{present.map((summary) => (
<Line
key={summary.key}
type="monotone"
dataKey={summary.key}
name={summary.key}
stroke={COLOR_BY_KEY[summary.key] ?? "var(--chart-1)"}
strokeWidth={2}
dot={false}
// A gap is a day nobody answered; joining across it would draw
// a value that was never given.
connectNulls={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<ul className="space-y-1.5">
{present.map((summary) => {
const mean = meanForWindow(summary, window);
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key);
return (
<li
key={summary.key}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className="inline-block size-2 shrink-0 rounded-full"
style={{
background: COLOR_BY_KEY[summary.key] ?? "var(--chart-1)",
}}
/>
<span>{labelFor(summary.key)}</span>
{summary.inverse && dimension ? (
<span className="text-muted-foreground text-xs">
{t("insights.mood.dimensions.higherIsMore", {
anchor: t(dimension.highAnchorKey),
})}
</span>
) : null}
</span>
<span className="text-foreground shrink-0 tabular-nums">
{mean === null
? t("insights.mood.dimensions.noneInWindow")
: mean}
</span>
</li>
);
})}
</ul>
{missing.length > 0 ? (
<p className="text-muted-foreground text-xs">
{t("insights.mood.dimensions.notRecorded", {
list: missing.map((d) => labelFor(d.key)).join(", "),
})}
</p>
) : null}
</div>
);
}
@@ -34,12 +34,25 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap";
// without a layout shift (charts stay Recharts, visually identical). Types
// stay value-free imports so they don't drag the chunk back in.
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
// v1.16.7 established the shared-barrel pattern for the trio; all three
// loaders now resolve the app-wide chart-runtime boundary, so they share
// the ONE recharts chunk with every other chart surface: the cards reveal
// together, and recharts ships exactly once for the whole app.
const MoodDimensionTrends = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDimensionTrends,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
@@ -127,6 +140,9 @@ interface MoodInsightsResponse {
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
// Optional only to tolerate a stale pre-v1.37 cached payload during a
// rollout; the live endpoint always populates it.
dimensions?: MoodDimensionSummaryData[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
@@ -258,6 +274,8 @@ export function MoodInsightsSections({
const hasCrosstab = crosstabRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
@@ -334,6 +352,15 @@ export function MoodInsightsSections({
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
{hasDimensions && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
@@ -85,7 +85,10 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/);
expect(formSrc).toContain('tagsInput.trim() !== ""');
expect(formSrc).toContain("tagKeys.length > 0");
expect(formSrc).toContain("ratedFactors.length > 0");
// v1.37 — a level-A value the user set is typed input too. The rated
// factors this line used to name are no longer captured here; see the
// level-A describe below.
expect(formSrc).toContain("Object.values(levelA).some((v) => v !== null)");
expect(formSrc).toContain('note.trim() !== ""');
// The `isDirty` expression itself must not read `moodLoggedAt`.
const isDirtyExpr = formSrc.slice(
@@ -115,31 +118,51 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
});
});
describe("MoodForm — rated factors (v1.12.0)", () => {
it("lifts a ratedFactors set and threads it to the tag picker", () => {
expect(formSrc).toContain("const [ratedFactors, setRatedFactors]");
expect(formSrc).toMatch(
/function rateFactor\(key: string, rating: number \| null\)/,
);
// The picker receives both the binary toggle and the factor rate
// callbacks so it can render BINARY chips and RATED controls.
expect(formSrc).toMatch(
/<MoodTagPicker[\s\S]*ratedFactors=\{ratedFactors\}[\s\S]*onRateFactor=\{rateFactor\}/,
describe("MoodForm — level A replaces the factor ratings (v1.37)", () => {
it("no longer offers a factor rating on a new entry", () => {
// A 1-5 score for "work stress" and a 0-10 stress self-state are two
// answers to one question. The create sheet asks the second one only.
// Nothing was deleted: the catalogue rows, the stored ratings and the
// edit dialog below all still work, which the next describe pins.
expect(formSrc).not.toContain("onRateFactor=");
expect(formSrc).not.toContain('mode="rated"');
expect(formSrc).not.toContain("ratedFactors");
// The binary structured-tag contract stays byte-identical.
expect(formSrc).toContain('mode="binary"');
expect(formSrc).toContain(
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
);
});
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
expect(formSrc).toContain(
"ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,",
it("offers the five level-A sliders and sends only the answered ones", () => {
expect(formSrc).toContain("<MoodLevelASection");
expect(formSrc).toContain("...levelACreatePayload(levelA)");
// Picking a face seeds pleasantness; moving that slider ends the seeding.
expect(formSrc).toMatch(
/function pickMood\(value: string\) \{[\s\S]*seedA1FromMood\(prev, value, a1Touched\)/,
);
// The binary structured-tag contract stays byte-identical.
expect(formSrc).toContain(
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
expect(formSrc).toContain("onA1Touched={() => setA1Touched(true)}");
// Nothing here defaults a value: the state starts empty and stays empty.
expect(formSrc).toContain("useState<LevelAState>(EMPTY_LEVEL_A)");
});
it("offers no clear control for pleasantness, matching the write path", () => {
// An entry always carries a label, so it always implies a pleasantness
// value and both write routes derive one from a null. A clear button here
// would be a control the server undoes on the way to the database.
const fieldsSrc = readFileSync(
resolve(__dirname, "../mood-level-a-fields.tsx"),
"utf8",
);
expect(fieldsSrc).toMatch(
/onClear=\{\s*dimension\.key === "a1"\s*\?\s*undefined/,
);
});
});
describe("MoodList — rated factor editing", () => {
// Unchanged by v1.37 on purpose: the create sheet stopped asking, and an
// entry that already carries a rating must still be correctable.
it("hydrates, renders, and submits the entry's current ratedFactors", () => {
expect(listSrc).toContain("ratedFactors: RatedFactor[];");
expect(listSrc).toContain(
@@ -156,6 +179,18 @@ describe("MoodList — rated factor editing", () => {
});
});
describe("MoodList — level A on the edit path (v1.37)", () => {
it("seeds the five values from the entry and sends them back, nulls included", () => {
// What the capture sheet writes, this dialog has to show. A value visible
// in a trend and nowhere else is a value the person cannot correct.
expect(listSrc).toContain("levelA: levelAFromEntry(entry)");
expect(listSrc).toContain("setEditLevelA(seed.levelA)");
expect(listSrc).toContain("<MoodLevelASection");
// Nulls travel: this path replaces, so a cleared value has to be stated.
expect(listSrc).toContain("...levelAUpdatePayload(levelA)");
});
});
describe("MoodList — delete failure toast (v1.11.5)", () => {
it("surfaces a toast on a rejected delete instead of failing silently", () => {
expect(listSrc).toContain('import { toast } from "sonner"');
+47 -35
View File
@@ -31,7 +31,6 @@ import {
MoreHorizontal,
Plus,
RotateCcw,
SlidersHorizontal,
StickyNote,
} from "lucide-react";
import { toast } from "sonner";
@@ -45,7 +44,14 @@ import {
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { MoodTagPicker } from "./mood-tag-picker";
import {
EMPTY_LEVEL_A,
MoodLevelASection,
levelACreatePayload,
seedA1FromMood,
type LevelAState,
} from "./mood-level-a-fields";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
import { moodFaceIcon } from "./mood-tag-icons";
@@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
count: MOOD_LEVELS.length,
selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood),
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value),
});
// v1.37 — the five level-A values. Pleasantness follows the picked face
// until the user moves it themselves; the other four start and stay empty
// unless somebody answers them.
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
const [a1Touched, setA1Touched] = useState(false);
const [levelAOpen, setLevelAOpen] = useState(false);
const [tagsInput, setTagsInput] = useState("");
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
const [tagKeys, setTagKeys] = useState<string[]>([]);
// v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on
// their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`.
const [ratedFactors, setRatedFactors] = useState<RatedFactor[]>([]);
// v1.8.5 (C1) — first-class free-text note. The model + API already
// accepted `note`; the web form was the only surface that couldn't
// write it.
@@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
mood !== "" ||
tagsInput.trim() !== "" ||
tagKeys.length > 0 ||
ratedFactors.length > 0 ||
// A level-A value the user set counts as typed input: Reset must ask
// before it discards an answer that took five slider moves.
Object.values(levelA).some((v) => v !== null) ||
note.trim() !== "";
// v1.37 — picking a face suggests a pleasantness value; the suggestion
// stands until the user moves that slider, after which the two are
// independent. Moving the slider never changes which face is lit.
function pickMood(value: string) {
setMood(value);
setLevelA((prev) => seedA1FromMood(prev, value, a1Touched));
}
function toggleTagKey(key: string) {
setTagKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
}
// v1.12.0 — set or clear a rated factor's score. `rating === null`
// removes the factor from the set (re-tapping the active step clears).
function rateFactor(key: string, rating: number | null) {
setRatedFactors((prev) => {
const rest = prev.filter((f) => f.key !== key);
return rating === null ? rest : [...rest, { key, rating }];
});
}
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
@@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
function resetForm() {
setMood("");
setLevelA(EMPTY_LEVEL_A);
setA1Touched(false);
setLevelAOpen(false);
setTagsInput("");
setTagKeys([]);
setRatedFactors([]);
setNote("");
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
setError(null);
@@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
mood,
tags: tags.length > 0 ? tags : undefined,
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
// v1.12.0 — rated factors as a parallel [{key,rating}] array.
ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
note: trimmedNote.length > 0 ? trimmedNote : undefined,
// v1.37 — only the values somebody answered. An untouched slider
// sends nothing, and the server writes NULL rather than a middle.
...levelACreatePayload(levelA),
moodLoggedAt: timestamp,
});
@@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
aria-label={t(level.labelKey)}
data-slot="mood-face"
data-mood={level.value}
onClick={() => setMood(level.value)}
onClick={() => pickMood(level.value)}
{...getMoodRadioProps(index)}
// v1.12 — selected-state unified with the tag tiles and
// factor steps: `border-primary bg-primary/15 text-primary`
@@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
/>
</SheetSection>
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
exactly as the existing segmented control. */}
<SheetSection
title={t("mood.sectionFactors")}
icon={<SlidersHorizontal />}
summary={<SheetSectionCount count={ratedFactors.length} />}
>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
mode="rated"
/>
</SheetSection>
{/* v1.37 — the five level-A values, closed by default. The rated
factors that used to sit here are no longer offered on a new
entry: a 1-5 score for "work stress" and a 0-10 stress
self-state are different questions, and asking both invites
two answers to one thing. Existing ratings keep working
everywhere they already do, including the edit dialog, and no
catalogue row was removed. */}
<MoodLevelASection
value={levelA}
onChange={setLevelA}
onA1Touched={() => setA1Touched(true)}
open={levelAOpen}
onOpenChange={setLevelAOpen}
/>
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
and the note field. The badge counts whichever of these carry
+194
View File
@@ -0,0 +1,194 @@
"use client";
/**
* The five level-A sliders, and the state rules around them.
*
* One component for both mood surfaces — the create sheet and the list's edit
* dialog — because what one writes the other has to be able to show. A value a
* user set on Monday and cannot see on Tuesday is worse than one they were
* never offered.
*
* The section is closed by default and nothing in it is required. The five
* faces above it remain a complete entry on their own: one tap still saves.
*/
import { Gauge } from "lucide-react";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { SliderField } from "@/components/ui/slider";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getA1ForMood } from "@/lib/validations/mood";
import { useTranslations } from "@/lib/i18n/context";
/** The five values as the form holds them. `null` means unanswered. */
export interface LevelAState {
a1: number | null;
a2: number | null;
a3: number | null;
a4: number | null;
a5: number | null;
}
export const EMPTY_LEVEL_A: LevelAState = {
a1: null,
a2: null,
a3: null,
a4: null,
a5: null,
};
/** How many of the five carry an answer — the collapsed section's badge. */
export function levelAAnsweredCount(state: LevelAState): number {
return MOOD_DIMENSIONS.filter((d) => state[d.key] !== null).length;
}
/**
* The five values as the API takes them, with the unanswered ones left out.
*
* Omitted rather than sent as null on the create path: absent means "nothing
* to say", and the server writes NULL for it. Nothing here ever sends a
* default, which is the whole reason the columns are nullable.
*/
export function levelACreatePayload(
state: LevelAState,
): Partial<Record<keyof LevelAState, number>> {
const out: Partial<Record<keyof LevelAState, number>> = {};
for (const dimension of MOOD_DIMENSIONS) {
const value = state[dimension.key];
if (value !== null) out[dimension.key] = value;
}
return out;
}
/**
* The five values as the edit path takes them — nulls included.
*
* The edit path replaces rather than adds, so a value the user cleared has to
* travel as an explicit null. Sending nothing would leave the old answer in
* place and the dialog would lie about what it just saved.
*/
export function levelAUpdatePayload(state: LevelAState): LevelAState {
return { ...state };
}
/**
* Seed the state from an entry being edited.
*
* The reader is the API's own wire shape, so a row that has never carried
* these values arrives as `undefined` and lands as `null` — unanswered, which
* is what it is.
*/
export function levelAFromEntry(entry: {
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
}): LevelAState {
return {
a1: entry.a1 ?? null,
a2: entry.a2 ?? null,
a3: entry.a3 ?? null,
a4: entry.a4 ?? null,
a5: entry.a5 ?? null,
};
}
/**
* Re-seed pleasantness from a freshly picked face — but only while the user
* has not moved that slider themselves.
*
* This is the one interaction in the sheet that will confuse a later reader,
* so it is written down here rather than inferred from two call sites: picking
* a face suggests a pleasantness value, and moving the slider takes the
* suggestion over. After that the faces and the slider are independent, and in
* particular moving the slider never changes which face is lit — the two are
* different questions at different resolutions, not two views of one answer.
*/
export function seedA1FromMood(
state: LevelAState,
mood: string,
a1Touched: boolean,
): LevelAState {
if (a1Touched) return state;
return { ...state, a1: mood ? getA1ForMood(mood) : null };
}
interface MoodLevelASectionProps {
value: LevelAState;
onChange: (next: LevelAState) => void;
/** Called when the user moves pleasantness themselves, which ends the re-seeding. */
onA1Touched?: () => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function MoodLevelASection({
value,
onChange,
onA1Touched,
open,
onOpenChange,
}: MoodLevelASectionProps) {
const { t } = useTranslations();
return (
<SheetSection
title={t("mood.sectionLevelA")}
icon={<Gauge />}
open={open}
onOpenChange={onOpenChange}
summary={<SheetSectionCount count={levelAAnsweredCount(value)} />}
>
<div className="space-y-4" data-slot="mood-level-a">
<p className="text-muted-foreground text-xs">
{t("mood.sectionLevelAHelp")}
</p>
{MOOD_DIMENSIONS.map((dimension) => {
const current = value[dimension.key];
const low = t(dimension.lowAnchorKey);
const high = t(dimension.highAnchorKey);
return (
<SliderField
key={dimension.key}
label={t(dimension.labelKey)}
value={current}
min={dimension.min}
max={dimension.max}
step={1}
lowAnchor={low}
highAnchor={high}
emptyLabel={t("mood.dimensionNotAnswered")}
valueText={
current === null
? undefined
: t("mood.dimensionValueText", {
value: current,
max: dimension.max,
low,
high,
})
}
clearLabel={t("mood.dimensionClear")}
// Pleasantness has no clear control, and that is the same rule
// the server keeps: an entry always carries a five-point label,
// so it always implies a pleasantness value, and there is no
// state in which the field is honestly unanswered. Offering a
// control that the write path would immediately undo would be a
// button that quietly does nothing. The other four are genuinely
// optional and keep theirs.
onClear={
dimension.key === "a1"
? undefined
: () => onChange({ ...value, [dimension.key]: null })
}
onValueChange={(next) => {
if (dimension.key === "a1") onA1Touched?.();
onChange({ ...value, [dimension.key]: next });
}}
/>
);
})}
</div>
</SheetSection>
);
}
+36
View File
@@ -87,6 +87,13 @@ import {
} from "@/components/data-list";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import {
EMPTY_LEVEL_A,
MoodLevelASection,
levelAFromEntry,
levelAUpdatePayload,
type LevelAState,
} from "./mood-level-a-fields";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
@@ -104,6 +111,12 @@ interface MoodEntry {
// v1.8.5 — structured-tag keys + free-text note.
tagKeys: string[];
ratedFactors: RatedFactor[];
// v1.37 — the five level-A values, absent on entries written before them.
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
note: string | null;
source: string;
moodLoggedAt: string;
@@ -185,6 +198,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const [editTagsInput, setEditTagsInput] = useState("");
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
// v1.37 — the five level-A values on the edit path. Seeded from the entry,
// so what the capture sheet wrote is visible and changeable here.
const [editLevelA, setEditLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editSeed, setEditSeed] = useState<{
@@ -192,6 +208,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagsInput: string;
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
note: string;
moodLoggedAt: string;
}>({
@@ -199,6 +216,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagsInput: "",
tagKeys: [],
ratedFactors: [],
levelA: EMPTY_LEVEL_A,
note: "",
moodLoggedAt: "",
});
@@ -391,6 +409,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags,
tagKeys,
ratedFactors,
levelA,
note,
moodLoggedAt,
}: {
@@ -399,6 +418,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags: string[] | null;
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
note: string | null;
moodLoggedAt: string;
}) => {
@@ -407,6 +427,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags,
tagKeys,
ratedFactors,
// Nulls included: this path replaces rather than adds, so a value the
// user cleared has to travel as an explicit null or the old answer
// survives an edit that visibly removed it.
...levelAUpdatePayload(levelA),
note,
moodLoggedAt,
});
@@ -421,6 +445,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
ratedFactors: [...editRatedFactors].sort((a, b) =>
a.key.localeCompare(b.key),
),
levelA: editLevelA,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
});
@@ -439,6 +464,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
ratedFactors: [...editRatedFactors].sort((a, b) =>
a.key.localeCompare(b.key),
),
levelA: editLevelA,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
},
@@ -455,6 +481,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) =>
a.key.localeCompare(b.key),
),
levelA: levelAFromEntry(entry),
note: entry.note ?? "",
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
};
@@ -463,6 +490,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setEditTagsInput(seed.tagsInput);
setEditTagKeys(seed.tagKeys);
setEditRatedFactors(seed.ratedFactors);
setEditLevelA(seed.levelA);
setEditNote(seed.note);
setEditMoodLoggedAt(seed.moodLoggedAt);
setEditSeed(seed);
@@ -515,6 +543,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags: tags.length > 0 ? tags : null,
tagKeys: editTagKeys,
ratedFactors: editRatedFactors,
levelA: editLevelA,
note: trimmedNote.length > 0 ? trimmedNote : null,
moodLoggedAt: measuredDate.toISOString(),
});
@@ -1008,6 +1037,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
/>
</div>
{/* v1.37 — the five level-A values, seeded from the entry.
Pleasantness is not re-seeded from the face here: this dialog
edits an entry that already has an answer, and replacing it
with the derived one would quietly discard what the person
set. */}
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
+120
View File
@@ -0,0 +1,120 @@
/**
* `<Slider>` / `<SliderField>` — the shape and the unanswered state.
*
* The suite renders server-side (`@testing-library/react` is not a dependency
* here), so nothing below drags a thumb or presses an arrow key; the keyboard
* behaviour is Radix's own contract and the manual pass covers it. What is
* proven here is what the component decides: that an unanswered field renders
* no number and announces itself as unanswered rather than as the value its
* parked thumb sits on, that a zero is rendered as an answer, and that the
* anchors reach the markup as muted legend text.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Slider, SliderField } from "../slider";
function field(props: Partial<React.ComponentProps<typeof SliderField>> = {}) {
return renderToStaticMarkup(
<SliderField
label="Stress"
value={7}
onValueChange={() => {}}
emptyLabel="nicht angegeben"
valueText="7 von 10"
lowAnchor="vollkommen entspannt"
highAnchor="extrem belastet"
{...props}
/>,
);
}
describe("<Slider>", () => {
it("renders the Radix parts under their slots", () => {
const html = renderToStaticMarkup(
<Slider value={[4]} min={0} max={10} step={1} />,
);
expect(html).toContain('data-slot="slider"');
expect(html).toContain('data-slot="slider-track"');
expect(html).toContain('data-slot="slider-range"');
expect(html).toContain('data-slot="slider-thumb"');
});
it("announces the caller's text instead of the bare number", () => {
const html = renderToStaticMarkup(
<Slider
value={[6]}
min={0}
max={10}
valueText="6 von 10: sehr unangenehm bis sehr angenehm"
thumbLabel="Stimmung"
/>,
);
expect(html).toContain(
'aria-valuetext="6 von 10: sehr unangenehm bis sehr angenehm"',
);
expect(html).toContain('aria-label="Stimmung"');
// The thumb is a slider to assistive tech, with the bounds published.
// `aria-valuenow` is deliberately not asserted: Radix sets it after mount,
// so it is absent from server markup and a test demanding it here would be
// measuring the renderer rather than the component.
expect(html).toContain('role="slider"');
expect(html).toContain('aria-valuemin="0"');
expect(html).toContain('aria-valuemax="10"');
expect(html).toContain('aria-orientation="horizontal"');
});
});
describe("<SliderField>", () => {
it("renders the label, the value and both anchors", () => {
const html = field();
expect(html).toContain("Stress");
expect(html).toContain("vollkommen entspannt");
expect(html).toContain("extrem belastet");
expect(html).toContain('data-slot="slider-field-value"');
expect(html).toContain(">7<");
});
it("puts the answer in content colour and the anchors in muted", () => {
const html = field();
expect(html).toContain("tabular-nums");
expect(html).toContain("text-foreground");
expect(html).toContain("text-muted-foreground");
expect(html).toContain("text-xs");
});
it("renders an unanswered field as unanswered, not as its parked value", () => {
const html = field({ value: null, valueText: "should not be announced" });
expect(html).toContain('data-unset="true"');
expect(html).toContain("nicht angegeben");
// The thumb sits at the low end so the track reads empty, but nothing
// claims that low end as the person's answer.
expect(html).toContain('aria-valuetext="nicht angegeben"');
expect(html).not.toContain("should not be announced");
});
it("renders a zero as an answer", () => {
// Zero is a real point on this scale. A field that treated it as absence
// would show the empty label and lose an answer somebody gave.
const html = field({ value: 0, valueText: "0 von 10" });
expect(html).not.toContain('data-unset="true"');
expect(html).not.toContain("nicht angegeben");
expect(html).toContain('aria-valuetext="0 von 10"');
});
it("offers to clear an answer only when there is one to clear", () => {
const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" });
expect(set).toContain('data-slot="slider-field-clear"');
expect(set).toContain("zurücksetzen");
const unanswered = field({
value: null,
onClear: () => {},
clearLabel: "zurücksetzen",
});
expect(unanswered).not.toContain('data-slot="slider-field-clear"');
const noHandler = field();
expect(noHandler).not.toContain('data-slot="slider-field-clear"');
});
});
+167
View File
@@ -0,0 +1,167 @@
"use client";
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Numeric range entry.
*
* The alternative this replaces is eleven discrete buttons, which is what the
* rated-factor picker does for a 1-5 scale. That pattern does not survive the
* jump to 0-10: eleven 32 px targets with 4 px gaps come to roughly 392 px
* before the label, and the narrow phone this app is mostly used on is 390 px
* wide. A slider answers the same question in a fixed width at any scale, and
* arrow keys move it a step at a time, which eleven buttons never did.
*
* `Slider` is the bare control. `SliderField` is the labelled row around it —
* name on the left, value on the right, the two scale anchors underneath — and
* it is what a capture surface should reach for, because the anchors are what
* make a 7 mean anything.
*/
function Slider({
className,
valueText,
thumbLabel,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root> & {
/**
* What a screen reader announces instead of the bare number. A slider that
* says "6" tells somebody who cannot see the anchors nothing at all; the
* caller passes the number together with what the ends of the scale mean.
*/
valueText?: string;
/** Accessible name for the thumb when the field label is not adjacent. */
thumbLabel?: string;
}) {
return (
<SliderPrimitive.Root
data-slot="slider"
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50",
// The 44 px hit target WCAG 2.5.5 asks for, without a 44 px track:
// the padding is vertical only, so the visible rail stays 6 px.
"py-[19px]",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="bg-primary absolute h-full"
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
data-slot="slider-thumb"
aria-label={thumbLabel}
aria-valuetext={valueText}
className="border-primary bg-background focus-visible:ring-ring/50 block size-4 rounded-full border-2 shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none"
/>
</SliderPrimitive.Root>
);
}
/**
* A labelled slider with its scale anchors.
*
* `value` is nullable and the null is load-bearing: a question nobody answered
* is not a question answered in the middle. An unset field renders no number
* and fills no track, and only an interaction commits a value. `onClear`, when
* given, renders the control that takes an answer back — without it a user who
* nudged a slider by accident can never return the field to empty.
*/
function SliderField({
label,
value,
onValueChange,
onClear,
clearLabel,
emptyLabel,
valueText,
lowAnchor,
highAnchor,
min = 0,
max = 10,
step = 1,
className,
...props
}: {
label: string;
value: number | null;
onValueChange: (value: number) => void;
onClear?: () => void;
clearLabel?: string;
/** Rendered in place of the number while the field is unanswered. */
emptyLabel: string;
/** Announced by a screen reader; carries the anchors, not only the number. */
valueText?: string;
lowAnchor: string;
highAnchor: string;
min?: number;
max?: number;
step?: number;
className?: string;
} & Omit<
React.ComponentProps<typeof SliderPrimitive.Root>,
"value" | "onValueChange" | "min" | "max" | "step" | "className"
>) {
const unset = value === null;
return (
<div
data-slot="slider-field"
data-unset={unset ? "true" : undefined}
className={cn("flex flex-col gap-1", className)}
>
<div className="flex items-baseline justify-between gap-2">
{/* Content colour for the label and the value, muted for the anchors
below: the anchors are the legend, the value is the answer. */}
<span className="text-sm font-medium">{label}</span>
<span className="flex items-center gap-2">
<span
data-slot="slider-field-value"
className={cn(
"text-sm tabular-nums",
unset ? "text-muted-foreground" : "text-foreground",
)}
>
{unset ? emptyLabel : value}
</span>
{!unset && onClear ? (
<button
type="button"
onClick={onClear}
data-slot="slider-field-clear"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-xs underline-offset-2 outline-none hover:underline focus-visible:ring-[3px]"
>
{clearLabel}
</button>
) : null}
</span>
</div>
<Slider
// An unanswered field parks the thumb at the low end with an empty
// track, so it reads as "nothing here" rather than as the value the
// low end happens to carry. The announced text says the same.
value={[unset ? min : value]}
onValueChange={(next) => onValueChange(next[0])}
min={min}
max={max}
step={step}
thumbLabel={label}
valueText={unset ? emptyLabel : valueText}
{...props}
/>
<div className="text-muted-foreground flex justify-between gap-2 text-xs">
<span>{lowAnchor}</span>
<span className="text-right">{highAnchor}</span>
</div>
</div>
);
}
export { Slider, SliderField };
+48
View File
@@ -400,3 +400,51 @@ describe("formatMoodEntriesForExport with userTz", () => {
expect(result[0].loggedAt).toBe("2026-05-15T14:30:00-04:00");
});
});
describe("formatMoodEntriesForExport level-A columns", () => {
const base = {
date: "2026-05-15",
mood: "OKAY",
score: 3,
tags: null,
source: "MANUAL",
moodLoggedAt: new Date("2026-05-15T18:30:00Z"),
};
it("appends the five values without moving an existing column", () => {
const csv = toCSV(
formatMoodEntriesForExport([
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
]),
);
const [header, row] = csv.split("\n");
// The pre-existing columns keep their order and their positions. Somebody's
// spreadsheet has a formula pointed at column D.
expect(header).toBe(
"date,mood,score,tags,note,source,loggedAt,a1,a2,a3,a4,a5",
);
expect(row.split(",").slice(0, 3)).toEqual(["2026-05-15", "OKAY", "3"]);
});
it("writes an empty cell for an unanswered value and a zero for a zero", () => {
const csv = toCSV(
formatMoodEntriesForExport([
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
]),
);
const cells = csv.split("\n")[1].split(",");
// a1, a2, a3, a4, a5 — the last two were never answered.
expect(cells.slice(-5)).toEqual(["4", "9", "0", "", ""]);
});
it("leaves every level-A cell empty for an entry written before the columns", () => {
const csv = toCSV(formatMoodEntriesForExport([base]));
expect(csv.split("\n")[1].split(",").slice(-5)).toEqual([
"",
"",
"",
"",
"",
]);
});
});
+13 -12
View File
@@ -188,11 +188,12 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => {
});
});
// v1.18.10 (W7) — online-first resolver. The `ipwho.is` HTTPS lookup is the
// DEFAULT path; the bundled GeoLite2 offline tier is an OPTIONAL fallback
// consulted only when the online lookup misses (provider down / non-ok) or
// when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`.
describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => {
// Offline-first resolver: the GeoLite2 City MMDB answers when it is mounted
// and holds the address, and the `ipwho.is` HTTPS lookup only sees what it
// could not place. Between v1.18.10 and v1.37 it ran the other way round —
// these two cases pinned that order, and they are the reason a host with the
// databases mounted still sent every login IP to a third party.
describe("lookupIpLocation — offline-first city resolver", () => {
// Helper: a non-ok online response forces the offline fallback to run.
function onlineMiss(): Response {
return new Response("", {
@@ -201,7 +202,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () =>
});
}
it("prefers the online provider even when the offline DB is present", async () => {
it("prefers the offline DB over the online provider, and sends nothing", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
@@ -221,12 +222,13 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () =>
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
// Online wins by default; the offline München record is not consulted.
expect(await lookupIpLocation("85.214.0.1")).toBe("Berlin, DE");
expect(fetchSpy).toHaveBeenCalledTimes(1);
// The mounted database answers, so the provider is never asked and the
// address never leaves the host.
expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("falls back to the offline German exonym when the online lookup misses", async () => {
it("uses the German exonym the offline DB carries rather than the provider's fold", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
@@ -237,8 +239,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () =>
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
// Online was tried first (and missed), then the offline tier resolved.
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("offline fallback uses the English city name when the German exonym is absent", async () => {
+234
View File
@@ -0,0 +1,234 @@
/**
* The offline tier is consulted FIRST, and a local hit makes no request.
*
* From v1.18.10 until v1.37 the resolver queried the online provider first
* and read the MMDBs only when that missed. Every document describing the
* feature said the opposite — the contributor notes, the self-hosting runbook,
* the admin notification that tells an operator their login IPs are leaving
* the server. So an operator who mounted the databases in order to stop that
* egress kept every bit of it, and nothing said so: the location resolved, the
* admin overview filled in, and the only difference was where the address had
* been.
*
* The assertion that matters is therefore about the fetch that does NOT
* happen. Watched red: restore the old order (online lookup first, offline as
* the fallback) and the first two cases fail on a call count of 1.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// The MMDB readers are stubbed: `loadMmdbReader` only needs the file to
// exist, and what the reader answers is this table.
const cityRows = new Map<string, unknown>();
const asnRows = new Map<string, unknown>();
vi.mock("mmdb-lib", () => ({
Reader: class {
private readonly rows: Map<string, unknown>;
constructor(buf: Buffer) {
// The two readers are told apart by the stub file's own content, which
// is the only thing distinguishing them at construction time.
this.rows = buf.toString().startsWith("asn") ? asnRows : cityRows;
}
get(ip: string): unknown {
return this.rows.get(ip) ?? null;
}
},
}));
// safeFetch's requirePublicHost path runs through undici's own `fetch`.
// Delegate it to the global stub so an outbound call is visible either way.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findMany: vi.fn(async () => []) } },
}));
const ORIGINAL_ENV = { ...process.env };
let tmpRoot: string;
function jsonOk(value: unknown): Response {
return new Response(
new TextEncoder().encode(JSON.stringify(value)).buffer as ArrayBuffer,
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function writeCityDb(): void {
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
}
function writeAsnDb(): void {
writeFileSync(join(tmpRoot, "GeoLite2-ASN.mmdb"), Buffer.from("asn-stub"));
}
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IP_GEO_LOOKUP_URL;
delete process.env.IP_GEO_LOOKUP_DISABLED;
delete process.env.IP_GEO_ALLOW_INSECURE;
tmpRoot = mkdtempSync(join(tmpdir(), "healthlog-geo-offline-first-"));
process.env.GEOLITE2_DIR = tmpRoot;
cityRows.clear();
asnRows.clear();
vi.resetModules();
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("geo resolver is offline-first", () => {
it("makes no outbound request when the MMDBs place the address", async () => {
writeCityDb();
writeAsnDb();
cityRows.set("8.8.8.8", {
city: { names: { de: "Nürnberg", en: "Nuremberg" } },
country: { iso_code: "DE" },
});
asnRows.set("8.8.8.8", {
autonomous_system_number: 3320,
autonomous_system_organization: "Deutsche Telekom AG",
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("8.8.8.8")).toEqual({
location: "Nürnberg, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
});
expect(
fetchSpy,
"an address the local databases placed still left the host",
).not.toHaveBeenCalled();
});
it("makes no outbound request when only the City MMDB is mounted", async () => {
// The ASN database is a separate download and an operator may have only
// the City one. Reaching out for a carrier string would send the address
// off the host anyway, which is the thing the mount was for.
writeCityDb();
cityRows.set("9.9.9.9", {
city: { names: { en: "Zurich" } },
country: { iso_code: "CH" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("9.9.9.9")).toEqual({
location: "Zurich, CH",
asn: null,
carrier: null,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it("falls through to the online provider when the MMDB holds no row", async () => {
writeCityDb();
writeAsnDb();
// Databases present, this address simply not in them.
const fetchSpy = vi.fn().mockResolvedValue(
jsonOk({
success: true,
city: "Berlin",
country_code: "DE",
connection: { asn: 3209, isp: "Vodafone" },
}),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("1.1.1.1")).toEqual({
location: "Berlin, DE",
asn: 3209,
carrier: "Vodafone",
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("keeps the offline ASN authoritative when the location came from online", async () => {
writeCityDb();
writeAsnDb();
asnRows.set("1.1.1.1", {
autonomous_system_number: 13335,
autonomous_system_organization: "Cloudflare, Inc.",
});
const fetchSpy = vi.fn().mockResolvedValue(
jsonOk({
success: true,
city: "Berlin",
country_code: "DE",
connection: { asn: 3209, isp: "Vodafone" },
}),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("1.1.1.1")).toEqual({
location: "Berlin, DE",
asn: 13335,
carrier: "Cloudflare, Inc.",
});
});
it("leaves the online path unchanged when no MMDB is mounted", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValue(
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect(await lookupIpGeo("8.8.8.8")).toEqual({
location: "Berlin, DE",
asn: null,
carrier: null,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8");
});
it("resolves locally with IP_GEO_LOOKUP_DISABLED set, and never reaches the network without the databases", async () => {
// The kill switch is unchanged by the reordering: it disables step 3
// only, so an offline hit still resolves and an offline miss returns
// null rather than going online.
process.env.IP_GEO_LOOKUP_DISABLED = "1";
writeCityDb();
cityRows.set("8.8.8.8", {
city: { names: { en: "Vienna" } },
country: { iso_code: "AT" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpGeo } = await import("../geo");
expect((await lookupIpGeo("8.8.8.8")).location).toBe("Vienna, AT");
expect((await lookupIpGeo("1.1.1.1")).location).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+22
View File
@@ -1,3 +1,25 @@
/**
* The WELLBEING pillar reads validated instruments, and only those.
*
* PHQ-9, GAD-7 and WHO-5 over a 90-day window. Every threshold below cites
* the paper it comes from, which is the whole reason this pillar can be shown
* to a doctor.
*
* Daily mood does not feed it, and that is a decision rather than an
* oversight. Since v1.37 a mood entry carries a 0-10 self-rated pleasantness
* on most rows, including a mapped value on historical ones, so the number is
* available and folding it in would be a small change. It stays out: a
* self-rated slider moved once a day and a scored clinical questionnaire are
* different kinds of evidence, and averaging them into one figure that cites
* Kroenke, Spitzer and the WHO would weaken exactly the line that makes the
* figure worth anything. There is also no good answer to "why did my score
* drop because I logged a bad afternoon".
*
* Daily mood keeps its own surfaces — the trend page, the assistant, the
* briefing — where its resolution is the point. Bringing it in here would be
* a score decision, taken on the score line, with its own coverage rule and
* its own line in the explainer; it is not something to add on the way past.
*/
import type { Derived } from "@/lib/insights/derived/types";
import type { PillarValue, WellbeingPillarInput } from "./types";
+86 -14
View File
@@ -434,6 +434,46 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
AccountGrant: ["grantorId", "granteeId"],
};
/**
* Models the ADMIN wipe keeps although the per-account wipe deletes them.
*
* `DELETE /api/admin/data` is the same action pointed at every account at
* once: the operator clearing a box before handing it on. It therefore reads
* {@link WIPE_MODELS} rather than a list of its own — that list was written
* inline once and had drifted to nine tables against a schema of a hundred and
* twenty-two by the time anybody counted.
*
* The two actions differ in exactly one place, and it is written here rather
* than folded into the route so the difference has to be argued for.
*/
export const ADMIN_WIPE_EXEMPT: Readonly<Record<string, string>> = {
AccountGrant:
"a grant is the only way into a managed profile — that record has no credentials of its own, so deleting every grant at once would leave a set of accounts nobody can open, administer or delete. This action preserves accounts by definition, and an access edge without the record behind it is not personal data: everything those profiles hold is wiped with everyone else's.",
};
/**
* Every model the admin wipe deletes, for all accounts, children first.
*
* Derived, so a model added to {@link WIPE_MODELS} is in both wipes at once
* and a model excused from this one has to say why above.
*/
export const ADMIN_WIPE_MODELS: readonly string[] = WIPE_MODELS.filter(
(model) => !(model in ADMIN_WIPE_EXEMPT),
);
/**
* Budget for a wipe transaction.
*
* Prisma's default interactive-transaction timeout is 5 s, which was already
* optimistic for thirteen `deleteMany` statements and is not survivable for
* the full model set on an account with a large import behind it — let alone
* for the admin wipe, which runs the same set across every account. The whole
* action is one transaction on purpose: a half-applied wipe is worse than
* none, because the person is told it succeeded either way.
*/
export const WIPE_TRANSACTION_TIMEOUT_MS = 120_000;
export const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
/** Prisma client delegate key for a model name (`MoodEntry` → `moodEntry`). */
export function wipeDelegateKey(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
@@ -456,14 +496,37 @@ export interface UserScopedDeleteDelegate {
deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>;
}
/** The same slice for the admin wipe, which selects no account at all. */
export interface UnscopedDeleteDelegate {
deleteMany(args: Record<string, never>): Promise<{ count: number }>;
}
interface RawDeleteDelegate {
deleteMany(args: { where?: object }): Promise<{ count: number }>;
}
/**
* The Prisma delegate for a model name, or a thrown error naming the model.
*
* One documented cast: {@link WIPE_MODELS} is a literal list of model names
* checked against `schema.prisma` by the completeness test, and every entry
* indexes a delegate that exposes `deleteMany`. The test also asserts every
* entry resolves, so an unresolvable name fails the build rather than the
* request.
*/
function rawDelegate(client: object, model: string): RawDeleteDelegate {
const delegate = (client as Record<string, RawDeleteDelegate>)[
wipeDelegateKey(model)
];
if (!delegate || typeof delegate.deleteMany !== "function") {
throw new Error(`No Prisma delegate for wipe model "${model}"`);
}
return delegate;
}
/**
* Resolve a model name to its delegate on a client or transaction handle.
*
* One documented cast: `WIPE_MODELS` is a literal list of model names checked
* against `schema.prisma` by the completeness test, and every entry indexes a
* delegate that exposes `deleteMany`. The test also asserts every entry
* resolves, so an unresolvable name fails the build rather than the request.
*
* A model listed in {@link WIPE_OWNER_FIELDS} gets a wrapper that rewrites the
* caller's `{ userId }` into that model's own ownership predicate. The caller
* still says "this account's rows" and still gets a count back; only the
@@ -474,15 +537,7 @@ export function resolveWipeDelegate(
client: object,
model: string,
): UserScopedDeleteDelegate {
const delegate = (
client as Record<
string,
{ deleteMany(args: { where: object }): Promise<{ count: number }> }
>
)[wipeDelegateKey(model)];
if (!delegate || typeof delegate.deleteMany !== "function") {
throw new Error(`No Prisma delegate for wipe model "${model}"`);
}
const delegate = rawDelegate(client, model);
if (!WIPE_OWNER_FIELDS[model]) {
return delegate as UserScopedDeleteDelegate;
}
@@ -491,3 +546,20 @@ export function resolveWipeDelegate(
delegate.deleteMany({ where: wipeWhere(model, where.userId) }),
};
}
/**
* Resolve a model name to a delegate that deletes every row of it.
*
* The admin wipe asks a different question from the per-account one — "every
* row of this model" rather than "this account's rows" — so it gets its own
* resolver instead of a `where` the caller has to remember to leave out.
* {@link WIPE_OWNER_FIELDS} is irrelevant here by construction: with no
* account to select, a row that belongs to two of them is deleted once.
*/
export function resolveGlobalWipeDelegate(
client: object,
model: string,
): UnscopedDeleteDelegate {
const delegate = rawDelegate(client, model);
return { deleteMany: () => delegate.deleteMany({}) };
}
+15
View File
@@ -336,6 +336,12 @@ export function formatMoodEntriesForExport(
note?: string | null;
source: string;
moodLoggedAt: Date;
/** The five level-A values, absent on entries written before they existed. */
moodA1?: number | null;
stressA2?: number | null;
energyA3?: number | null;
connectionA4?: number | null;
stabilityA5?: number | null;
}>,
userTz?: string,
): ExportableRecord[] {
@@ -347,5 +353,14 @@ export function formatMoodEntriesForExport(
note: e.note ?? "",
source: e.source,
loggedAt: formatTimestamp(e.moodLoggedAt, userTz),
// Appended after the existing columns rather than slotted in beside
// `score`, because this file lands in somebody's spreadsheet and a column
// that moves breaks every formula pointed at it. An unanswered value is an
// empty cell, not a zero: zero is a real answer on this scale.
a1: e.moodA1 ?? "",
a2: e.stressA2 ?? "",
a3: e.energyA3 ?? "",
a4: e.connectionA4 ?? "",
a5: e.stabilityA5 ?? "",
}));
}
+14
View File
@@ -117,6 +117,11 @@ const MOOD_ENTRY_BACKUP_SELECT = {
date: true,
mood: true,
score: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
tags: true,
note: true,
noteEncrypted: true,
@@ -480,6 +485,15 @@ export async function buildFullBackupPayload(
date: moodEntry.date,
mood: moodEntry.mood,
score: moodEntry.score,
// The five level-A values, under the keys the write path takes. Both
// arms carry them: they are the entry's own answers, not a storage
// detail, and a portable export that dropped them would hand back a
// file describing the day less well than the row it came from.
a1: moodEntry.moodA1,
a2: moodEntry.stressA2,
a3: moodEntry.energyA3,
a4: moodEntry.connectionA4,
a5: moodEntry.stabilityA5,
tags: moodEntry.tags,
source: moodEntry.source,
loggedAt: moodEntry.moodLoggedAt.toISOString(),
+60 -44
View File
@@ -1,18 +1,24 @@
/**
* IP geolocation lookup for audit-log enrichment.
*
* v1.18.10 (W7): online-first resolver. The `ipwho.is` HTTPS lookup
* (free, no key) is the DEFAULT path — every self-host resolves a
* location out of the box with no MaxMind licence configured. The
* bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb`
* is an OPTIONAL fallback (perspective: removed later): it is consulted
* only when the online lookup misses (provider down / rate-limited) or
* when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`.
* OFFLINE-FIRST resolver. The MaxMind GeoLite2 MMDBs at `/opt/geolite2/`
* (or wherever `GEOLITE2_DIR` points) answer whenever they can, and the
* online provider only ever sees an address they could not place. That
* ordering is the whole reason an operator mounts the databases: a lookup
* that resolves locally sends nothing anywhere. The databases stay OPTIONAL
* — a self-host without them resolves every location through the online
* provider, which needs no licence and works out of the box.
*
* 1. Private / loopback / opt-out → null (no lookup).
* 1. Private / loopback → null (no lookup).
* 2. Per-IP cache hit → return immediately.
* 3. Online `ipwho.is` lookup → return on success.
* 4. Offline MMDB present → local read fallback on an online miss.
* 3. Offline MMDB present → local read; on a hit the request ends here.
* 4. Online lookup → only on an offline miss, and only when
* `IP_GEO_LOOKUP_DISABLED` is not set.
*
* It was the other way round from v1.18.10 until v1.37: online first, the
* local databases as the fallback. The documentation described this order the
* whole time, so a host with the MMDBs mounted was sending every login IP to a
* third party while its own runbook said it was not.
*
* The legacy contract still holds: `lookupIpLocation` returns a
* `"City, CC"` string or `null`, never throws. `lookupIpGeo` is the
@@ -121,8 +127,9 @@ const PRIVATE_IP =
// the ip-api.com shape (`status` + `countryCode`), so operators whose egress
// ipwho.is rejects can point `IP_GEO_LOOKUP_URL` at `https://ip-api.com/json`
// (or any keyed provider matching one of those shapes) with no code change.
// The offline GeoLite2 MMDB tier stays OPTIONAL — consulted only when the
// online lookup misses, and skipped entirely when the databases are absent.
// The offline GeoLite2 MMDB tier stays OPTIONAL, but it goes FIRST when it is
// there: this provider is consulted only for an address the local databases
// could not place, and not at all on a host that has them and hits.
const DEFAULT_GEO_URL = "https://ipwho.is";
// The host portion of DEFAULT_GEO_URL — the provider name the admin status
@@ -261,10 +268,10 @@ export function __resetGeoLite2CacheForTests(): void {
//
// The CI workflow drops an `.empty` marker into the geo asset directory
// when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in
// which case the City + ASN MMDBs are absent and every lookup hits the
// online fallback. `offlineGeoReady()` is the canonical truth used by
// `/api/version` and the admin status surface so the two paths cannot
// disagree.
// which case the City + ASN MMDBs are absent and every lookup goes to the
// online provider. `offlineGeoReady()` is the canonical truth used by
// `/api/version`, the admin status surface, and the resolver's own
// offline-first branch, so the three cannot disagree.
//
// `notifiedThisProcess` is a module-level latch so the admin only
// receives the "offline geo is disabled" notification once per worker
@@ -313,7 +320,7 @@ async function notifyOfflineGeoUnavailable(): Promise<void> {
return;
}
getEvent()?.addWarning(
"geo: offline databases unavailable, falling back to ipwho.is — notifying admins",
`geo: offline databases unavailable, every lookup goes online to ${resolveGeoProviderHost()} — notifying admins`,
);
for (const admin of admins) {
await dispatchLocalisedNotification({
@@ -507,15 +514,24 @@ async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
/**
* Resolve location + carrier + AS number for an IP in a single pass.
*
* v1.25.8 — the online provider lookup now also yields the carrier, so a
* self-host without the optional offline GeoLite2-ASN MMDB still surfaces a
* network operator in the admin sign-in overview. The merge order:
* OFFLINE FIRST. The order below is the one the documentation has always
* described and the code did not do: for two releases the online provider was
* queried first and the local databases were the fallback, so an operator who
* had mounted the MMDBs precisely so that no address would leave the host was
* sending every login IP to a third party anyway. The MMDBs answer; the online
* provider only sees an address the local databases could not place.
*
* 1. Online lookup (one request) → location + carrier + ASN.
* 2. Offline City MMDB → location fallback on an online miss (if present).
* 3. Offline ASN MMDB → AUTHORITATIVE carrier/ASN when present (it carries
* the canonical org name the short-label folder expects); the online
* carrier is the fallback so a host without the DBs still resolves one.
* 1. Offline ASN MMDB → carrier + AS number when configured (authoritative:
* it carries the canonical org name the short-label folder expects).
* 2. Offline City MMDB → location. Present and resolving means the request
* ends here, with no egress at all.
* 3. Online lookup → only on an offline miss (databases absent, unreadable,
* or holding no row for this address). Fills the location, and the
* carrier/ASN when the offline tier had none, so a host without the
* databases still surfaces an operator in the admin sign-in overview.
*
* `IP_GEO_LOOKUP_DISABLED=1` remains the full off-switch for step 3 — with it
* set, an offline miss resolves to null rather than reaching the network.
*
* Never throws; the auth-audit caller is fire-and-forget. The resolved
* record (including all-null misses) is cached per IP for a bounded TTL.
@@ -527,28 +543,28 @@ export async function lookupIpGeo(ip: string | null): Promise<GeoResolved> {
const cached = getCachedGeo(ip);
if (cached) return cached;
// v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup
// is the primary resolver for every self-host: it needs no MaxMind licence
// and resolves out of the box. The bundled GeoLite2 offline tier is an
// OPTIONAL fallback, so a missing offline tier is the expected baseline.
const online = await lookupIpOnline(ip);
let location = online?.location ?? null;
let asn = online?.asn ?? null;
let carrier = online?.carrier ?? null;
// Offline City MMDB fills the location when the online tier missed it.
if (!location && offlineGeoReady()) {
location = lookupIpLocationOffline(ip);
}
// Offline ASN MMDB is authoritative for the carrier/ASN when configured;
// prefer it over the online ISP string but keep the online value as the
// fallback. `lookupIpAsn` also fires the one-shot "no resolver" admin alert
// when neither an offline ASN reader nor the offline tier is present.
// Offline ASN MMDB first, and unconditionally: it is a local file read, and
// it is also what fires the one-shot "no offline resolver" admin alert when
// neither an ASN reader nor the offline tier is present.
let asn: number | null = null;
let carrier: string | null = null;
const offlineAsn = lookupIpAsn(ip);
if (offlineAsn) {
asn = offlineAsn.asn;
if (offlineAsn.carrier) carrier = offlineAsn.carrier;
carrier = offlineAsn.carrier;
}
let location = offlineGeoReady() ? lookupIpLocationOffline(ip) : null;
// The address the local databases could not place is the only one that
// travels. A host with the MMDBs mounted and a hit makes no request at all.
if (!location) {
const online = await lookupIpOnline(ip);
if (online) {
location = online.location;
if (asn === null) asn = online.asn;
if (!carrier) carrier = online.carrier;
}
}
const resolved: GeoResolved = { location, asn, carrier };
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
computeMoodDimensionSeries,
type MoodDimensionRow,
} from "@/lib/insights/mood-dimension-series";
const TODAY = "2026-08-08";
function dayKey(daysAgo: number): string {
const d = new Date(Date.parse(`${TODAY}T00:00:00.000Z`));
d.setUTCDate(d.getUTCDate() - daysAgo);
return d.toISOString().slice(0, 10);
}
function row(daysAgo: number, values: Partial<MoodDimensionRow>) {
return {
date: dayKey(daysAgo),
a1: null,
a2: null,
a3: null,
a4: null,
a5: null,
...values,
} as MoodDimensionRow;
}
describe("computeMoodDimensionSeries", () => {
it("reports all five dimensions even when only one was ever answered", () => {
const out = computeMoodDimensionSeries([row(0, { a1: 7 })], TODAY);
expect(out.map((d) => d.key)).toEqual(["a1", "a2", "a3", "a4", "a5"]);
expect(out[0].present).toBe(true);
// The other four are absent, not zero. This is the whole rule.
for (const dimension of out.slice(1)) {
expect(dimension.present).toBe(false);
expect(dimension.count).toBe(0);
expect(dimension.avg7).toBeNull();
expect(dimension.avg30).toBeNull();
expect(dimension.avg90).toBeNull();
expect(dimension.latest).toBeNull();
expect(dimension.newestDaysAgo).toBeNull();
expect(dimension.series).toEqual([]);
}
});
it("treats a dimension answered with zeros as present", () => {
// Zero is a real answer on this scale. A presence check on truthiness
// would report a genuinely calm week as no data at all.
const out = computeMoodDimensionSeries(
[row(0, { a2: 0 }), row(1, { a2: 0 })],
TODAY,
);
const a2 = out.find((d) => d.key === "a2")!;
expect(a2.present).toBe(true);
expect(a2.count).toBe(2);
expect(a2.avg7).toBe(0);
expect(a2.latest).toBe(0);
});
it("averages several entries on the same day into one point", () => {
const out = computeMoodDimensionSeries(
[row(1, { a1: 4 }), row(1, { a1: 8 }), row(0, { a1: 6 })],
TODAY,
);
const a1 = out[0];
expect(a1.series).toEqual([
{ date: dayKey(1), value: 6, samples: 2 },
{ date: dayKey(0), value: 6, samples: 1 },
]);
// The daily mean is the unit every other mood surface reports, so a
// number here can be held against one there.
expect(a1.count).toBe(3);
});
it("windows the means at 7, 30 and 90 days", () => {
const out = computeMoodDimensionSeries(
[
row(1, { a3: 10 }), // inside all three
row(20, { a3: 4 }), // inside 30 and 90
row(60, { a3: 1 }), // inside 90 only
row(200, { a3: 0 }), // outside every window
],
TODAY,
);
const a3 = out.find((d) => d.key === "a3")!;
expect(a3.avg7).toBe(10);
expect(a3.avg30).toBe(7);
expect(a3.avg90).toBe(5);
// The out-of-window row contributes to nothing, not even the count.
expect(a3.count).toBe(3);
expect(a3.series).toHaveLength(3);
});
it("carries the newest value with its age, per dimension", () => {
// The point of a per-dimension age: stress can be a week stale while
// pleasantness was answered this morning.
const out = computeMoodDimensionSeries(
[row(0, { a1: 8 }), row(6, { a2: 9 })],
TODAY,
);
const a1 = out.find((d) => d.key === "a1")!;
const a2 = out.find((d) => d.key === "a2")!;
expect(a1.latest).toBe(8);
expect(a1.latestDate).toBe(dayKey(0));
expect(a1.newestDaysAgo).toBe(0);
expect(a2.latest).toBe(9);
expect(a2.newestDaysAgo).toBe(6);
});
it("carries the orientation rather than flipping the stored value", () => {
const out = computeMoodDimensionSeries([row(0, { a2: 9 })], TODAY);
const a2 = out.find((d) => d.key === "a2")!;
expect(a2.inverse).toBe(true);
// Stored literally: a 9 the user set is a 9 here. Flipping on the way
// through would make the chart disagree with the label they answered.
expect(a2.latest).toBe(9);
expect(out.filter((d) => d.inverse).map((d) => d.key)).toEqual(["a2"]);
});
it("ignores a malformed day key and a future-dated row", () => {
const out = computeMoodDimensionSeries(
[
{ date: "not-a-date", a1: 9, a2: null, a3: null, a4: null, a5: null },
row(-3, { a1: 1 }),
row(2, { a1: 5 }),
],
TODAY,
);
const a1 = out[0];
expect(a1.count).toBe(1);
expect(a1.latest).toBe(5);
});
it("answers five empty summaries for no rows at all", () => {
const out = computeMoodDimensionSeries([], TODAY);
expect(out).toHaveLength(5);
expect(out.every((d) => !d.present)).toBe(true);
});
});
@@ -0,0 +1,111 @@
/**
* v1.37 — per-dimension staleness on the level-A blocks.
*
* The failure this exists to prevent has already happened once on the vitals
* surfaces: a value was handed to the narrator with no notion of when it was
* taken, and last week's reading came back as this morning's. The five
* dimensions make that failure easier, not harder, because their ages differ
* — somebody taps a face every morning and opens the detail sliders once a
* week, so pleasantness is fresh while stress is six days old.
*
* The rule under test: every dimension carries its own age, and `current`
* answers "may this back a present-tense sentence" through the one shared
* freshness rule rather than through a comparison written here.
*/
import { describe, expect, it } from "vitest";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import {
TODAY_CLAIM_MAX_AGE_DAYS,
isCurrentForTodayClaim,
} from "@/lib/insights/measurement-freshness";
const TODAY = "2026-08-08";
function dayKey(daysAgo: number): string {
const d = new Date(Date.parse(`${TODAY}T00:00:00.000Z`));
d.setUTCDate(d.getUTCDate() - daysAgo);
return d.toISOString().slice(0, 10);
}
/** The shape `buildMoodDimensionFeatures` derives, without a database. */
function blocksFor(
rows: Array<{ daysAgo: number; a1?: number; a2?: number }>,
): Array<{
key: string;
latest: number | null;
newestDaysAgo: number | null;
current: boolean;
}> {
const summaries = computeMoodDimensionSeries(
rows.map((r) => ({
date: dayKey(r.daysAgo),
a1: r.a1 ?? null,
a2: r.a2 ?? null,
a3: null,
a4: null,
a5: null,
})),
TODAY,
);
return summaries
.filter((s) => s.present)
.map((s) => ({
key: s.key,
latest: s.latest,
newestDaysAgo: s.newestDaysAgo,
current: isCurrentForTodayClaim(s.newestDaysAgo),
}));
}
describe("level-A staleness", () => {
it("calls this morning's pleasantness current and a five-day-old stress stale", () => {
// The fixture the whole file is about: one entry today with a mood, one
// five days ago that also answered stress.
const blocks = blocksFor([
{ daysAgo: 0, a1: 8 },
{ daysAgo: 5, a1: 4, a2: 9 },
]);
const a1 = blocks.find((b) => b.key === "a1")!;
const a2 = blocks.find((b) => b.key === "a2")!;
expect(a1.newestDaysAgo).toBe(0);
expect(a1.current).toBe(true);
expect(a2.latest).toBe(9);
expect(a2.newestDaysAgo).toBe(5);
// The claim under test: nothing may say "your stress is high today" from
// this row. It is history and is stated with its date.
expect(a2.current).toBe(false);
});
it("never carries a latest without an age beside it", () => {
// The structural half of the fix. A `latest` alone is exactly what
// produced the wrong sentence, so the shape is asserted, not the value.
for (const block of blocksFor([{ daysAgo: 2, a1: 5, a2: 5 }])) {
expect(block.latest).not.toBeNull();
expect(block.newestDaysAgo).not.toBeNull();
expect(typeof block.current).toBe("boolean");
}
});
it("treats an unknown age as stale rather than as fresh", () => {
// A missing age is not a fresh one. This is the shared rule's own
// behaviour and is asserted here so a local reimplementation that
// defaulted to `true` would be caught.
expect(isCurrentForTodayClaim(null)).toBe(false);
expect(isCurrentForTodayClaim(undefined)).toBe(false);
});
it("puts the boundary exactly where the shared rule puts it", () => {
// Yesterday still backs a present claim; the day before does not. Read
// off the exported constant rather than the literal 1, so a change to the
// window moves this test with it instead of stranding it.
const fresh = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS, a1: 6 }]);
expect(fresh[0].current).toBe(true);
const stale = blocksFor([{ daysAgo: TODAY_CLAIM_MAX_AGE_DAYS + 1, a1: 6 }]);
expect(stale[0].current).toBe(false);
});
});
+186
View File
@@ -13,6 +13,11 @@ import {
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -64,6 +69,158 @@ interface DataCoverage {
newestDaysAgo: number;
}
/**
* One level-A dimension as the model reads it.
*
* `latest` never travels without `newestDaysAgo` and `current` beside it. A
* value with no age is exactly what produced a reading from last week stated
* as this morning's, so the age is part of the shape rather than something a
* caller is trusted to look up.
*/
export interface MoodDimensionFeature {
key: string;
/** The wording behind the numbers, so a 7 is not read as 7 out of 5. */
scale: string;
/** True when a higher number means a worse day — stress, and only stress. */
inverse: boolean;
avg7: number | null;
avg30: number | null;
latest: number | null;
/** The local day the newest value belongs to. */
latestDate: string | null;
/** Whole days between that day and today. */
newestDaysAgo: number | null;
/**
* Whether that value may back a present-tense claim. False for an absent
* age too: a missing age is not a fresh one.
*/
current: boolean;
/**
* Present only when `current` is false, and says in words what the flag
* says in a boolean. The model reads prose more reliably than it reads a
* field name, and a stale dimension narrated as today's is the one failure
* this block exists to prevent.
*/
staleNotice?: string;
count: number;
}
/**
* The level-A blocks for the model, read once over a bounded window.
*
* The rollup tier caches the mean of `score` and nothing else, so this is its
* own read rather than a widened rollup: a cached tier for four columns that
* may all be NULL would cost a migration to answer a question the live read
* answers in one indexed range scan. If a surface measurably slows, the tier
* grows then, as a read-swap with a fallback on a coverage miss — never as a
* second engine running beside this one.
*
* Only dimensions somebody answered are returned. An empty block would invite
* a sentence about a question that was never asked.
*
* A STALE dimension is still returned, carrying its age and an instruction,
* rather than filtered out the way the health-status surfaces drop a reading
* too old to describe. That is a deliberate difference and worth the sentence:
* those surfaces write one line about one metric, so a value they cannot
* narrate has nothing left to contribute and dropping it is the whole fix.
* This block is context for an open conversation, where "your stress was 9 on
* the third, and you have not answered it since" is a useful and true thing to
* say — and where silence is worse than history, because the reader then has
* no way to tell an unanswered dimension from one nobody has looked at in a
* week. The safety therefore rides on `current` plus `staleNotice` rather than
* on absence, and both are asserted at the shipped composition in
* `tests/integration/mood-dimension-features.test.ts`.
*/
async function buildMoodDimensionFeatures(
userId: string,
): Promise<MoodDimensionFeature[] | undefined> {
const tz = await resolveUserTimezone(userId);
const { t } = getServerTranslator("en");
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const rows = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: 2000,
select: {
date: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
},
});
if (rows.length === 0) return undefined;
// The same calculator the trend page uses, so the number in a Coach
// sentence and the number on the chart cannot disagree.
const summaries = computeMoodDimensionSeries(
rows.map((row) => ({
date: row.date,
a1: row.moodA1,
a2: row.stressA2,
a3: row.energyA3,
a4: row.connectionA4,
a5: row.stabilityA5,
})),
userDayKey(new Date(), tz),
);
const blocks = summaries
.filter((summary) => summary.present)
.map((summary) => {
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key)!;
const current = isCurrentForTodayClaim(summary.newestDaysAgo);
return {
key: summary.key,
// The anchors are resolved through the bundle rather than repeated
// here: the model must read the same words the person read when they
// moved the slider, and a second copy of that wording would drift
// from the one on screen the first time either was edited. English,
// like the legacy `scale` line beside it.
scale: `${t(dimension.labelKey)}, 0-10: 0 = ${t(dimension.lowAnchorKey)}, 10 = ${t(dimension.highAnchorKey)}`,
inverse: summary.inverse,
avg7: summary.avg7,
avg30: summary.avg30,
latest: summary.latest,
latestDate: summary.latestDate,
newestDaysAgo: summary.newestDaysAgo,
current,
...(current
? {}
: {
staleNotice: summary.latestDate
? `Last answered on ${summary.latestDate}. State it as history with that date; do not describe it as today.`
: "No dated value. Do not describe this dimension as current.",
}),
count: summary.count,
};
});
return blocks.length > 0 ? blocks : undefined;
}
/**
* The dimensions block and the note that says how to read it beside the
* block-level `asOf`. Absent entirely when nobody has answered a dimension,
* so an account that only ever taps a face carries neither key.
*/
async function buildMoodDimensionBlock(userId: string): Promise<{
dimensions?: MoodDimensionFeature[];
dimensionsAsOfNote?: string;
}> {
const dimensions = await buildMoodDimensionFeatures(userId);
if (!dimensions) return {};
return {
dimensions,
dimensionsAsOfNote:
"The block-level asOf describes the newest mood ENTRY on the 1-5 axis. " +
"For the five dimensions below, each carries its own newestDaysAgo and " +
"current, and those are the ones that govern what may be said about " +
"them today; they are often older than the entry itself.",
};
}
export interface AggregatedFeatures {
weight?: {
latest: number;
@@ -161,6 +318,34 @@ export interface AggregatedFeatures {
trend30: "improving" | "declining" | "stable" | null;
totalEntries: number;
coverage: DataCoverage;
/**
* v1.37 — the five level-A self-state values, one block each, and only for
* the dimensions somebody actually answered. `scale` above describes the
* legacy 1-5 axis and is handed to the model verbatim, so it is left
* exactly as it was; these carry their own scale line beside it.
*
* Every block states its own age, because the ages differ: stress can be
* six days old while pleasantness was answered this morning, and a model
* given one age for the pair narrates the stale one as today's.
*/
dimensions?: MoodDimensionFeature[];
/**
* Says which age governs which claim, because two of them ride in the
* same block and they disagree by design.
*
* `annotateSnapshotFreshness` stamps every metric block with an `asOf`
* built from `coverage.newestDaysAgo`, which for mood is the age of the
* newest ENTRY — the 1-5 axis. Somebody who taps a face each morning and
* opens the sliders once a week has `asOf.daysAgo` of 0 and a stress
* reading six days old, so a reader taking the block-level stamp as
* covering the dimensions would state last week's answer as today's.
* That is the exact sentence this milestone exists to prevent, and it is
* cheaper to name the precedence than to leave two true numbers looking
* like a contradiction.
*
* Present only alongside `dimensions`.
*/
dimensionsAsOfNote?: string;
};
sleep?: {
avg7: number | null;
@@ -1055,6 +1240,7 @@ export async function extractFeatures(
latest: moodLatestScore,
trend30,
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
+49
View File
@@ -35,6 +35,15 @@ import {
computeMoodNarratives,
type MoodNarrative,
} from "@/lib/insights/mood-narratives";
import {
computeMoodDimensionSeries,
type MoodDimensionSummary,
} from "@/lib/insights/mood-dimension-series";
export {
computeMoodDimensionSeries,
type MoodDimensionPoint,
type MoodDimensionSummary,
} from "@/lib/insights/mood-dimension-series";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
@@ -162,6 +171,16 @@ export interface MoodAggregateEntry {
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
/**
* v1.37 — the five level-A values, each 0-10 and each nullable. Optional so
* every fixture written before they existed keeps its narrow shape, and
* nullable because an unanswered dimension is absent rather than average.
*/
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
/**
* v1.14.0 — per-entry RATED factor scores (work / sleep-quality /
* stress …) from the same `mood_entry_tag_links` join, but carrying the
@@ -414,6 +433,13 @@ export interface MoodAggregates {
cells: HeatmapCell[];
};
distribution: DistributionRow[];
/**
* v1.37 — one summary per level-A dimension: 7 / 30 / 90-day means, the
* newest value with its age, and the daily series behind them. A dimension
* nobody answered reports `present: false` and an empty series; the surface
* says so rather than drawing a line through the middle of the scale.
*/
dimensions: MoodDimensionSummary[];
weekday: WeekdayRow[];
/**
* v1.9.0 — average mood per part of day, bucketed in each entry's own
@@ -578,6 +604,17 @@ export function computeMoodAggregates(args: {
cells: computeHeatmapCells(entries, now, windowDays),
},
distribution: computeDistribution(moodDaily),
dimensions: computeMoodDimensionSeries(
entries.map((entry) => ({
date: entry.date,
a1: entry.a1 ?? null,
a2: entry.a2 ?? null,
a3: entry.a3 ?? null,
a4: entry.a4 ?? null,
a5: entry.a5 ?? null,
})),
dayOffsetToBerlinDayKey(now, 0, tz),
),
weekday,
timeOfDay,
stability,
@@ -624,6 +661,13 @@ export async function fetchMoodAggregates(
select: {
date: true,
score: true,
// v1.37 — the five level-A values. Plaintext columns, so the trend is
// a plain read rather than a decrypt-per-row.
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
tags: true,
moodLoggedAt: true,
// v1.9.0 — per-row IANA tz anchors the part-of-day bucketing in
@@ -664,6 +708,11 @@ export async function fetchMoodAggregates(
return rows.reverse().map((row) => ({
date: row.date,
score: row.score,
a1: row.moodA1,
a2: row.stressA2,
a3: row.energyA3,
a4: row.connectionA4,
a5: row.stabilityA5,
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
+138
View File
@@ -0,0 +1,138 @@
/**
* v1.37 — per-dimension mood series over the level-A columns.
*
* A sibling calculator of `mood-aggregates`, following that file's own
* structure: pure functions over already-fetched rows, no database, no i18n.
* The hub reads the rows once and hands them here, which is what keeps a
* second engine from appearing — a parallel `summarize()` of mood that
* disagreed with the dashboard's is the failure this project has already
* paid for once.
*
* The rule the whole module is built around: a dimension nobody answered is
* absent, not zero and not a flat line through the middle. On a 0-10
* self-rating every value in the range is a real answer, so a fabricated one
* is indistinguishable from a given one — and five is the answer somebody
* gives on a genuinely average day.
*/
import {
MOOD_DIMENSIONS,
MOOD_DIMENSION_WINDOWS,
type MoodDimensionKey,
} from "@/lib/mood/dimensions";
import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness";
import { round } from "@/lib/insights/status-shared";
/** One day's mean for one dimension. */
export interface MoodDimensionPoint {
/** `YYYY-MM-DD`, the row's own local day key. */
date: string;
value: number;
samples: number;
}
export interface MoodDimensionSummary {
key: MoodDimensionKey;
/** True when at least one entry in the window carries a value. */
present: boolean;
/** Higher is worse — the analytics layer flips through this, never inline. */
inverse: boolean;
min: number;
max: number;
/** Number of entries carrying a value, across the whole window. */
count: number;
/** Mean over the trailing 7 / 30 / 90 days; null when that window is empty. */
avg7: number | null;
avg30: number | null;
avg90: number | null;
/** The most recent value, and how old it is in whole local days. */
latest: number | null;
latestDate: string | null;
newestDaysAgo: number | null;
/** Daily means, oldest first, over the longest window. */
series: MoodDimensionPoint[];
}
/** A mood row as this calculator needs it. */
export interface MoodDimensionRow {
date: string;
a1: number | null;
a2: number | null;
a3: number | null;
a4: number | null;
a5: number | null;
}
function meanOf(values: number[]): number | null {
if (values.length === 0) return null;
const sum = values.reduce((acc, v) => acc + v, 0);
return round(sum / values.length, 1);
}
/**
* Summarise each of the five dimensions over the rows given.
*
* `rows` are the window's entries in any order; `todayKey` is the reader's own
* local day, already resolved by the caller, so nothing here converts a zone.
* Each entry contributes at most one value per dimension, and days with
* several entries are averaged — the same daily-mean shape every other mood
* surface reports, so a number here can be compared with one there.
*/
export function computeMoodDimensionSeries(
rows: MoodDimensionRow[],
todayKey: string,
): MoodDimensionSummary[] {
const longest = Math.max(...MOOD_DIMENSION_WINDOWS);
return MOOD_DIMENSIONS.map((dimension) => {
const byDay = new Map<string, number[]>();
for (const row of rows) {
const value = row[dimension.key];
if (typeof value !== "number" || !Number.isFinite(value)) continue;
const age = dayKeyAgeInDays(row.date, todayKey);
if (age === null || age < 0 || age >= longest) continue;
const bucket = byDay.get(row.date);
if (bucket) bucket.push(value);
else byDay.set(row.date, [value]);
}
const series: MoodDimensionPoint[] = [...byDay.entries()]
.map(([date, values]) => ({
date,
value: meanOf(values) as number,
samples: values.length,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const count = [...byDay.values()].reduce((acc, v) => acc + v.length, 0);
const windowMean = (days: number): number | null => {
const values: number[] = [];
for (const point of series) {
const age = dayKeyAgeInDays(point.date, todayKey);
if (age === null || age >= days) continue;
values.push(point.value);
}
return meanOf(values);
};
const newest = series.at(-1) ?? null;
return {
key: dimension.key,
// Presence is decided by whether anybody answered, never by whether the
// mean happens to be zero.
present: count > 0,
inverse: dimension.inverse,
min: dimension.min,
max: dimension.max,
count,
avg7: windowMean(7),
avg30: windowMean(30),
avg90: windowMean(90),
latest: newest?.value ?? null,
latestDate: newest?.date ?? null,
newestDaysAgo: newest ? dayKeyAgeInDays(newest.date, todayKey) : null,
series,
};
});
}
+5
View File
@@ -41,6 +41,7 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
import { getScoreForMood } from "@/lib/validations/mood";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { deriveA1 } from "@/lib/mood/level-a";
import {
classifyExternalId,
unstableExternalIdMessage,
@@ -648,6 +649,10 @@ export async function logMcpMood(input: {
tz,
mood,
score,
// v1.37 — pleasantness derived from the same label the score comes
// from. The tool still takes a 1-5 score; that is its input and it is
// unchanged.
moodA1: deriveA1(mood),
note: null,
noteEncrypted: encryptNote(note),
source: MCP_SOURCE,
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
MOOD_DIMENSIONS,
MOOD_DIMENSION_MAX,
MOOD_DIMENSION_MIN,
getMoodDimension,
} from "@/lib/mood/dimensions";
describe("MOOD_DIMENSIONS", () => {
it("carries the five level-A dimensions in capture order", () => {
expect(MOOD_DIMENSIONS.map((d) => d.key)).toEqual([
"a1",
"a2",
"a3",
"a4",
"a5",
]);
});
it("binds each dimension to its own column", () => {
expect(MOOD_DIMENSIONS.map((d) => d.column)).toEqual([
"moodA1",
"stressA2",
"energyA3",
"connectionA4",
"stabilityA5",
]);
const columns = new Set(MOOD_DIMENSIONS.map((d) => d.column));
expect(columns.size).toBe(MOOD_DIMENSIONS.length);
});
it("runs 0..10 on every dimension", () => {
for (const d of MOOD_DIMENSIONS) {
expect(d.min).toBe(MOOD_DIMENSION_MIN);
expect(d.max).toBe(MOOD_DIMENSION_MAX);
}
});
it("marks exactly one dimension inverse, and it is stress", () => {
const inverse = MOOD_DIMENSIONS.filter((d) => d.inverse);
expect(inverse.map((d) => d.key)).toEqual(["a2"]);
});
it("gives every dimension a distinct label and two distinct anchors", () => {
const keys: string[] = [];
for (const d of MOOD_DIMENSIONS) {
expect(d.labelKey).toBe(`mood.dimension.${d.key}.label`);
expect(d.lowAnchorKey).toBe(`mood.dimension.${d.key}.low`);
expect(d.highAnchorKey).toBe(`mood.dimension.${d.key}.high`);
keys.push(d.labelKey, d.lowAnchorKey, d.highAnchorKey);
}
expect(new Set(keys).size).toBe(keys.length);
});
it("resolves a dimension by wire key and refuses anything else", () => {
expect(getMoodDimension("a3")?.column).toBe("energyA3");
expect(getMoodDimension("a6")).toBeUndefined();
expect(getMoodDimension("moodA1")).toBeUndefined();
expect(getMoodDimension("")).toBeUndefined();
});
});
+5
View File
@@ -17,6 +17,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
import { getScoreForMood } from "@/lib/validations/mood";
import { deriveA1 } from "@/lib/mood/level-a";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
@@ -79,6 +80,10 @@ export async function logTelegramMood(input: {
tz,
mood,
score,
// v1.37 — pleasantness derived from the same label the score comes
// from, so a mood tapped in the chat carries the value a mood typed
// into the web form would. Nothing here can answer the other four.
moodA1: deriveA1(mood),
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
note: null,
noteEncrypted: encryptNote(input.note ?? null),
+135
View File
@@ -0,0 +1,135 @@
/**
* The five level-A self-state dimensions of a mood entry.
*
* A mood entry has carried one 1–5 value (`MoodEntry.score`) since the first
* release. That value keeps its meaning and its scale — eleven surfaces read
* it on the assumption that it runs 1..5 — and the five dimensions below ride
* beside it as their own 0–10 columns. The two coexist by design: `score` is
* "how was the day, in one word", the dimensions are "how was the day, in the
* terms the day was actually made of".
*
* Each dimension is stored literally, exactly as the user set it against the
* anchors they read. A2 (stress) is inverse-oriented — a higher value means a
* worse day — and it is stored that way rather than flipped at write time. An
* analytics surface that needs "up is better" flips the sign through
* `inverse` on this table; nothing flips inline. The same rule already governs
* rated mood factors (`MoodTag.inverse`), and it exists because a stored value
* that disagrees with the label the user saw is unrecoverable afterwards.
*
* This module is pure: it holds i18n keys and column names, and imports
* neither Prisma nor the message bundles. The German wording behind every key
* lives in `messages/de.json` and its five siblings.
*/
/** Wire/DTO key of a level-A dimension. */
export type MoodDimensionKey = "a1" | "a2" | "a3" | "a4" | "a5";
/** `MoodEntry` column a level-A dimension is stored in. */
export type MoodDimensionColumn =
"moodA1" | "stressA2" | "energyA3" | "connectionA4" | "stabilityA5";
export interface MoodDimension {
/** Wire key (`a1`…`a5`) accepted by the create schema and echoed on reads. */
key: MoodDimensionKey;
/** Prisma field on `MoodEntry` this dimension is stored in. */
column: MoodDimensionColumn;
/** i18n key of the dimension's own name. */
labelKey: string;
/** i18n key of the wording at the bottom of the scale. */
lowAnchorKey: string;
/** i18n key of the wording at the top of the scale. */
highAnchorKey: string;
/**
* A higher stored value means a worse day. Exactly one dimension carries
* this today (A2, stress), and the unit test pins that count, so a second
* one cannot be flipped quietly.
*/
inverse: boolean;
min: 0;
max: 10;
}
/**
* The dimensions in capture order — the order the sliders render in and the
* order every per-dimension surface iterates. Concept order, not alphabetical:
* pleasantness first because it is the one the quick check-in already answers.
*/
export const MOOD_DIMENSIONS: readonly MoodDimension[] = [
{
key: "a1",
column: "moodA1",
labelKey: "mood.dimension.a1.label",
lowAnchorKey: "mood.dimension.a1.low",
highAnchorKey: "mood.dimension.a1.high",
inverse: false,
min: 0,
max: 10,
},
{
key: "a2",
column: "stressA2",
labelKey: "mood.dimension.a2.label",
lowAnchorKey: "mood.dimension.a2.low",
highAnchorKey: "mood.dimension.a2.high",
inverse: true,
min: 0,
max: 10,
},
{
key: "a3",
column: "energyA3",
labelKey: "mood.dimension.a3.label",
lowAnchorKey: "mood.dimension.a3.low",
highAnchorKey: "mood.dimension.a3.high",
inverse: false,
min: 0,
max: 10,
},
{
key: "a4",
column: "connectionA4",
labelKey: "mood.dimension.a4.label",
lowAnchorKey: "mood.dimension.a4.low",
highAnchorKey: "mood.dimension.a4.high",
inverse: false,
min: 0,
max: 10,
},
{
key: "a5",
column: "stabilityA5",
labelKey: "mood.dimension.a5.label",
lowAnchorKey: "mood.dimension.a5.low",
highAnchorKey: "mood.dimension.a5.high",
inverse: false,
min: 0,
max: 10,
},
] as const;
/**
* Windows the per-dimension trend reports, in days. Concept §10.1.
*
* Here rather than beside the calculator that averages over them, because the
* chart offering the choice needs the same three numbers and cannot import
* that module: it reaches Prisma two hops down, and a client component that
* pulled it in would drag the database client into the browser bundle. This
* module is pure by construction, which is what makes it the one place both
* ends can agree on.
*/
export const MOOD_DIMENSION_WINDOWS = [7, 30, 90] as const;
export type MoodDimensionWindow = (typeof MOOD_DIMENSION_WINDOWS)[number];
/** Lowest value any dimension accepts. */
export const MOOD_DIMENSION_MIN = 0;
/** Highest value any dimension accepts. */
export const MOOD_DIMENSION_MAX = 10;
const BY_KEY = new Map<string, MoodDimension>(
MOOD_DIMENSIONS.map((d) => [d.key, d]),
);
/** The dimension carrying this wire key, or `undefined` for anything else. */
export function getMoodDimension(key: string): MoodDimension | undefined {
return BY_KEY.get(key);
}
+152
View File
@@ -0,0 +1,152 @@
/**
* The one place a mood write resolves its five level-A values.
*
* Five writers reach `MoodEntry`, and every one of them sends exactly what it
* sent before this existed: a five-point label and nothing else. The server
* derives pleasantness (A1) from that label so a quick check-in is a full
* entry rather than a degraded one, and so a Telegram row, an imported row and
* a row typed into the web form all carry the same value for the same day.
*
* The other four are never derived. There is nothing in a five-point label to
* derive stress, energy, connectedness or stability from, and a fabricated
* value would read as an answer nobody gave — it would poison the trend and
* any later model trained on these columns. They stay NULL until somebody
* moves the slider.
*/
import { getA1ForMood } from "@/lib/validations/mood";
/** Level-A values as a client may send them, all optional. */
export interface LevelAInput {
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
}
/**
* Level-A values as they are written to `MoodEntry`.
*
* A1 is always stated, because it is always derivable from the label. The
* other four are present only when the request carried them: an absent key
* means the caller said nothing about that dimension, and on an upsert's
* `update:` arm saying nothing has to leave the stored answer alone.
*/
export interface LevelAColumns {
moodA1: number;
stressA2?: number | null;
energyA3?: number | null;
connectionA4?: number | null;
stabilityA5?: number | null;
}
/**
* The five columns for a write of `mood`, with any explicit client value
* winning over the derivation.
*
* A client that sends only a label gets the derived A1 and says nothing at all
* about the other four. A client that sends `a1` gets its own number — that is
* what makes the detail sliders a real capture surface rather than a
* suggestion the server overrides on the way to the database.
*
* **A2 to A5 are omitted when the request did not carry them, and that is the
* point.** The callers are upserts, so this same object feeds an `update:`
* arm: a phone re-posting an entry it holds — the label, the timestamp, the
* tags, and no sliders, because its own build has none — must not blank the
* stress and energy somebody answered on the web. Absence in a request means
* "nothing to say", never "set this to nothing". Clearing an answer is what an
* explicit `null` is for, and it travels here as a stated null.
*
* The asymmetry with A1 is deliberate rather than an oversight. A1 can always
* be derived from the label the request already carries, so there is no state
* in which restating it loses information; the other four have no such source,
* and a value invented for them would be indistinguishable from one somebody
* gave — which would poison the trend and anything later trained on it.
*
* On the `create:` arm an omitted column lands as NULL anyway, so one object
* serves both arms. Callers whose write semantics are per-field rather than
* whole-row (the edit route) build their own object and use `deriveA1`.
*/
export function levelAForWrite(
mood: string,
explicit?: LevelAInput,
): LevelAColumns {
// Field by field, never a spread of the parsed body.
return {
// An explicit null falls back to the derivation rather than clearing: an
// entry always carries a pleasantness value, because the five-point label
// it was saved with always implies one.
moodA1: explicit?.a1 ?? getA1ForMood(mood),
...(explicit?.a2 === undefined ? {} : { stressA2: explicit.a2 }),
...(explicit?.a3 === undefined ? {} : { energyA3: explicit.a3 }),
...(explicit?.a4 === undefined ? {} : { connectionA4: explicit.a4 }),
...(explicit?.a5 === undefined ? {} : { stabilityA5: explicit.a5 }),
};
}
/** A1 for a five-point label — the derivation on its own, for writers that carry no level-A input. */
export function deriveA1(mood: string): number {
return getA1ForMood(mood);
}
/** The five columns as a client reads them back. */
export interface LevelAWire {
a1: number | null;
a2: number | null;
a3: number | null;
a4: number | null;
a5: number | null;
}
/** The five columns as they sit on a `MoodEntry` row. */
export interface LevelARow {
moodA1: number | null;
stressA2: number | null;
energyA3: number | null;
connectionA4: number | null;
stabilityA5: number | null;
}
/**
* Turn a stored row's five columns into the wire keys the client sends.
*
* A read that answered `moodA1` while the write took `a1` would leave every
* form mapping the pair by hand, and one of them would eventually map it
* wrong. The names match in both directions.
*/
export function levelAForWire(row: LevelARow): LevelAWire {
return {
a1: row.moodA1,
a2: row.stressA2,
a3: row.energyA3,
a4: row.connectionA4,
a5: row.stabilityA5,
};
}
/**
* A row shaped for a response: the five columns replaced by their wire keys,
* not joined by them.
*
* The mood routes spread the whole row into their responses, so simply adding
* the wire keys left every entry carrying the same five values twice under two
* spellings. Two names for one number is a decision nobody made, and the one a
* client picks is the one it gets stuck with. This strips the column spelling
* and keeps the wire one, which is the spelling the write path accepts.
*/
export function shapeLevelA<T extends LevelARow>(
row: T,
): Omit<T, keyof LevelARow> & LevelAWire {
const { moodA1, stressA2, energyA3, connectionA4, stabilityA5, ...rest } =
row;
return {
...rest,
...levelAForWire({
moodA1,
stressA2,
energyA3,
connectionA4,
stabilityA5,
}),
};
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
getA1ForMood,
getScoreForMood,
moodA1Map,
} from "@/lib/validations/mood";
/**
* The five-point label → A1 map is the single derivation point every writer
* shares, so every anchor is asserted by value rather than by shape. A test
* that only checked monotonicity would pass a map that had drifted a point.
*/
describe("getA1ForMood", () => {
it("maps every five-point label onto its level-A anchor", () => {
expect(getA1ForMood("LAUSIG")).toBe(1);
expect(getA1ForMood("SCHLECHT")).toBe(3);
expect(getA1ForMood("OKAY")).toBe(5);
expect(getA1ForMood("GUT")).toBe(7);
expect(getA1ForMood("SUPER_GUT")).toBe(9);
});
it("keeps the endpoints inside the scale rather than on it", () => {
// 0 and 10 stay reachable only by a value the user set by hand: a mapped
// historical entry must not read as more extreme than every future one.
expect(getA1ForMood("LAUSIG")).toBeGreaterThan(0);
expect(getA1ForMood("SUPER_GUT")).toBeLessThan(10);
});
it("answers the same neutral midpoint an unknown label gets from the score map", () => {
// getScoreForMood answers 3 on a 1..5 axis for an unknown label — the
// midpoint. A1's answer is the midpoint of its own axis, and the two are
// asserted against each other so a change to one is loud in the other.
expect(getScoreForMood("NOT_A_MOOD")).toBe(3);
expect(getA1ForMood("NOT_A_MOOD")).toBe(5);
expect(getA1ForMood("")).toBe(5);
});
it("is ordered and evenly spaced across the five labels", () => {
const ordered = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"].map(
getA1ForMood,
);
expect(ordered).toEqual([1, 3, 5, 7, 9]);
for (let i = 1; i < ordered.length; i++) {
expect(ordered[i] - ordered[i - 1]).toBe(2);
}
});
it("exposes exactly the five labels and nothing else", () => {
expect(Object.keys(moodA1Map()).sort()).toEqual([
"GUT",
"LAUSIG",
"OKAY",
"SCHLECHT",
"SUPER_GUT",
]);
});
it("agrees with the score map on ordering", () => {
// Both axes must rank the five labels identically; a disagreement would
// mean a row whose score and A1 tell opposite stories.
const labels = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"];
const byScore = [...labels].sort(
(a, b) => getScoreForMood(a) - getScoreForMood(b),
);
const byA1 = [...labels].sort((a, b) => getA1ForMood(a) - getA1ForMood(b));
expect(byA1).toEqual(byScore);
});
});
+9
View File
@@ -254,6 +254,15 @@ const moodEntrySchema = z
date: z.string().min(1),
mood: z.string().min(1),
score: z.number().int().min(0).max(10),
// The five level-A values. Optional and nullable: a file written before
// they existed carries none, and an entry whose sliders were never touched
// carries nulls. Absent restores as NULL, never as a defaulted midpoint —
// a restore that invents an answer is worse than one that admits none.
a1: z.number().int().min(0).max(10).nullable().optional(),
a2: z.number().int().min(0).max(10).nullable().optional(),
a3: z.number().int().min(0).max(10).nullable().optional(),
a4: z.number().int().min(0).max(10).nullable().optional(),
a5: z.number().int().min(0).max(10).nullable().optional(),
tags: moodEntryTagsSchema,
// The free text, in both storage shapes. A portable export carries the
// decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus
+67
View File
@@ -46,6 +46,43 @@ export function getScoreForMood(mood: string): number {
return MOOD_SCORE_MAP[mood] ?? 3;
}
/**
* The five-point mood label mapped onto the level-A pleasantness scale (A1).
*
* The quick check-in asks one question and offers five faces, and it stays a
* full-value capture path: its answer produces a real A1 rather than a
* degraded one. This is the single place those five numbers exist. A second
* copy anywhere in the tree — including inside a migration — is a drift
* waiting to happen, which is why the backfill's SQL is read back and compared
* against this map by a test rather than trusted to stay in step.
*
* The endpoints are 1 and 9, not 0 and 10. "LAUSIG" means as bad as this
* instrument can express, not as bad as a person can feel; pinning it to 0
* would make every mapped historical entry look more extreme than every future
* hand-set one and would leave the true extremes unreachable. 5 is the
* concept's neutral midpoint and is where "OKAY" belongs.
*/
const MOOD_A1_MAP: Record<string, number> = {
SUPER_GUT: 9,
GUT: 7,
OKAY: 5,
SCHLECHT: 3,
LAUSIG: 1,
};
/**
* A1 for a five-point mood label. An unrecognised label answers the neutral
* midpoint, matching what `getScoreForMood` answers for the same input.
*/
export function getA1ForMood(mood: string): number {
return MOOD_A1_MAP[mood] ?? 5;
}
/** Read-only view of the A1 map, for the guards that pin the backfill to it. */
export function moodA1Map(): Readonly<Record<string, number>> {
return MOOD_A1_MAP;
}
// v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`).
// Additive alongside the flat free-text `tags`: an entry can carry both.
// Bounded so a single create can't fan out an unbounded link set.
@@ -69,6 +106,14 @@ const ratedFactor = z.object({
});
const ratedFactors = z.array(ratedFactor).max(30);
// v1.37 — the five level-A self-state values, each 0-10. Optional on every
// path: a client that sends only a five-point label still writes a complete
// entry, because the server derives A1 from the label. A2 to A5 are only ever
// what the user set, so an omitted one stays absent rather than defaulting to
// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS`
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
const levelADimension = z.number().int().min(0).max(10);
export const createMoodEntrySchema = z
.object({
mood: moodLevelEnum,
@@ -83,6 +128,19 @@ export const createMoodEntrySchema = z
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
// rejected (422) / dropped server-side per the catalog.
ratedFactors: ratedFactors.optional(),
// v1.37 — the five level-A values the detail sliders capture. Three
// states, and they mean three different things, because this create can
// land on the `update:` arm of an `externalId` upsert:
// absent — nothing to say; a stored answer is left alone
// number — the answer, stored literally
// null — take the answer back
// `a1` is the exception: absent or null, the server derives it from
// `mood`, so a one-tap check-in is a full entry and never a degraded one.
a1: levelADimension.nullable().optional(),
a2: levelADimension.nullable().optional(),
a3: levelADimension.nullable().optional(),
a4: levelADimension.nullable().optional(),
a5: levelADimension.nullable().optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
@@ -118,6 +176,15 @@ export const updateMoodEntrySchema = z.object({
// v1.12.0 — full replacement of the rated-factor set when present.
// `null` clears every rated link; omit to leave them untouched.
ratedFactors: ratedFactors.nullable().optional(),
// v1.37 — level-A values on the edit path. Omitted leaves the stored value
// alone; an explicit `null` clears it, which is how a user takes back an
// answer they did not mean to give. Editing the five-point label without
// sending `a1` re-derives A1, the same way it re-derives `score`.
a1: levelADimension.nullable().optional(),
a2: levelADimension.nullable().optional(),
a3: levelADimension.nullable().optional(),
a4: levelADimension.nullable().optional(),
a5: levelADimension.nullable().optional(),
note: z.string().max(500).nullable().optional(),
// v1.17 W1b — same plausibility bound on the edit path.
moodLoggedAt: validateEntryInstant(
+244 -64
View File
@@ -1,24 +1,44 @@
/**
* Integration regression guard for `DELETE /api/admin/data`.
* Integration guard for `DELETE /api/admin/data` — the global "wipe all data".
*
* The admin "wipe all data" path nulls personal columns on `User` and
* deletes a curated list of personal-data tables in one transaction.
* v1.4.6 left three notification-channel-style tables out of that
* list, leaving encrypted Telegram bot tokens (NotificationChannel.config)
* and Web Push endpoints behind. This test is the regression guard
* that the wipe now extends to:
* This is the wipe an operator runs before handing a box on, and it deleted
* nine tables. The list was inline, written when nine was most of the schema,
* and it stayed at nine while the schema grew past a hundred: laboratory
* results, documents, cycle logs, journals, mood, workouts, sleep and Coach
* conversations all survived a confirmation typed by hand. The previous
* version of this file asserted the three tables a v1.4.6 fix had added and
* was green the whole time — it could only see the tables it named.
*
* - notification_channels (Telegram/ntfy/web-push channel config)
* - push_subscriptions (raw browser push endpoints)
* - telegram_scheduled_deletions (queued message-delete tickets)
* So it now works the way the account-wipe guard does: seed a row into every
* table in the schema, for two different accounts, run the wipe, and ask the
* database what is left. What it asserts:
*
* AuditLog rows are intentionally PRESERVED — a wipe must not erase the
* audit trail of the operation itself.
* - every model in `ADMIN_WIPE_MODELS` is empty across the WHOLE table, not
* merely for the admin who pressed the button;
* - every table reached only through a database cascade is empty too;
* - the exempt sets still hold their rows, so the "kept" half of the
* confirmation copy is true;
* - the instance-scoped tables are untouched;
* - every `User` row survives with `USER_RESET` applied;
* - the audit history goes and exactly one receipt row replaces it.
*
* Its honest limit: it proves the wipe removes what the plan declares. The
* plan being right is what `src/__tests__/admin-data-wipe-completeness.test.ts`
* holds against `schema.prisma`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { countRows, modelTableMap, seedEveryTable } from "./wipe-seeder";
import {
ADMIN_WIPE_EXEMPT,
ADMIN_WIPE_MODELS,
INSTANCE_SCOPED,
USER_KEPT_FIELDS,
USER_RESET,
WIPE_EXEMPT,
} from "@/lib/data-wipe/wipe-plan";
// next/headers cookie + ip headers stub for getSession() inside
// requireAdmin(). The admin route reads x-forwarded-for too; we stub
@@ -56,19 +76,89 @@ beforeEach(async () => {
headerJar.clear();
});
describe("DELETE /api/admin/data wipe scope", () => {
it("clears notification channels, push subscriptions, telegram deletions; preserves AuditLog", async () => {
async function runWipe(): Promise<Response> {
const { DELETE } = await import("@/app/api/admin/data/route");
const req = new Request("http://localhost/api/admin/data", {
method: "DELETE",
headers: {
"content-type": "application/json",
"x-forwarded-for": "127.0.0.1",
},
body: JSON.stringify({ confirm: "DELETE ALL" }),
});
return DELETE(req as unknown as Parameters<typeof DELETE>[0]);
}
describe("DELETE /api/admin/data leaves nothing it promised to delete", () => {
it("empties every in-scope table for every account and keeps exactly the declared set", async () => {
const prisma = getPrismaClient();
const tables = modelTableMap();
// ── arrange ──
const admin = await prisma.user.create({
data: {
username: "wipe-admin",
email: "wipe-admin@example.test",
role: "ADMIN",
passwordHash: "argon2-placeholder",
heightCm: 180,
dateOfBirth: new Date("1990-01-01T00:00:00Z"),
telegramBotToken: "secret-token",
telegramEnabled: true,
locale: "en",
unitPreference: "metric",
},
});
// The whole point of this route is that it reaches accounts other than
// the one pressing the button. A second account's rows are seeded by
// hand across the domains the old nine-table list left behind.
const other = await prisma.user.create({
data: {
username: "wipe-bystander",
email: "wipe-bystander@example.test",
passwordHash: "argon2-placeholder",
heightCm: 165,
onboardingCompletedAt: new Date(),
},
});
await prisma.measurement.create({
data: {
userId: other.id,
type: "WEIGHT",
value: 70,
unit: "kg",
measuredAt: new Date(),
},
});
await prisma.labResult.create({
data: {
userId: other.id,
analyte: "HbA1c",
value: 5.4,
unit: "%",
takenAt: new Date(),
},
});
await prisma.coachConversation.create({
data: { userId: other.id, title: "bystander conversation" },
});
await prisma.workout.create({
data: {
userId: other.id,
sportType: "running",
startedAt: new Date("2026-01-01T06:00:00Z"),
endedAt: new Date("2026-01-01T06:30:00Z"),
durationSec: 1800,
},
});
const seeded = await seedEveryTable(prisma, admin.id);
expect(
seeded.length,
"the seeder should reach essentially the whole schema",
).toBeGreaterThan(100);
// Created after the generic seed so the cookie carries this session's id.
const session = await prisma.session.create({
data: {
userId: admin.id,
@@ -77,65 +167,155 @@ describe("DELETE /api/admin/data wipe scope", () => {
});
cookieJar.set("healthlog_session", session.id);
// Seed the channels & adjacent rows the wipe must clear.
await prisma.notificationChannel.create({
// ── act ──
const response = await runWipe();
expect(response.status).toBe(200);
const body = (await response.json()) as {
data: { cleared: boolean; deletedRows: number };
};
expect(body.data.cleared).toBe(true);
expect(body.data.deletedRows).toBeGreaterThan(50);
// ── assert: nothing the plan claims to delete is left, for anyone ──
const survivors: string[] = [];
for (const model of ADMIN_WIPE_MODELS) {
const table = tables.get(model)!;
// `AuditLog` is wiped inside the transaction and then receives exactly
// one new row after it commits: the receipt for the wipe itself.
const expected = model === "AuditLog" ? 1 : 0;
if ((await countRows(prisma, table)) > expected) {
survivors.push(`${model} (${table})`);
}
}
expect(
survivors,
`rows survived a wipe that told the operator everything was deleted:\n${survivors.join("\n")}`,
).toEqual([]);
const auditRows = await prisma.auditLog.findMany({
select: { action: true },
});
expect(auditRows.map((r) => r.action)).toEqual(["admin.data.clear"]);
// ── assert: the kept set is exactly what the copy promises ──
const missing: string[] = [];
for (const model of [
...Object.keys(WIPE_EXEMPT),
...Object.keys(ADMIN_WIPE_EXEMPT),
]) {
const table = tables.get(model)!;
if ((await countRows(prisma, table)) === 0) {
missing.push(`${model} (${table})`);
}
}
expect(
missing,
`the copy says these are kept, and they are gone:\n${missing.join("\n")}`,
).toEqual([]);
// ── assert: the instance's own tables are untouched ──
for (const model of Object.keys(INSTANCE_SCOPED)) {
if (model === "User") continue;
const table = tables.get(model)!;
expect(
await countRows(prisma, table),
`${model} belongs to the instance and must survive the data wipe`,
).toBeGreaterThan(0);
}
// ── assert: everything reached only by cascade is gone too ──
const wipedTables = new Set(ADMIN_WIPE_MODELS.map((m) => tables.get(m)!));
const keptTables = new Set(
[
...Object.keys(WIPE_EXEMPT),
...Object.keys(ADMIN_WIPE_EXEMPT),
...Object.keys(INSTANCE_SCOPED),
].map((m) => tables.get(m)!),
);
const leftovers: string[] = [];
for (const table of seeded) {
if (wipedTables.has(table)) continue;
if (keptTables.has(table)) continue;
if ((await countRows(prisma, table)) > 0) leftovers.push(table);
}
expect(
leftovers,
`tables the wipe reaches only through a database cascade still hold rows:\n${leftovers.join("\n")}`,
).toEqual([]);
// ── assert: both accounts survive, reset but signed-in-able ──
const rows = await prisma.user.findMany({ orderBy: { username: "asc" } });
expect(rows.map((r) => r.username)).toEqual([
"wipe-admin",
"wipe-bystander",
]);
for (const row of rows) {
const record = row as unknown as Record<string, unknown>;
expect(record.passwordHash).toBe("argon2-placeholder");
for (const column of Object.keys(USER_KEPT_FIELDS)) {
expect(record).toHaveProperty(column);
}
const stillSet: string[] = [];
for (const column of Object.keys(USER_RESET)) {
const value = record[column];
const cleared =
value === null ||
value === false ||
value === 0 ||
(Array.isArray(value) && value.length === 0) ||
value === "aggregated" ||
value === "disconnected";
if (!cleared) stillSet.push(`${column} = ${JSON.stringify(value)}`);
}
expect(
stillSet,
`${record.username}: User columns the plan resets still carry a value:\n${stillSet.join("\n")}`,
).toEqual([]);
}
// The admin who ran it is still signed in — the session that carried the
// request has to outlive it or the operator is logged out mid-action.
expect(await prisma.session.count({ where: { id: session.id } })).toBe(1);
});
it("refuses a wipe without the typed confirmation", async () => {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
userId: admin.id,
type: "TELEGRAM",
enabled: true,
// Pretend-encrypted blob — content is irrelevant, presence is.
config: "encrypted:test",
username: "wipe-admin-unconfirmed",
email: "wipe-admin-unconfirmed@example.test",
role: "ADMIN",
},
});
await prisma.pushSubscription.create({
data: {
userId: admin.id,
endpoint: "https://push.example/sub-" + admin.id,
p256dh: "p256dh-enc",
auth: "auth-enc",
},
const session = await prisma.session.create({
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
});
await prisma.telegramScheduledDeletion.create({
cookieJar.set("healthlog_session", session.id);
await prisma.measurement.create({
data: {
userId: admin.id,
chatId: "1234",
messageId: 42,
deleteAfter: new Date(Date.now() + 60_000),
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date(),
},
});
// ── act: invoke the route handler exactly as Next.js would ──
const { DELETE } = await import("@/app/api/admin/data/route");
const req = new Request("http://localhost/api/admin/data", {
method: "DELETE",
headers: {
"content-type": "application/json",
"x-forwarded-for": "127.0.0.1",
},
body: JSON.stringify({ confirm: "DELETE ALL" }),
});
const res = await DELETE(req as unknown as Parameters<typeof DELETE>[0]);
expect(res.status).toBe(200);
const res = await DELETE(
new Request("http://localhost/api/admin/data", {
method: "DELETE",
headers: {
"content-type": "application/json",
"x-forwarded-for": "127.0.0.1",
},
body: JSON.stringify({ confirm: "delete all" }),
}) as unknown as Parameters<typeof DELETE>[0],
);
// ── assert: notification-related tables are empty ──
expect(
await prisma.notificationChannel.count({ where: { userId: admin.id } }),
).toBe(0);
expect(
await prisma.pushSubscription.count({ where: { userId: admin.id } }),
).toBe(0);
expect(
await prisma.telegramScheduledDeletion.count({
where: { userId: admin.id },
}),
).toBe(0);
// ── assert: AuditLog rows survive ──
// The wipe writes admin.data.clear.start AND admin.data.clear audit
// entries. They must outlive the operation (audit log is immune).
const auditCount = await prisma.auditLog.count({
where: { userId: admin.id },
});
expect(auditCount).toBeGreaterThanOrEqual(2);
expect(res.status).toBe(422);
expect(await prisma.measurement.count()).toBe(1);
});
});
+229
View File
@@ -0,0 +1,229 @@
/**
* v1.37 — the level-A backfill, read out of the migration and executed.
*
* The statement that maps a hundred thousand historical rows onto the
* pleasantness scale carries its own copy of five numbers, and the runtime map
* carries the other. Two copies of the same five numbers is a drift waiting to
* happen, so this test reads the arms back out of the `.sql` file and compares
* them to `MOOD_A1_MAP` arm by arm — a migration edited without the map, or a
* map edited without the migration, fails here.
*
* The rest of it executes the statement against real Postgres on rows inserted
* the way legacy rows actually look (`mood_a1 IS NULL`), and asserts the rows,
* not the fact that the statement ran. It runs a second time and asserts
* nothing moved: a migration re-applied against a database that has since
* taken real answers must leave them alone.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { moodA1Map } from "@/lib/validations/mood";
import { getPrismaClient, truncateAllTables } from "./setup";
const MIGRATION_PATH = path.join(
process.cwd(),
"prisma",
"migrations",
"0310_mood_level_a_dimensions",
"migration.sql",
);
const USER = "user-mood-a1-backfill";
function migrationSql(): string {
return readFileSync(MIGRATION_PATH, "utf8");
}
/** The `UPDATE mood_entries … ;` statement, comments stripped. */
function backfillStatement(sql: string): string {
const withoutComments = sql
.split("\n")
.filter((line) => !line.trimStart().startsWith("--"))
.join("\n");
const match = withoutComments.match(/UPDATE\s+mood_entries[\s\S]*?;/i);
if (!match) {
throw new Error(
`No UPDATE statement found in ${MIGRATION_PATH}. The backfill is the point of this test; an absent statement is a failure, not a skip.`,
);
}
return match[0];
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("mood a1 backfill — the statement agrees with the runtime map", () => {
it("carries one CASE arm per label, with the value the map answers", () => {
const statement = backfillStatement(migrationSql());
const arms = [
...statement.matchAll(/WHEN\s+'([A-Z_]+)'\s+THEN\s+(\d+)/gi),
].map((m) => [m[1], Number(m[2])] as const);
// An empty match set means the matcher stopped matching, not that the
// migration is correct. A guard that cannot fail is worse than none.
expect(arms.length).toBeGreaterThan(0);
const map = moodA1Map();
expect(arms.length).toBe(Object.keys(map).length);
for (const [label, value] of arms) {
expect(
map[label],
`CASE arm '${label}' has no entry in MOOD_A1_MAP`,
).toBe(value);
}
// And the other direction: a label in the map with no arm would leave
// those rows NULL after the backfill claimed to have covered them.
for (const label of Object.keys(map)) {
expect(
arms.map(([l]) => l),
`MOOD_A1_MAP entry '${label}' has no CASE arm in the migration`,
).toContain(label);
}
});
it("is guarded on the column being empty, so a re-run cannot overwrite an answer", () => {
const statement = backfillStatement(migrationSql());
expect(statement.replace(/\s+/g, " ")).toMatch(
/WHERE\s+mood_a1\s+IS\s+NULL/i,
);
});
});
describe("mood a1 backfill — executed against real rows", () => {
async function seedLegacyRows() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: USER,
username: "mood-a1-backfill",
email: "mood-a1-backfill@example.test",
timezone: "Europe/Berlin",
},
});
// Inserted through raw SQL so the columns look exactly like a row written
// before they existed: every level-A column NULL. Going through Prisma
// would let a default or a hook fill one in and the test would be
// proving the seed rather than the migration.
const rows: Array<[string, string, number, string | null]> = [
["legacy-lausig", "LAUSIG", 1, null],
["legacy-schlecht", "SCHLECHT", 2, null],
["legacy-okay", "OKAY", 3, null],
["legacy-gut", "GUT", 4, null],
["legacy-super", "SUPER_GUT", 5, null],
// A tombstoned row is still the user's history and still restorable.
["legacy-deleted", "GUT", 4, "2026-05-01T00:00:00.000Z"],
// A label nothing recognises: no arm matches, so it stays NULL rather
// than being handed the midpoint.
["legacy-unknown", "NOT_A_MOOD", 3, null],
];
for (const [index, [id, mood, score, deletedAt]] of rows.entries()) {
// An explicit instant per row, one hour apart. `NOW()` would be the
// transaction's own clock, and two inserts landing on the same tick
// collide on the `(user_id, date, mood_logged_at)` unique — a seed that
// fails on a fast machine and passes on a slow one.
const loggedAt = `2026-05-16T0${index}:00:00.000Z`;
await prisma.$executeRawUnsafe(
`INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, created_at, updated_at, sync_version, deleted_at)
VALUES ($1, $2, '2026-05-16', $3, $4, 'MANUAL', $5::timestamptz, NOW(), NOW(), NOW(), 0, $6::timestamptz)`,
id,
USER,
mood,
score,
loggedAt,
deletedAt,
);
}
const before = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*)::bigint AS count FROM mood_entries WHERE mood_a1 IS NULL`,
);
expect(Number(before[0].count)).toBe(rows.length);
return rows.length;
}
it("maps every legacy row onto its anchor and leaves the other four empty", async () => {
const prisma = getPrismaClient();
const seeded = await seedLegacyRows();
const changed = await prisma.$executeRawUnsafe(
backfillStatement(migrationSql()),
);
// Every row but the unknown label — the statement's IN list excludes it.
expect(changed).toBe(seeded - 1);
const after = await prisma.moodEntry.findMany({
where: { userId: USER },
orderBy: { id: "asc" },
select: {
id: true,
mood: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
},
});
expect(after).toHaveLength(seeded);
const byId = new Map(after.map((r) => [r.id, r]));
expect(byId.get("legacy-lausig")?.moodA1).toBe(1);
expect(byId.get("legacy-schlecht")?.moodA1).toBe(3);
expect(byId.get("legacy-okay")?.moodA1).toBe(5);
expect(byId.get("legacy-gut")?.moodA1).toBe(7);
expect(byId.get("legacy-super")?.moodA1).toBe(9);
// A soft-deleted row is backfilled too: it is still in the backup and
// still comes back from a restore.
expect(byId.get("legacy-deleted")?.moodA1).toBe(7);
// An unrecognised label gets nothing rather than a fabricated midpoint.
expect(byId.get("legacy-unknown")?.moodA1).toBeNull();
// Nothing derived the other four, on any row.
for (const row of after) {
expect(row.stressA2).toBeNull();
expect(row.energyA3).toBeNull();
expect(row.connectionA4).toBeNull();
expect(row.stabilityA5).toBeNull();
}
});
it("changes nothing on a second run, including a value set since the first", async () => {
const prisma = getPrismaClient();
await seedLegacyRows();
const statement = backfillStatement(migrationSql());
await prisma.$executeRawUnsafe(statement);
// Somebody answers the slider on one of the backfilled rows, contradicting
// the mapped value. A re-run must not take that answer away.
await prisma.moodEntry.update({
where: { id: "legacy-okay" },
data: { moodA1: 8 },
});
const beforeSecond = await prisma.moodEntry.findMany({
where: { userId: USER },
orderBy: { id: "asc" },
select: { id: true, moodA1: true, updatedAt: true },
});
const changedOnSecondRun = await prisma.$executeRawUnsafe(statement);
expect(changedOnSecondRun).toBe(0);
const afterSecond = await prisma.moodEntry.findMany({
where: { userId: USER },
orderBy: { id: "asc" },
select: { id: true, moodA1: true, updatedAt: true },
});
expect(afterSecond).toEqual(beforeSecond);
expect(
afterSecond.find((r) => r.id === "legacy-okay")?.moodA1,
"the hand-set answer survived the re-run",
).toBe(8);
});
});
@@ -186,6 +186,15 @@ describe("mood backup round trip", () => {
date: "2026-07-19",
mood: "OKAY",
score: 3,
// All five level-A values, and deliberately not the ones the label
// would derive: a restore that re-derived them instead of carrying
// them would look right on this row and be wrong on every row where
// the person disagreed with their own five-point pick.
moodA1: 4,
stressA2: 9,
energyA3: 2,
connectionA4: 6,
stabilityA5: 0,
tags: JSON.stringify(["free-text-tag"]),
note: null,
noteEncrypted: encryptNote(NOTE),
@@ -268,6 +277,14 @@ describe("mood backup round trip", () => {
expect(restored.updatedAt).toEqual(UPDATED_AT);
expect(restored.date).toBe("2026-07-19");
expect(restored.score).toBe(3);
// Each of the five asserted by value. Zero is included on purpose: it is a
// real answer on this scale, and a carrier that treated it as absence
// would round-trip it as NULL and pass a truthiness check.
expect(restored.moodA1).toBe(4);
expect(restored.stressA2).toBe(9);
expect(restored.energyA3).toBe(2);
expect(restored.connectionA4).toBe(6);
expect(restored.stabilityA5).toBe(0);
expect(restored.tags).toBe(JSON.stringify(["free-text-tag"]));
expect(restored.deletedAt).toBeNull();
@@ -276,6 +293,11 @@ describe("mood backup round trip", () => {
});
expect(tombstone.deletedAt).toEqual(DELETED_AT);
expect(tombstone.syncVersion).toBe(5);
// The tombstone was written without level-A values and comes back without
// them. A restore filling these in would put an answer nobody gave into a
// row the person had already deleted.
expect(tombstone.moodA1).toBeNull();
expect(tombstone.stressA2).toBeNull();
const links = await prisma.moodEntryTagLink.findMany({
where: { moodEntryId: "mood-rt-entry" },
@@ -296,6 +318,146 @@ describe("mood backup round trip", () => {
expect(byKey.get("rt_sleep_quality")!.rating).toBe(4);
});
it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await seedOwner(prisma);
// A row as it stands today, with answered sliders.
await prisma.moodEntry.create({
data: {
id: "mood-rt-live",
userId: OWNER_ID,
date: "2026-07-19",
mood: "OKAY",
score: 3,
moodA1: 4,
stressA2: 9,
source: "MANUAL",
moodLoggedAt: LOGGED_AT,
},
});
// Restoring a file written before these columns existed. The restore
// deletes the owner's mood partition and rebuilds it from the file, so
// the row that comes back is the file's row and carries nothing the file
// did not hold. This is the disaster-recovery contract and it is asserted
// here so nobody reads the omit-on-update below as protecting a live row:
// by the time that arm runs there are no live rows left.
const legacyPayload = {
schemaVersion: "2",
exportedAt: "2026-07-20T00:00:00.000Z",
userId: OWNER_ID,
appSettings: null,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [
{
id: "mood-rt-live",
date: "2026-07-19",
mood: "OKAY",
score: 3,
tags: null,
source: "MANUAL",
loggedAt: LOGGED_AT.toISOString(),
externalId: null,
deletedAt: null,
factors: [],
},
],
customMoodTags: [],
nutrientDays: [],
};
const response = await restoreFromPayload(
prisma,
parseBackupPayload(legacyPayload),
"MOOD_LEVEL_A_WIPE",
);
expect(response.status).toBe(200);
const after = await prisma.moodEntry.findUniqueOrThrow({
where: { id: "mood-rt-live" },
});
// Not 4 and not 9: the file said nothing about them and the file is what
// was restored. Nothing was derived to fill the gap either.
expect(after.moodA1).toBeNull();
expect(after.stressA2).toBeNull();
});
it("lets a second entry for the same row add without erasing", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await seedOwner(prisma);
// Two entries in ONE file addressing the same row. The first creates it,
// the second lands on the upsert's update arm — the only way that arm is
// reachable, since the partition was wiped a moment earlier. A second
// entry that mentions nothing about a dimension must not blank what the
// first one set, and one that mentions it wins, including a null.
const payload = {
schemaVersion: "2",
exportedAt: "2026-08-08T00:00:00.000Z",
userId: OWNER_ID,
appSettings: null,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [
{
id: "mood-rt-collide",
date: "2026-07-19",
mood: "OKAY",
score: 3,
tags: null,
source: "MANUAL",
loggedAt: LOGGED_AT.toISOString(),
externalId: null,
deletedAt: null,
a1: 4,
a2: 9,
a3: 6,
factors: [],
},
{
id: "mood-rt-collide",
date: "2026-07-19",
mood: "GUT",
score: 4,
tags: null,
source: "MANUAL",
loggedAt: LOGGED_AT.toISOString(),
externalId: null,
deletedAt: null,
a1: 7,
a3: null,
factors: [],
},
],
customMoodTags: [],
nutrientDays: [],
};
const response = await restoreFromPayload(
prisma,
parseBackupPayload(payload),
"MOOD_LEVEL_A_COLLIDE",
);
expect(response.status).toBe(200);
const after = await prisma.moodEntry.findUniqueOrThrow({
where: { id: "mood-rt-collide" },
});
expect(after.mood).toBe("GUT");
// Stated by the second entry.
expect(after.moodA1).toBe(7);
// Not mentioned by the second entry: the first entry's answer stands.
expect(after.stressA2).toBe(9);
// Stated as null by the second entry: cleared.
expect(after.energyA3).toBeNull();
});
it("restores a file written in the shape that predates these fields", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
@@ -338,6 +500,7 @@ describe("mood backup round trip", () => {
expect(parsed.moodEntries[0].structuredTags).toEqual([]);
expect(parsed.moodEntries[0].tz).toBeUndefined();
expect(parsed.moodEntries[0].syncVersion).toBeUndefined();
expect(parsed.moodEntries[0].a1).toBeUndefined();
const response = await restoreFromPayload(
prisma,
@@ -360,6 +523,14 @@ describe("mood backup round trip", () => {
expect(restored.score).toBe(4);
expect(restored.externalId).toBe("moodlog-legacy");
expect(restored.deletedAt).toBeNull();
// A file from before these columns carries no answer for them, and the
// restore does not invent one. `GUT` would derive to 7; a restore that
// derived instead of carrying would write a number this file never held.
expect(restored.moodA1).toBeNull();
expect(restored.stressA2).toBeNull();
expect(restored.energyA3).toBeNull();
expect(restored.connectionA4).toBeNull();
expect(restored.stabilityA5).toBeNull();
const links = await prisma.moodEntryTagLink.findMany({
where: { moodEntryId: "mood-rt-legacy" },
@@ -0,0 +1,190 @@
/**
* v1.37 — the level-A blocks the assistant actually receives.
*
* This calls `extractFeatures`, the shipped composition, against real rows in
* real Postgres. The unit test beside it (`mood-staleness.test.ts`) rebuilds
* the block shape from the pure calculator, which proves the calculator and
* the freshness rule agree — but it never imports `features.ts`, so on its own
* it would stay green if the builder hardcoded `current: true`, dropped
* `staleNotice`, or stopped attaching `dimensions` at all. Both ends and the
* pipe between them: this is the pipe.
*
* The fixture is the one the phase exists for. A person taps a face every
* morning and opens the sliders once a week, so pleasantness is from today and
* stress is six days old. One of those may back a present-tense sentence and
* the other may not.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { extractFeatures } from "@/lib/insights/features";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-mood-dimension-features";
const TZ = "Europe/Berlin";
const DAY_MS = 24 * 60 * 60 * 1000;
/** The reader's own local today, resolved the way the builder resolves it. */
function todayKey(): string {
return userDayKey(new Date(), TZ);
}
async function seedUser() {
await getPrismaClient().user.create({
data: {
id: USER,
username: "mood-dimension-features",
email: "mood-dimension-features@example.test",
timezone: TZ,
},
});
}
/**
* One mood entry `daysAgo` days back, carrying whichever level-A values are
* given. Written through Prisma with every column stated, so the row looks
* exactly like one a writer would produce.
*/
async function seedEntry(
daysAgo: number,
values: {
mood: string;
score: number;
a1?: number | null;
a2?: number | null;
},
) {
const loggedAt = new Date(Date.now() - daysAgo * DAY_MS);
await getPrismaClient().moodEntry.create({
data: {
userId: USER,
date: shiftDateKey(todayKey(), -daysAgo),
tz: TZ,
mood: values.mood,
score: values.score,
moodA1: values.a1 ?? null,
stressA2: values.a2 ?? null,
source: "MANUAL",
moodLoggedAt: loggedAt,
},
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("level-A blocks reach the feature set with their own ages", () => {
it("marks today's pleasantness current and a six-day-old stress stale", async () => {
await seedUser();
// Six days ago: a full entry, sliders and all.
await seedEntry(6, { mood: "SCHLECHT", score: 2, a1: 3, a2: 9 });
// Since then, face-only check-ins. Pleasantness keeps moving; stress does
// not, because nobody opened the section again.
await seedEntry(2, { mood: "OKAY", score: 3, a1: 5 });
await seedEntry(0, { mood: "GUT", score: 4, a1: 7 });
const features = await extractFeatures(USER, false);
// The block exists at all — a dropped `dimensions` key fails right here.
expect(features.mood).toBeDefined();
const dimensions = features.mood?.dimensions;
expect(
dimensions,
"features.mood carries no dimensions block; the assistant would see none of the five",
).toBeDefined();
// Only the two that were answered. A block for a question nobody was
// asked invites a sentence about it.
expect(dimensions!.map((d) => d.key).sort()).toEqual(["a1", "a2"]);
const a1 = dimensions!.find((d) => d.key === "a1")!;
expect(a1.latest).toBe(7);
expect(a1.newestDaysAgo).toBe(0);
expect(a1.current).toBe(true);
// Nothing to warn about while it is current.
expect(a1.staleNotice).toBeUndefined();
expect(a1.count).toBe(3);
expect(a1.inverse).toBe(false);
const a2 = dimensions!.find((d) => d.key === "a2")!;
expect(a2.latest).toBe(9);
expect(a2.newestDaysAgo).toBe(6);
// The claim under test: nothing may say "your stress is high today".
expect(a2.current).toBe(false);
expect(
a2.staleNotice,
"a stale dimension ships without the instruction that makes it safe to read",
).toBeTruthy();
expect(a2.staleNotice).toContain(shiftDateKey(todayKey(), -6));
expect(a2.staleNotice!.toLowerCase()).toContain("do not describe");
// Stored literally: stress is inverse-oriented and says so rather than
// arriving pre-flipped.
expect(a2.inverse).toBe(true);
expect(a2.count).toBe(1);
// The block-level asOf the snapshot stamps on top of this comes from
// coverage.newestDaysAgo — the newest ENTRY, which is today, because a
// face was tapped this morning. Read as covering the dimensions it would
// make the six-day-old stress look current, so the block says which age
// governs which claim.
expect(features.mood?.coverage.newestDaysAgo).toBe(0);
expect(
features.mood?.dimensionsAsOfNote,
"the block carries two ages that disagree and nothing says which one governs the dimensions",
).toBeTruthy();
expect(features.mood?.dimensionsAsOfNote).toContain("newestDaysAgo");
});
it("never ships a latest without an age beside it, and names the scale", async () => {
await seedUser();
await seedEntry(1, { mood: "GUT", score: 4, a1: 8, a2: 2 });
const features = await extractFeatures(USER, false);
const dimensions = features.mood?.dimensions ?? [];
expect(dimensions.length).toBeGreaterThan(0);
for (const block of dimensions) {
expect(block.latest).not.toBeNull();
// The structural half of the fix: a `latest` with no age is exactly
// what produced a reading from last week stated as this morning's.
expect(block.newestDaysAgo).not.toBeNull();
expect(block.latestDate).not.toBeNull();
expect(typeof block.current).toBe("boolean");
// And the wording behind the numbers, so a 7 is not read as 7 out of 5.
expect(block.scale).toMatch(/0-10/);
expect(block.scale.length).toBeGreaterThan(20);
}
// Yesterday still backs a present-tense claim.
expect(dimensions.every((d) => d.current)).toBe(true);
});
it("omits the block entirely for an account that has only ever tapped a face", async () => {
await seedUser();
// Rows written before the columns existed look exactly like this.
await seedEntry(0, { mood: "GUT", score: 4 });
const features = await extractFeatures(USER, false);
// The mood aggregate is still there — the five-point axis is unaffected.
expect(features.mood).toBeDefined();
expect(features.mood?.latest).toBe(4);
// But nothing claims a self-state that was never recorded — and no note
// explaining a block that is not there.
expect(features.mood?.dimensions).toBeUndefined();
expect(features.mood?.dimensionsAsOfNote).toBeUndefined();
});
it("leaves the legacy scale line untouched", async () => {
await seedUser();
await seedEntry(0, { mood: "GUT", score: 4, a1: 7 });
const features = await extractFeatures(USER, false);
// Sent to the model verbatim and describing `score`, not the new axis.
// A phase that widened this string would have changed a sentence the
// model has been reading correctly for years.
expect(features.mood?.scale).toBe(
"1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
);
});
});
@@ -0,0 +1,490 @@
/**
* v1.37 — every mood writer resolves the same pleasantness value.
*
* Five paths write a `MoodEntry`, and only one of them is the web form. If
* the derivation lived at the call site, a mood tapped in a chat and the same
* mood typed into the form would drift apart the first time one of the five
* was edited without the others. This test posts the identical mood through
* all five and asserts the rows agree, against real Postgres and the real
* handlers:
*
* 1. POST /api/mood-entries (web + iOS single)
* 2. POST /api/mood-entries/bulk (iOS backfill)
* 3. logTelegramMood (the bot)
* 4. POST /api/import (JSON restore)
* 5. logMcpMood (the assistant tool)
*
* It also pins the three rules the columns exist for: a re-post refreshes the
* value rather than leaving a stale one beside a fresh mood, an explicit
* client value beats the derivation, and nothing ever invents A2 to A5.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-mood-writers-agree";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function createUserSession() {
await getPrismaClient().user.create({
data: {
id: USER,
username: "mood-writers-agree",
email: "mood-writers-agree@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: USER,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
}
function jsonRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
});
describe("mood writers agree on the derived level-A value", () => {
it("writes the same moodA1 through all five writers for the same mood", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { POST: postBulk } =
await import("@/app/api/mood-entries/bulk/route");
const { POST: postImport } = await import("@/app/api/import/route");
const { logTelegramMood } = await import("@/lib/mood/create-from-telegram");
const { logMcpMood } = await import("@/lib/mcp/writes");
// 1 — single create.
const single = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "writer-single",
}),
);
expect(single.status).toBe(201);
// 2 — bulk backfill. The request shape carries no level-A field and the
// server derives it, which is why the published contract does not move:
// this batch path is the one in `docs/api/openapi.yaml`, and it is
// byte-identical. The unpublished single routes DO answer five new keys
// on their reads, which is additive and which the client ignores.
const bulk = await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T09:00:00.000Z",
externalId: "writer-bulk",
},
],
}),
);
expect(bulk.status).toBe(200);
// 3 — the bot. Its input is the 1-5 score; it resolves the label itself.
const telegram = await logTelegramMood({
userId: USER,
score: 4,
tz: "Europe/Berlin",
externalId: "telegram:mood:1:2:4",
});
expect(telegram.created).toBe(true);
// 4 — the JSON import. Deliberately carries a `score` that disagrees with
// its own label: this is the one writer whose score comes out of the file
// rather than out of the label, and the self-assessment column has to
// follow the label the user picked, not the number the file asserts.
const imported = await postImport(
jsonRequest("/api/import", {
moodEntries: [
{
date: "2026-05-16",
mood: "GUT",
score: 2,
loggedAt: "2026-05-16T10:00:00.000Z",
externalId: "writer-import",
},
],
}),
);
expect(imported.status).toBe(200);
// 5 — the assistant tool.
const mcp = await logMcpMood({
userId: USER,
score: 4,
tz: "Europe/Berlin",
idempotencyKey: "writer-mcp-1",
});
expect(mcp.status).toBe("written");
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
orderBy: { source: "asc" },
select: {
source: true,
mood: true,
score: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
},
});
// Five writers, five rows — and one of them is the bulk path, which must
// not have been folded into the single path's row.
expect(rows).toHaveLength(5);
expect(rows.map((r) => r.source).sort()).toEqual([
"IMPORT",
"MANUAL",
"MANUAL",
"MCP",
"TELEGRAM",
]);
for (const row of rows) {
expect(row.mood).toBe("GUT");
// GUT sits at 7 on the level-A scale. Asserted by value on every row,
// not by comparing the rows to each other: five rows agreeing on a
// wrong number would pass a same-value assertion.
expect(row.moodA1).toBe(7);
// Nothing derives the other four, on any path.
expect(row.stressA2).toBeNull();
expect(row.energyA3).toBeNull();
expect(row.connectionA4).toBeNull();
expect(row.stabilityA5).toBeNull();
}
// The imported row keeps the file's own score while its level-A value
// follows the label — the disagreement the file carried is not copied
// into the new column.
const importedRow = rows.find((r) => r.source === "IMPORT");
expect(importedRow?.score).toBe(2);
expect(importedRow?.moodA1).toBe(7);
});
it("refreshes moodA1 when an externalId is re-posted with a different mood", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { POST: postBulk } =
await import("@/app/api/mood-entries/bulk/route");
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "LAUSIG",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "repost-single",
}),
);
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T08:10:00.000Z",
externalId: "repost-single",
}),
);
await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "LAUSIG",
moodLoggedAt: "2026-05-16T11:00:00.000Z",
externalId: "repost-bulk",
},
],
}),
);
await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T11:10:00.000Z",
externalId: "repost-bulk",
},
],
}),
);
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
orderBy: { externalId: "asc" },
select: { externalId: true, mood: true, score: true, moodA1: true },
});
// One row per externalId — the re-post updated in place, as before.
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.mood).toBe("SUPER_GUT");
expect(row.score).toBe(5);
// The update arm of both upserts restates the value. Without it the row
// would carry LAUSIG's 1 beside SUPER_GUT's 5.
expect(row.moodA1).toBe(9);
}
});
it("leaves a dimension the re-post said nothing about exactly where it was", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { POST: postBulk } =
await import("@/app/api/mood-entries/bulk/route");
// Somebody answers the sliders on the web.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "preserve-single",
a2: 9,
a3: 2,
}),
);
// The phone re-posts the same entry it holds: label, timestamp, id, and no
// sliders, because its build has none. This must not blank what the web
// wrote. Absence in a request means "nothing to say", not "set to
// nothing" — a request that cleared four columns by staying silent would
// destroy answers on every sync round.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:05:00.000Z",
externalId: "preserve-single",
}),
);
const afterRepost = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER, externalId: "preserve-single" },
});
expect(afterRepost.mood).toBe("GUT");
// Pleasantness DOES follow the label: the request carried one, and a
// stale A1 beside a changed face would contradict the entry itself.
expect(afterRepost.moodA1).toBe(7);
// The four the request said nothing about are untouched.
expect(afterRepost.stressA2).toBe(9);
expect(afterRepost.energyA3).toBe(2);
expect(afterRepost.connectionA4).toBeNull();
expect(afterRepost.stabilityA5).toBeNull();
// An explicit null is how an answer is taken back — the other route out of
// the same three-state contract.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:06:00.000Z",
externalId: "preserve-single",
a2: null,
}),
);
const afterClear = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER, externalId: "preserve-single" },
});
expect(afterClear.stressA2).toBeNull();
// Clearing one says nothing about the others.
expect(afterClear.energyA3).toBe(2);
// The batch path answers the same way, and always has: it carries no
// level-A input at all, so every re-import preserves.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T12:00:00.000Z",
externalId: "preserve-bulk",
a4: 6,
}),
);
await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T12:30:00.000Z",
externalId: "preserve-bulk",
},
],
}),
);
const afterBulk = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER, externalId: "preserve-bulk" },
});
expect(afterBulk.mood).toBe("SUPER_GUT");
expect(afterBulk.moodA1).toBe(9);
expect(afterBulk.connectionA4).toBe(6);
});
it("lets an explicit client value win over the derivation, and stores the other four literally", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const res = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T12:00:00.000Z",
// A day that felt middling overall but was genuinely stressful: the
// whole reason five values beat one.
a1: 4,
a2: 9,
a3: 2,
}),
);
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: Record<string, unknown> & {
a1: number;
a2: number;
a3: number;
a4: null;
a5: null;
};
};
// The response echoes the values under the keys the request used.
expect(json.data.a1).toBe(4);
expect(json.data.a2).toBe(9);
expect(json.data.a5).toBeNull();
// And under those keys ONLY. The routes spread the whole row, so simply
// adding the wire keys shipped every value twice under two spellings; a
// client would then have picked one and been stuck with it.
const keys = Object.keys(json.data as Record<string, unknown>);
expect(keys).not.toContain("moodA1");
expect(keys).not.toContain("stressA2");
expect(keys).not.toContain("energyA3");
expect(keys).not.toContain("connectionA4");
expect(keys).not.toContain("stabilityA5");
const row = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER },
});
// Not 5 — OKAY's derived value lost to the number the user set.
expect(row.moodA1).toBe(4);
// Stored literally, higher meaning more stress. A value flipped on the
// way in could never be recovered.
expect(row.stressA2).toBe(9);
expect(row.energyA3).toBe(2);
// Untouched sliders write nothing at all.
expect(row.connectionA4).toBeNull();
expect(row.stabilityA5).toBeNull();
// The five-point axis is untouched by any of this.
expect(row.score).toBe(3);
});
it("re-derives A1 when an entry's mood is corrected, and clears a value on an explicit null", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
const created = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "SCHLECHT",
moodLoggedAt: "2026-05-16T13:00:00.000Z",
a2: 8,
}),
);
const createdJson = (await created.json()) as { data: { id: string } };
const id = createdJson.data.id;
const corrected = await PUT(
new NextRequest(`http://localhost/api/mood-entries/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mood: "GUT", a2: null }),
}),
{ params: Promise.resolve({ id }) },
);
expect(corrected.status).toBe(200);
const row = await getPrismaClient().moodEntry.findUniqueOrThrow({
where: { id },
});
expect(row.mood).toBe("GUT");
expect(row.score).toBe(4);
// The label moved, so pleasantness moved with it — the same way `score`
// does. A stale 3 here would contradict the entry's own label.
expect(row.moodA1).toBe(7);
// An explicit null takes back an answer.
expect(row.stressA2).toBeNull();
});
it("will not empty pleasantness, on either route", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
// An entry always carries a five-point label, so it always implies a
// pleasantness value. A null asks for one that cannot exist, and both
// routes answer it the same way: derive from the label. The capture
// surfaces therefore offer no clear control for this one field, which is
// the UI half of the same rule.
const created = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T14:00:00.000Z",
a1: null,
}),
);
expect(created.status).toBe(201);
const createdJson = (await created.json()) as {
data: { id: string; a1: number };
};
expect(createdJson.data.a1).toBe(9);
const id = createdJson.data.id;
const cleared = await PUT(
new NextRequest(`http://localhost/api/mood-entries/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ a1: null }),
}),
{ params: Promise.resolve({ id }) },
);
expect(cleared.status).toBe(200);
const row = await getPrismaClient().moodEntry.findUniqueOrThrow({
where: { id },
});
// Derived from the entry's own stored label, not emptied.
expect(row.moodA1).toBe(9);
});
});
+3 -247
View File
@@ -10,7 +10,8 @@
* than a hand-written fixture, for the same reason the wipe list is derived
* rather than hand-written: a fixture written today covers the schema of
* today. A table added next year is seeded automatically and therefore
* asserted automatically.
* asserted automatically. It lives in `./wipe-seeder` because the admin wipe
* has to be measured with the same ruler.
*
* What it asserts:
* - every model in `WIPE_MODELS` has zero rows for the account afterwards;
@@ -25,20 +26,17 @@
* plan being right is what `src/__tests__/data-wipe-completeness.test.ts`
* holds against `schema.prisma`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { countRows, modelTableMap, seedEveryTable } from "./wipe-seeder";
import {
INSTANCE_SCOPED,
USER_KEPT_FIELDS,
USER_RESET,
WIPE_EXEMPT,
WIPE_MODELS,
WIPE_OWNER_FIELDS,
} from "@/lib/data-wipe/wipe-plan";
import type { PrismaClient } from "@/generated/prisma/client";
import {
@@ -71,248 +69,6 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// ── schema.prisma model → table name ────────────────────────────────────────
function modelTableMap(): Map<string, string> {
const schema = readFileSync(
join(process.cwd(), "prisma", "schema.prisma"),
"utf8",
);
const map = new Map<string, string>();
for (const block of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
const mapped = /@@map\("([^"]+)"\)/.exec(block[2]);
map.set(block[1], mapped ? mapped[1] : block[1]);
}
return map;
}
// ── generic seeder ──────────────────────────────────────────────────────────
interface ColumnRow {
table_name: string;
column_name: string;
is_nullable: string;
column_default: string | null;
udt_name: string;
}
interface ForeignKeyRow {
table_name: string;
column_name: string;
ref_table: string;
ref_column: string;
cascades: boolean;
}
/** Tables the seeder never writes: Prisma's own ledger, and the account row. */
const SEED_SKIP = new Set(["_prisma_migrations", "users"]);
function synthesise(udt: string, enums: Map<string, string>, tag: string) {
if (udt.startsWith("_")) return `'{}'::${udt.slice(1)}[]`;
switch (udt) {
case "text":
case "varchar":
case "bpchar":
return `'${tag}'`;
case "int2":
case "int4":
case "int8":
case "numeric":
case "float4":
case "float8":
return "1";
case "bool":
return "false";
case "timestamp":
case "timestamptz":
case "date":
return "now()";
case "bytea":
return "'\\x00'::bytea";
case "json":
case "jsonb":
return `'{}'::${udt}`;
case "uuid":
return "gen_random_uuid()";
default: {
const label = enums.get(udt);
if (label) return `'${label}'::"${udt}"`;
throw new Error(`seeder has no value for column type "${udt}"`);
}
}
}
/**
* Insert one row into every table in `public`, parents first, wiring every
* foreign key to the row already created for its target and every `user_id` to
* the account under test.
*/
async function seedEveryTable(
prisma: PrismaClient,
userId: string,
): Promise<string[]> {
const columns = await prisma.$queryRaw<ColumnRow[]>`
SELECT table_name, column_name, is_nullable, column_default, udt_name
FROM information_schema.columns
WHERE table_schema = 'public'`;
const foreignKeys = await prisma.$queryRaw<ForeignKeyRow[]>`
SELECT con.conrelid::regclass::text AS table_name,
att.attname AS column_name,
con.confrelid::regclass::text AS ref_table,
ratt.attname AS ref_column,
con.confdeltype = 'c' AS cascades
FROM pg_constraint con
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS rk(attnum, ord)
ON rk.ord = ck.ord
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ck.attnum
JOIN pg_attribute ratt ON ratt.attrelid = con.confrelid AND ratt.attnum = rk.attnum
WHERE con.contype = 'f' AND con.connamespace = 'public'::regnamespace`;
const enumRows = await prisma.$queryRaw<
Array<{ typname: string; enumlabel: string }>
>`
SELECT t.typname, e.enumlabel
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
ORDER BY t.typname, e.enumsortorder`;
const enums = new Map<string, string>();
for (const row of enumRows) {
if (!enums.has(row.typname)) enums.set(row.typname, row.enumlabel);
}
const byTable = new Map<string, ColumnRow[]>();
for (const column of columns) {
if (SEED_SKIP.has(column.table_name)) continue;
const bucket = byTable.get(column.table_name) ?? [];
bucket.push(column);
byTable.set(column.table_name, bucket);
}
const fkByTable = new Map<string, ForeignKeyRow[]>();
for (const fk of foreignKeys) {
const bucket = fkByTable.get(fk.table_name) ?? [];
bucket.push(fk);
fkByTable.set(fk.table_name, bucket);
}
// Parents before children. A self-reference or an optional cycle is skipped
// by only ordering on the columns the insert actually fills.
const pending = new Set(byTable.keys());
const ordered: string[] = [];
while (pending.size > 0) {
let progressed = false;
for (const table of [...pending]) {
const blocked = (fkByTable.get(table) ?? []).some(
(fk) => fk.ref_table !== table && pending.has(fk.ref_table),
);
if (blocked) continue;
ordered.push(table);
pending.delete(table);
progressed = true;
}
if (!progressed) {
throw new Error(
`foreign-key cycle among: ${[...pending].sort().join(", ")}`,
);
}
}
const insertedId = new Map<string, string>();
let tag = 0;
for (const table of ordered) {
const tableColumns = byTable.get(table)!;
const fks = fkByTable.get(table) ?? [];
const names: string[] = [];
const values: string[] = [];
for (const column of tableColumns) {
const fk = fks.find((f) => f.column_name === column.column_name);
const required =
column.is_nullable === "NO" && column.column_default === null;
const isUserColumn = column.column_name === "user_id";
if (fk?.ref_table === "users") {
// Always bind to the account under test, nullable or not — a NULL
// owner would put the row outside the wipe and make the assertion
// vacuous.
names.push(column.column_name);
values.push(`'${userId}'`);
continue;
}
if (!required && !isUserColumn) continue;
if (fk) {
const parent = insertedId.get(fk.ref_table);
if (!parent) {
throw new Error(
`${table}.${column.column_name} references ${fk.ref_table}, which was not seeded`,
);
}
names.push(column.column_name);
values.push(`'${parent}'`);
continue;
}
names.push(column.column_name);
values.push(synthesise(column.udt_name, enums, `seed-${tag++}`));
}
const sql =
names.length === 0
? `INSERT INTO "${table}" DEFAULT VALUES ON CONFLICT DO NOTHING`
: `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")}) VALUES (${values.join(", ")}) ON CONFLICT DO NOTHING`;
await prisma.$executeRawUnsafe(sql);
const hasId = tableColumns.some((c) => c.column_name === "id");
if (hasId) {
const rows = await prisma.$queryRawUnsafe<Array<{ id: string }>>(
`SELECT id FROM "${table}" LIMIT 1`,
);
if (rows[0]) insertedId.set(table, rows[0].id);
}
}
return ordered;
}
/**
* The columns that bind a row of `model` to an account, as SQL identifiers.
*
* `user_id` is the convention. A model that relates two accounts cannot use
* it, and the wipe plan already declares its columns — read them from there
* rather than keeping a second list here, or the two would answer differently
* and this file would be asserting against its own idea of ownership instead
* of the one the route uses.
*/
function ownerColumns(model: string | undefined): string[] {
const declared = model ? WIPE_OWNER_FIELDS[model] : undefined;
if (!declared) return ["user_id"];
return declared.map((field) =>
field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`),
);
}
async function countRows(
prisma: PrismaClient,
table: string,
userId?: string,
model?: string,
): Promise<number> {
const predicate = ownerColumns(model)
.map((column) => `"${column}" = $1`)
.join(" OR ");
const rows = userId
? await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
`SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`,
userId,
)
: await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
`SELECT count(*)::bigint AS n FROM "${table}"`,
);
return Number(rows[0].n);
}
async function managedProfileLockIsHeld(
prisma: PrismaClient,
profileId: string,
+265
View File
@@ -0,0 +1,265 @@
/**
* Schema-driven seeding + counting for the two wipe integration guards.
*
* The whole defect class here is about rows that SURVIVE, so a mocked Prisma
* proves nothing: it can only show that the calls a route makes were made.
* Both guards therefore seed a row into every table in the schema, run their
* wipe against real Postgres, and then ask the database what is left.
*
* The seeding is driven by `information_schema` and `pg_constraint` rather
* than a hand-written fixture, for the same reason the wipe list is derived
* rather than hand-written: a fixture written today covers the schema of
* today. A table added next year is seeded automatically and therefore
* asserted automatically.
*
* It lives here rather than in either test file because the account wipe and
* the admin wipe have to be measured with the same ruler. Two copies would
* drift, and the copy that drifted would be the one whose route was wrong.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { PrismaClient } from "@/generated/prisma/client";
import { WIPE_OWNER_FIELDS } from "@/lib/data-wipe/wipe-plan";
// ── schema.prisma model → table name ────────────────────────────────────────
export function modelTableMap(): Map<string, string> {
const schema = readFileSync(
join(process.cwd(), "prisma", "schema.prisma"),
"utf8",
);
const map = new Map<string, string>();
for (const block of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
const mapped = /@@map\("([^"]+)"\)/.exec(block[2]);
map.set(block[1], mapped ? mapped[1] : block[1]);
}
return map;
}
// ── generic seeder ──────────────────────────────────────────────────────────
interface ColumnRow {
table_name: string;
column_name: string;
is_nullable: string;
column_default: string | null;
udt_name: string;
}
interface ForeignKeyRow {
table_name: string;
column_name: string;
ref_table: string;
ref_column: string;
cascades: boolean;
}
/** Tables the seeder never writes: Prisma's own ledger, and the account row. */
const SEED_SKIP = new Set(["_prisma_migrations", "users"]);
function synthesise(udt: string, enums: Map<string, string>, tag: string) {
if (udt.startsWith("_")) return `'{}'::${udt.slice(1)}[]`;
switch (udt) {
case "text":
case "varchar":
case "bpchar":
return `'${tag}'`;
case "int2":
case "int4":
case "int8":
case "numeric":
case "float4":
case "float8":
return "1";
case "bool":
return "false";
case "timestamp":
case "timestamptz":
case "date":
return "now()";
case "bytea":
return "'\\x00'::bytea";
case "json":
case "jsonb":
return `'{}'::${udt}`;
case "uuid":
return "gen_random_uuid()";
default: {
const label = enums.get(udt);
if (label) return `'${label}'::"${udt}"`;
throw new Error(`seeder has no value for column type "${udt}"`);
}
}
}
/**
* Insert one row into every table in `public`, parents first, wiring every
* foreign key to the row already created for its target and every `user_id` to
* the account under test.
*/
export async function seedEveryTable(
prisma: PrismaClient,
userId: string,
): Promise<string[]> {
const columns = await prisma.$queryRaw<ColumnRow[]>`
SELECT table_name, column_name, is_nullable, column_default, udt_name
FROM information_schema.columns
WHERE table_schema = 'public'`;
const foreignKeys = await prisma.$queryRaw<ForeignKeyRow[]>`
SELECT con.conrelid::regclass::text AS table_name,
att.attname AS column_name,
con.confrelid::regclass::text AS ref_table,
ratt.attname AS ref_column,
con.confdeltype = 'c' AS cascades
FROM pg_constraint con
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS rk(attnum, ord)
ON rk.ord = ck.ord
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ck.attnum
JOIN pg_attribute ratt ON ratt.attrelid = con.confrelid AND ratt.attnum = rk.attnum
WHERE con.contype = 'f' AND con.connamespace = 'public'::regnamespace`;
const enumRows = await prisma.$queryRaw<
Array<{ typname: string; enumlabel: string }>
>`
SELECT t.typname, e.enumlabel
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
ORDER BY t.typname, e.enumsortorder`;
const enums = new Map<string, string>();
for (const row of enumRows) {
if (!enums.has(row.typname)) enums.set(row.typname, row.enumlabel);
}
const byTable = new Map<string, ColumnRow[]>();
for (const column of columns) {
if (SEED_SKIP.has(column.table_name)) continue;
const bucket = byTable.get(column.table_name) ?? [];
bucket.push(column);
byTable.set(column.table_name, bucket);
}
const fkByTable = new Map<string, ForeignKeyRow[]>();
for (const fk of foreignKeys) {
const bucket = fkByTable.get(fk.table_name) ?? [];
bucket.push(fk);
fkByTable.set(fk.table_name, bucket);
}
// Parents before children. A self-reference or an optional cycle is skipped
// by only ordering on the columns the insert actually fills.
const pending = new Set(byTable.keys());
const ordered: string[] = [];
while (pending.size > 0) {
let progressed = false;
for (const table of [...pending]) {
const blocked = (fkByTable.get(table) ?? []).some(
(fk) => fk.ref_table !== table && pending.has(fk.ref_table),
);
if (blocked) continue;
ordered.push(table);
pending.delete(table);
progressed = true;
}
if (!progressed) {
throw new Error(
`foreign-key cycle among: ${[...pending].sort().join(", ")}`,
);
}
}
const insertedId = new Map<string, string>();
let tag = 0;
for (const table of ordered) {
const tableColumns = byTable.get(table)!;
const fks = fkByTable.get(table) ?? [];
const names: string[] = [];
const values: string[] = [];
for (const column of tableColumns) {
const fk = fks.find((f) => f.column_name === column.column_name);
const required =
column.is_nullable === "NO" && column.column_default === null;
const isUserColumn = column.column_name === "user_id";
if (fk?.ref_table === "users") {
// Always bind to the account under test, nullable or not — a NULL
// owner would put the row outside the wipe and make the assertion
// vacuous.
names.push(column.column_name);
values.push(`'${userId}'`);
continue;
}
if (!required && !isUserColumn) continue;
if (fk) {
const parent = insertedId.get(fk.ref_table);
if (!parent) {
throw new Error(
`${table}.${column.column_name} references ${fk.ref_table}, which was not seeded`,
);
}
names.push(column.column_name);
values.push(`'${parent}'`);
continue;
}
names.push(column.column_name);
values.push(synthesise(column.udt_name, enums, `seed-${tag++}`));
}
const sql =
names.length === 0
? `INSERT INTO "${table}" DEFAULT VALUES ON CONFLICT DO NOTHING`
: `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")}) VALUES (${values.join(", ")}) ON CONFLICT DO NOTHING`;
await prisma.$executeRawUnsafe(sql);
const hasId = tableColumns.some((c) => c.column_name === "id");
if (hasId) {
const rows = await prisma.$queryRawUnsafe<Array<{ id: string }>>(
`SELECT id FROM "${table}" LIMIT 1`,
);
if (rows[0]) insertedId.set(table, rows[0].id);
}
}
return ordered;
}
/**
* The columns that bind a row of `model` to an account, as SQL identifiers.
*
* `user_id` is the convention. A model that relates two accounts cannot use
* it, and the wipe plan already declares its columns — read them from there
* rather than keeping a second list here, or the two would answer differently
* and this file would be asserting against its own idea of ownership instead
* of the one the route uses.
*/
export function ownerColumns(model: string | undefined): string[] {
const declared = model ? WIPE_OWNER_FIELDS[model] : undefined;
if (!declared) return ["user_id"];
return declared.map((field) =>
field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`),
);
}
export async function countRows(
prisma: PrismaClient,
table: string,
userId?: string,
model?: string,
): Promise<number> {
const predicate = ownerColumns(model)
.map((column) => `"${column}" = $1`)
.join(" OR ");
const rows = userId
? await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
`SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`,
userId,
)
: await prisma.$queryRawUnsafe<Array<{ n: bigint }>>(
`SELECT count(*)::bigint AS n FROM "${table}"`,
);
return Number(rows[0].n);
}