diff --git a/CHANGELOG.md b/CHANGELOG.md index 33225f7d8..c6292d151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [1.15.13] — 2026-06-07 — search, filter, and bulk delete + +### Added + +- **Filter your measurements and mood entries.** Both lists now filter by source (manual, Apple Health, Withings, …) and by a date range, alongside the existing type / mood filter and sorting. +- **Select and delete in bulk.** Tick several entries on a page and remove them in one step, with a confirmation step. Deletions stay reversible on your devices the same way a single delete does. + ## [1.15.12] — 2026-06-07 — a fairer health score and a richer overview ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eca21c232..232fd2dae 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.12 + version: 1.15.13 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1508,6 +1508,18 @@ paths: type: string enum: - rollup + - in: query + name: sourceEq + schema: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT - in: query name: groupBy schema: @@ -1698,6 +1710,31 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/measurements/bulk-delete: + post: + tags: + - Measurements + summary: Bulk soft-delete measurements + description: Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the + single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited + `measurements:bulk-delete:`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is + the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementsBulkDeleteRequest" + responses: + "200": + description: Bulk delete processed. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/sleep/night: get: tags: @@ -3998,6 +4035,19 @@ components: - externalIds description: iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard). + MeasurementsBulkDeleteRequest: + type: object + properties: + ids: + minItems: 1 + maxItems: 200 + type: array + items: + type: string + minLength: 1 + required: + - ids + description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak). DeviceRegisterRequest: type: object properties: @@ -7012,6 +7062,33 @@ components: required: - deletedCount additionalProperties: false + MeasurementsBulkDeleteResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementsBulkDeleteResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementsBulkDeleteResult: + type: object + properties: + deleted: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - deleted + additionalProperties: false SleepNightResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index 976ea7cc8..6c5c6e4b1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -494,7 +494,15 @@ "typeEnergyExpenditureKj": "Energieverbrauch", "typeAverageHeartRate": "Durchschnittlicher Puls", "typeMaxHeartRate": "Maximaler Puls", - "typeSleepDisturbanceCount": "Schlafstörungen" + "typeSleepDisturbanceCount": "Schlafstörungen", + "filterBySource": "Nach Quelle filtern", + "sourceComputed": "Berechnet", + "sourceWhoop": "WHOOP", + "sourceFitbit": "Fitbit", + "bulkDeleteSuccess": "{count} Messungen gelöscht", + "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", + "bulkDeleteConfirmTitle": "{count} Messungen löschen?", + "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht." }, "mood": { "title": "Stimmung", @@ -538,6 +546,8 @@ "deleteConfirmTitle": "Eintrag löschen?", "deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.", "pageInfo": "Seite {page} von {total}", + "previousPage": "Vorherige Seite", + "nextPage": "Nächste Seite", "loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.", "note": "Notiz", "notePlaceholder": "Notiz dazu, wie du dich fühlst…", @@ -598,7 +608,16 @@ "factorConflict": "Konflikt", "factorFamily": "Familie", "factorSadness": "Traurigkeit" - } + }, + "filterBySource": "Nach Quelle filtern", + "sourceManual": "Manuell", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "bulkDeleteSuccess": "{count} Einträge gelöscht", + "bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge", + "bulkDeleteConfirmTitle": "{count} Einträge löschen?", + "bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht." }, "medications": { "title": "Medikamente", @@ -5303,5 +5322,16 @@ "fertileSoonBody": "Dein geschätztes fruchtbares Fenster beginnt voraussichtlich in etwa zwei Tagen. Dies ist eine Schätzung und keine medizinische oder verhütende Garantie.", "discreetTitle": "HealthLog-Erinnerung", "discreetBody": "Du hast eine Erinnerung in HealthLog." + }, + "dataList": { + "allSources": "Alle Quellen", + "dateFrom": "Von", + "dateTo": "Bis", + "selectAll": "Alle auf dieser Seite auswählen", + "selectRow": "Zeile auswählen", + "selected": "{count} ausgewählt", + "deleteN": "Löschen ({count})", + "clearSelection": "Auswahl aufheben", + "selectionBarLabel": "Auswahlaktionen" } } diff --git a/messages/en.json b/messages/en.json index 1831e188b..547a0a04e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -494,7 +494,15 @@ "typeEnergyExpenditureKj": "Energy expenditure", "typeAverageHeartRate": "Average heart rate", "typeMaxHeartRate": "Maximum heart rate", - "typeSleepDisturbanceCount": "Sleep disturbances" + "typeSleepDisturbanceCount": "Sleep disturbances", + "filterBySource": "Filter by source", + "sourceComputed": "Computed", + "sourceWhoop": "WHOOP", + "sourceFitbit": "Fitbit", + "bulkDeleteSuccess": "{count} measurements deleted", + "bulkDeleteError": "Error deleting the selected measurements", + "bulkDeleteConfirmTitle": "Delete {count} measurements?", + "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted." }, "mood": { "title": "Mood", @@ -538,6 +546,8 @@ "deleteConfirmTitle": "Delete entry?", "deleteConfirmDescription": "This mood entry will be permanently deleted.", "pageInfo": "Page {page} of {total}", + "previousPage": "Previous page", + "nextPage": "Next page", "loginRequired": "Please sign in to view mood entries.", "note": "Note", "notePlaceholder": "Add a note about how you're feeling…", @@ -598,7 +608,16 @@ "factorConflict": "conflict", "factorFamily": "family", "factorSadness": "sadness" - } + }, + "filterBySource": "Filter by source", + "sourceManual": "Manual", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "bulkDeleteSuccess": "{count} entries deleted", + "bulkDeleteError": "Error deleting the selected entries", + "bulkDeleteConfirmTitle": "Delete {count} entries?", + "bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted." }, "medications": { "title": "Medications", @@ -5303,5 +5322,16 @@ "fertileSoonBody": "Your estimated fertile window is predicted to start in about two days. This is an estimate, not a medical or contraceptive guarantee.", "discreetTitle": "HealthLog reminder", "discreetBody": "You have a reminder in HealthLog." + }, + "dataList": { + "allSources": "All sources", + "dateFrom": "From", + "dateTo": "To", + "selectAll": "Select all on this page", + "selectRow": "Select row", + "selected": "{count} selected", + "deleteN": "Delete ({count})", + "clearSelection": "Clear selection", + "selectionBarLabel": "Selection actions" } } diff --git a/messages/es.json b/messages/es.json index ec2fd334e..21c074df3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -494,7 +494,15 @@ "typeEnergyExpenditureKj": "Gasto energético", "typeAverageHeartRate": "Frecuencia cardíaca media", "typeMaxHeartRate": "Frecuencia cardíaca máxima", - "typeSleepDisturbanceCount": "Interrupciones del sueño" + "typeSleepDisturbanceCount": "Interrupciones del sueño", + "filterBySource": "Filtrar por fuente", + "sourceComputed": "Calculado", + "sourceWhoop": "WHOOP", + "sourceFitbit": "Fitbit", + "bulkDeleteSuccess": "{count} mediciones eliminadas", + "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", + "bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?", + "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente." }, "mood": { "title": "Estado de ánimo", @@ -538,6 +546,8 @@ "deleteConfirmTitle": "¿Eliminar entrada?", "deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.", "pageInfo": "Página {page} de {total}", + "previousPage": "Página anterior", + "nextPage": "Página siguiente", "loginRequired": "Inicia sesión para ver tus estados de ánimo.", "note": "Nota", "notePlaceholder": "Añade una nota sobre cómo te sientes…", @@ -598,7 +608,16 @@ "factorConflict": "conflicto", "factorFamily": "familia", "factorSadness": "tristeza" - } + }, + "filterBySource": "Filtrar por fuente", + "sourceManual": "Manual", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "bulkDeleteSuccess": "{count} entradas eliminadas", + "bulkDeleteError": "Error al eliminar las entradas seleccionadas", + "bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?", + "bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente." }, "medications": { "title": "Medicamentos", @@ -5303,5 +5322,16 @@ "fertileSoonBody": "Se prevé que tu ventana fértil estimada comience en unos dos días. Es una estimación, no una garantía médica ni anticonceptiva.", "discreetTitle": "Recordatorio de HealthLog", "discreetBody": "Tienes un recordatorio en HealthLog." + }, + "dataList": { + "allSources": "Todas las fuentes", + "dateFrom": "Desde", + "dateTo": "Hasta", + "selectAll": "Seleccionar todo en esta página", + "selectRow": "Seleccionar fila", + "selected": "{count} seleccionados", + "deleteN": "Eliminar ({count})", + "clearSelection": "Borrar selección", + "selectionBarLabel": "Acciones de selección" } } diff --git a/messages/fr.json b/messages/fr.json index bfa9dd524..f6c054532 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -494,7 +494,15 @@ "typeEnergyExpenditureKj": "Dépense énergétique", "typeAverageHeartRate": "Fréquence cardiaque moyenne", "typeMaxHeartRate": "Fréquence cardiaque maximale", - "typeSleepDisturbanceCount": "Perturbations du sommeil" + "typeSleepDisturbanceCount": "Perturbations du sommeil", + "filterBySource": "Filtrer par source", + "sourceComputed": "Calculé", + "sourceWhoop": "WHOOP", + "sourceFitbit": "Fitbit", + "bulkDeleteSuccess": "{count} mesures supprimées", + "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", + "bulkDeleteConfirmTitle": "Supprimer {count} mesures ?", + "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées." }, "mood": { "title": "Humeur", @@ -538,6 +546,8 @@ "deleteConfirmTitle": "Supprimer l’entrée ?", "deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.", "pageInfo": "Page {page} sur {total}", + "previousPage": "Page précédente", + "nextPage": "Page suivante", "loginRequired": "Connecte-toi pour voir tes entrées d’humeur.", "note": "Note", "notePlaceholder": "Ajoutez une note sur votre ressenti…", @@ -598,7 +608,16 @@ "factorConflict": "conflit", "factorFamily": "famille", "factorSadness": "tristesse" - } + }, + "filterBySource": "Filtrer par source", + "sourceManual": "Manuel", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "bulkDeleteSuccess": "{count} entrées supprimées", + "bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées", + "bulkDeleteConfirmTitle": "Supprimer {count} entrées ?", + "bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées." }, "medications": { "title": "Médicaments", @@ -5303,5 +5322,16 @@ "fertileSoonBody": "Votre fenêtre fertile estimée devrait commencer dans environ deux jours. Il s'agit d'une estimation, et non d'une garantie médicale ou contraceptive.", "discreetTitle": "Rappel HealthLog", "discreetBody": "Vous avez un rappel dans HealthLog." + }, + "dataList": { + "allSources": "Toutes les sources", + "dateFrom": "Du", + "dateTo": "Au", + "selectAll": "Tout sélectionner sur cette page", + "selectRow": "Sélectionner la ligne", + "selected": "{count} sélectionné(s)", + "deleteN": "Supprimer ({count})", + "clearSelection": "Effacer la sélection", + "selectionBarLabel": "Actions de sélection" } } diff --git a/messages/it.json b/messages/it.json index 3200c2b2e..e3b55fe43 100644 --- a/messages/it.json +++ b/messages/it.json @@ -494,7 +494,15 @@ "typeEnergyExpenditureKj": "Dispendio energetico", "typeAverageHeartRate": "Frequenza cardiaca media", "typeMaxHeartRate": "Frequenza cardiaca massima", - "typeSleepDisturbanceCount": "Disturbi del sonno" + "typeSleepDisturbanceCount": "Disturbi del sonno", + "filterBySource": "Filtra per fonte", + "sourceComputed": "Calcolato", + "sourceWhoop": "WHOOP", + "sourceFitbit": "Fitbit", + "bulkDeleteSuccess": "{count} misurazioni eliminate", + "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", + "bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?", + "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente." }, "mood": { "title": "Umore", @@ -538,6 +546,8 @@ "deleteConfirmTitle": "Eliminare la voce?", "deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.", "pageInfo": "Pagina {page} di {total}", + "previousPage": "Pagina precedente", + "nextPage": "Pagina successiva", "loginRequired": "Accedi per vedere le voci dell’umore.", "note": "Nota", "notePlaceholder": "Aggiungi una nota su come ti senti…", @@ -598,7 +608,16 @@ "factorConflict": "conflitto", "factorFamily": "famiglia", "factorSadness": "tristezza" - } + }, + "filterBySource": "Filtra per fonte", + "sourceManual": "Manuale", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "bulkDeleteSuccess": "{count} voci eliminate", + "bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate", + "bulkDeleteConfirmTitle": "Eliminare {count} voci?", + "bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente." }, "medications": { "title": "Farmaci", @@ -5303,5 +5322,16 @@ "fertileSoonBody": "La tua finestra fertile stimata dovrebbe iniziare tra circa due giorni. È una stima, non una garanzia medica o contraccettiva.", "discreetTitle": "Promemoria HealthLog", "discreetBody": "Hai un promemoria in HealthLog." + }, + "dataList": { + "allSources": "Tutte le fonti", + "dateFrom": "Dal", + "dateTo": "Al", + "selectAll": "Seleziona tutto in questa pagina", + "selectRow": "Seleziona riga", + "selected": "{count} selezionati", + "deleteN": "Elimina ({count})", + "clearSelection": "Cancella selezione", + "selectionBarLabel": "Azioni di selezione" } } diff --git a/messages/pl.json b/messages/pl.json index fe399b31e..afae4ef72 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -494,7 +494,15 @@ "typeEnergyExpenditureKj": "Wydatek energetyczny", "typeAverageHeartRate": "Średnie tętno", "typeMaxHeartRate": "Maksymalne tętno", - "typeSleepDisturbanceCount": "Zakłócenia snu" + "typeSleepDisturbanceCount": "Zakłócenia snu", + "filterBySource": "Filtruj według źródła", + "sourceComputed": "Obliczone", + "sourceWhoop": "WHOOP", + "sourceFitbit": "Fitbit", + "bulkDeleteSuccess": "Usunięto pomiary: {count}", + "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", + "bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?", + "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte." }, "mood": { "title": "Nastrój", @@ -538,6 +546,8 @@ "deleteConfirmTitle": "Usunąć wpis?", "deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.", "pageInfo": "Strona {page} z {total}", + "previousPage": "Poprzednia strona", + "nextPage": "Następna strona", "loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.", "note": "Notatka", "notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…", @@ -598,7 +608,16 @@ "factorConflict": "konflikt", "factorFamily": "rodzina", "factorSadness": "smutek" - } + }, + "filterBySource": "Filtruj według źródła", + "sourceManual": "Ręcznie", + "sourceWeb": "Web", + "sourceTelegram": "Telegram", + "sourceDaylio": "Daylio", + "bulkDeleteSuccess": "Usunięto wpisy: {count}", + "bulkDeleteError": "Błąd podczas usuwania wybranych wpisów", + "bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?", + "bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte." }, "medications": { "title": "Leki", @@ -5303,5 +5322,16 @@ "fertileSoonBody": "Szacowane okno płodności rozpocznie się prawdopodobnie za około dwa dni. To szacunek, a nie gwarancja medyczna ani antykoncepcyjna.", "discreetTitle": "Przypomnienie HealthLog", "discreetBody": "Masz przypomnienie w HealthLog." + }, + "dataList": { + "allSources": "Wszystkie źródła", + "dateFrom": "Od", + "dateTo": "Do", + "selectAll": "Zaznacz wszystko na tej stronie", + "selectRow": "Zaznacz wiersz", + "selected": "Zaznaczono: {count}", + "deleteN": "Usuń ({count})", + "clearSelection": "Wyczyść zaznaczenie", + "selectionBarLabel": "Akcje zaznaczenia" } } diff --git a/package.json b/package.json index 1afb16f1a..2a1960a16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.12", + "version": "1.15.13", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0136_v11513_source_filter_indexes/migration.sql b/prisma/migrations/0136_v11513_source_filter_indexes/migration.sql new file mode 100644 index 000000000..e2dab1bb0 --- /dev/null +++ b/prisma/migrations/0136_v11513_source_filter_indexes/migration.sql @@ -0,0 +1,15 @@ +-- v1.15.13 — source-filter support indexes for the measurements + mood +-- management lists. The new list surfaces add a source filter (and the +-- existing source/value sort). No index leads with `(user_id, source)` +-- today, so a source filter forces a `user_id` scan + in-memory sort — +-- the landmine at 100–200k rows per user. These two additive indexes turn +-- a source filter + recency sort into an index range scan. +-- +-- Pure additive `CREATE INDEX IF NOT EXISTS`, no backfill — same low-risk +-- shape as migration 0123. + +CREATE INDEX IF NOT EXISTS "measurements_user_id_source_measured_at_idx" + ON "measurements" ("user_id", "source", "measured_at" DESC); + +CREATE INDEX IF NOT EXISTS "mood_entries_user_id_source_mood_logged_at_idx" + ON "mood_entries" ("user_id", "source", "mood_logged_at" DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db2fb2cb7..5c97ed5da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -850,6 +850,10 @@ model Measurement { // (Postgres semantics) so manual entries don't collide here. @@unique([userId, type, source, externalId]) @@index([userId, type, measuredAt]) + // v1.15.13 — leads with `(userId, source)` so the management list's + // source filter + recency sort is an index range scan instead of a + // `userId` scan + in-memory sort. See migration 0136. + @@index([userId, source, measuredAt(sort: Desc)]) @@index([externalId]) // v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks // `where userId` ordered by `(updatedAt asc, id asc)` with `take @@ -2082,6 +2086,10 @@ model MoodEntry { // null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this // index turns that into a range scan. See migration 0123. @@index([userId, moodLoggedAt]) + // v1.15.13 — leads with `(userId, source)` so the mood management + // list's source filter + recency sort is an index range scan. See + // migration 0136. + @@index([userId, source, moodLoggedAt(sort: Desc)]) // v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed // (`where userId` ordered by `(updatedAt asc, id asc)`). @@index([userId, updatedAt, id]) diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts new file mode 100644 index 000000000..c761efd55 --- /dev/null +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -0,0 +1,182 @@ +/** + * v1.15.13 — POST /api/measurements/bulk-delete. + * + * Asserts the ownership-scoped soft-delete contract: only owned, not- + * already-tombstoned rows are touched; a forged / foreign id is a silent + * no-op (no 404 existence leak); the rollup recompute collapses to the + * unique `(type, day)` set instead of firing one recompute per row; the + * >200-id cap returns 422. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", async () => { + const actual = await vi.importActual< + typeof import("@/lib/rollups/measurement-rollups") + >("@/lib/rollups/measurement-rollups"); + return { + // keep the real collapse so the test exercises the actual fan-out logic + collapseToTypeDayKeys: actual.collapseToTypeDayKeys, + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + }; +}); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements/bulk-delete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + // `vi.resetAllMocks()` wipes the factory defaults; the best-effort + // post-delete hooks must be re-stubbed to resolved promises (the route + // awaits the rollup recompute and `.catch()`es the insight invalidate). + vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined); + vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined); +}); + +describe("POST /api/measurements/bulk-delete", () => { + it("soft-deletes only owned, non-tombstoned rows and returns that count", async () => { + // Caller asked for 3 ids but only 2 are owned + live. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") }, + ] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 2, + } as never); + + const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: number } }; + expect(body.data.deleted).toBe(2); + + // updateMany is ownership-scoped + filters tombstones — a forged id + // never 404s, it's just excluded from the count. + const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0]; + expect(call.where).toMatchObject({ + id: { in: ["m1", "m2", "foreign"] }, + userId: "user-1", + deletedAt: null, + }); + expect(call.data).toMatchObject({ + syncVersion: { increment: 1 }, + }); + expect(call.data.deletedAt).toBeInstanceOf(Date); + + expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1"); + }); + + it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => { + // 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day, + // 1× PULSE same day as the first → 3 distinct (type, day) keys. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") }, + { type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") }, + { type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") }, + ] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 4, + } as never); + + await POST(postReq({ ids: ["a", "b", "c", "d"] })); + + // 4 rows in, 3 distinct (type, day) recomputes out. + expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); + }); + + it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await POST(postReq({ ids: ["foreign-1", "foreign-2"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: number } }; + expect(body.data.deleted).toBe(0); + expect(invalidateUserMeasurements).not.toHaveBeenCalled(); + expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled(); + }); + + it("rejects a batch over the 200-id cap with 422", async () => { + const ids = Array.from({ length: 201 }, (_, i) => `m${i}`); + const res = await POST(postReq({ ids })); + expect(res.status).toBe(422); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); + + it("rejects an empty id array with 422", async () => { + const res = await POST(postReq({ ids: [] })); + expect(res.status).toBe(422); + }); + + it("429s when the rate limit is exceeded", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + const res = await POST(postReq({ ids: ["m1"] })); + expect(res.status).toBe(429); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts new file mode 100644 index 000000000..3494bd985 --- /dev/null +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -0,0 +1,142 @@ +/** + * `POST /api/measurements/bulk-delete` — multi-select soft-delete for the + * measurements management list (v1.15.13). + * + * Body: + * { ids: string[] } — 1..200 measurement ids, scoped to the caller. + * + * Soft-deletes (tombstones) every owned, not-already-deleted row in one + * `updateMany` — matching the single-DELETE iOS tombstone contract. A + * forged / foreign id is a silent no-op (never a 404 existence leak): the + * mutation `where` pins `userId` so it only ever touches the caller's rows. + * + * Response (200): + * { deleted: } + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { + recomputeBucketsForMeasurement, + collapseToTypeDayKeys, +} from "@/lib/rollups/measurement-rollups"; +import type { MeasurementType } from "@/generated/prisma/client"; + +const MAX_IDS_PER_BATCH = 200; +const RATE_LIMIT_MAX = 60; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const bulkDeleteSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH), +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete)); + +async function postBulkDelete(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `measurements:bulk-delete:${user.id}`, + RATE_LIMIT_MAX, + RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk deletions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = bulkDeleteSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "measurement.bulk-delete.invalid", + }); + } + + const { ids } = parsed.data; + const now = new Date(); + + // Read the soon-to-be-tombstoned rows first so we know which + // `(type, day)` buckets need a rollup recompute and which types need + // a status-insight cache drop. Ownership-scoped + `deletedAt: null` so + // a forged / foreign / already-tombstoned id is excluded here exactly + // as it is from the mutation below — no existence leak. + const affected = await prisma.measurement.findMany({ + where: { id: { in: ids }, userId: user.id, deletedAt: null }, + select: { type: true, measuredAt: true }, + }); + + // Soft-delete (tombstone) in one statement. The `where` pins `userId` + // so a forged id for another user is silently a no-op; `deletedAt: null` + // keeps the count honest (an already-tombstoned id doesn't re-count). + const { count } = await prisma.measurement.updateMany({ + where: { id: { in: ids }, userId: user.id, deletedAt: null }, + data: { deletedAt: now, syncVersion: { increment: 1 } }, + }); + + await auditLog("measurement.delete.bulk", { + userId: user.id, + ipAddress: getClientIp(request), + details: { count }, + }); + + annotate({ + action: { name: "measurement.delete.bulk" }, + meta: { count }, + }); + + if (count > 0) { + // v1.4.34 IW-G — bust per-user analytics + achievements + workouts + // caches so subsequent reads reflect the deletions. + invalidateUserMeasurements(user.id); + + // Collapse the deleted rows to the unique `(type, day)` set BEFORE + // recomputing so a 200-row delete spanning one day fires ~1 recompute + // per type, not 200 (mirrors the batch-insert path). Best-effort — a + // populator hiccup never fails the user's delete. + const keys = collapseToTypeDayKeys( + affected.map((row) => ({ + type: row.type as MeasurementType, + measuredAt: row.measuredAt, + })), + ); + try { + for (const k of keys) { + await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); + } + } catch (err) { + console.warn("[measurements] bulk-delete rollup recompute failed", err); + } + + // v1.8.0 — drop the cached per-metric assessment rows the deletion + // dirties so the next mount / nightly warm pass regenerates against + // the reduced history. Fire-and-forget: never blocks the delete. + const affectedTypes = Array.from(new Set(keys.map((k) => k.type))); + if (affectedTypes.length > 0) { + invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => { + console.warn( + "[measurements] bulk-delete status-insight invalidate failed", + err, + ); + }); + } + } + + return apiSuccess({ deleted: count }); +} diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 91d88109d..ac55d66a2 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -97,6 +97,7 @@ export const GET = apiHandler(async (request: NextRequest) => { sortDir, aggregate, source, + sourceEq, groupBy, dayKey, } = parsed.data; @@ -110,6 +111,10 @@ export const GET = apiHandler(async (request: NextRequest) => { // dashboard read paths) must never include them. deletedAt: null, ...(type && { type: type as MeasurementType }), + // v1.15.13 — management-list source filter. Distinct from the + // `source=rollup` opt-in above (which selects the rollup read tier); + // `sourceEq` narrows the plain list to one ingest source. + ...(sourceEq && { source: sourceEq }), ...(from || to ? { measuredAt: { diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index b501ed812..4e0a06e1b 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -178,6 +178,38 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => { }); }); +describe("GET /api/mood-entries — source filter (v1.15.13)", () => { + it("threads a valid source into the list where", async () => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never); + + const res = await GET(getReq("source=TELEGRAM")); + expect(res.status).toBe(200); + + const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0]; + expect(call?.where).toMatchObject({ + userId: "user-1", + deletedAt: null, + source: "TELEGRAM", + }); + }); + + it("omits source from the where when not supplied", async () => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never); + + const res = await GET(getReq("")); + expect(res.status).toBe(200); + const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0]; + expect(call?.where).not.toHaveProperty("source"); + }); + + it("422s on an unknown source value", async () => { + const res = await GET(getReq("source=BOGUS")); + expect(res.status).toBe(422); + }); +}); + describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => { it("surfaces TWO simultaneous validation errors", async () => { // Bad `mood` enum + missing `moodLoggedAt`. diff --git a/src/app/api/mood-entries/bulk-delete/__tests__/route.test.ts b/src/app/api/mood-entries/bulk-delete/__tests__/route.test.ts new file mode 100644 index 000000000..c81a4d768 --- /dev/null +++ b/src/app/api/mood-entries/bulk-delete/__tests__/route.test.ts @@ -0,0 +1,154 @@ +/** + * v1.15.13 — POST /api/mood-entries/bulk-delete. + * + * Asserts the ownership-scoped soft-delete contract: only owned, not- + * already-tombstoned rows are touched; a forged / foreign id is a silent + * no-op (no 404 existence leak); the mood rollup recompute collapses to + * the unique day set; the >200-id cap returns 422. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + moodEntry: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMood: vi.fn(), +})); +vi.mock("@/lib/rollups/mood-rollups", () => ({ + recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserMood } from "@/lib/cache/invalidate"; +import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/mood-entries/bulk-delete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + // `vi.resetAllMocks()` wipes the factory default; the route awaits the + // best-effort mood-rollup recompute, so re-stub it to a resolved promise. + vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined); +}); + +describe("POST /api/mood-entries/bulk-delete", () => { + it("soft-deletes only owned, non-tombstoned rows and bumps syncVersion", async () => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { moodLoggedAt: new Date("2026-01-01T08:00:00Z") }, + { moodLoggedAt: new Date("2026-01-02T20:00:00Z") }, + ] as never); + vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({ + count: 2, + } as never); + + const res = await POST(postReq({ ids: ["e1", "e2", "foreign"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: number } }; + expect(body.data.deleted).toBe(2); + + const call = vi.mocked(prisma.moodEntry.updateMany).mock.calls[0][0]; + expect(call.where).toMatchObject({ + id: { in: ["e1", "e2", "foreign"] }, + userId: "user-1", + deletedAt: null, + }); + expect(call.data).toMatchObject({ syncVersion: { increment: 1 } }); + expect(call.data.deletedAt).toBeInstanceOf(Date); + + expect(invalidateUserMood).toHaveBeenCalledWith("user-1"); + }); + + it("collapses the rollup recompute to the unique day set, not per-row", async () => { + // 3 deleted rows across 2 distinct UTC days → 2 recomputes. + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { moodLoggedAt: new Date("2026-01-01T08:00:00Z") }, + { moodLoggedAt: new Date("2026-01-01T22:00:00Z") }, + { moodLoggedAt: new Date("2026-01-02T09:00:00Z") }, + ] as never); + vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({ + count: 3, + } as never); + + await POST(postReq({ ids: ["a", "b", "c"] })); + expect(recomputeMoodBucketsForEntry).toHaveBeenCalledTimes(2); + }); + + it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await POST(postReq({ ids: ["foreign-1"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: number } }; + expect(body.data.deleted).toBe(0); + expect(invalidateUserMood).not.toHaveBeenCalled(); + expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled(); + }); + + it("rejects a batch over the 200-id cap with 422", async () => { + const ids = Array.from({ length: 201 }, (_, i) => `e${i}`); + const res = await POST(postReq({ ids })); + expect(res.status).toBe(422); + expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled(); + }); + + it("429s when the rate limit is exceeded", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + const res = await POST(postReq({ ids: ["e1"] })); + expect(res.status).toBe(429); + expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood-entries/bulk-delete/route.ts b/src/app/api/mood-entries/bulk-delete/route.ts new file mode 100644 index 000000000..9c61322f2 --- /dev/null +++ b/src/app/api/mood-entries/bulk-delete/route.ts @@ -0,0 +1,131 @@ +/** + * `POST /api/mood-entries/bulk-delete` — multi-select soft-delete for the + * mood management list (v1.15.13). + * + * Body: + * { ids: string[] } — 1..200 mood-entry ids, scoped to the caller. + * + * Soft-deletes (tombstones) every owned, not-already-deleted row in one + * `updateMany` — matching the single-DELETE iOS tombstone contract. A + * forged / foreign id is a silent no-op (never a 404 existence leak): the + * mutation `where` pins `userId` so it only ever touches the caller's rows. + * + * Response (200): + * { deleted: } + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserMood } from "@/lib/cache/invalidate"; +import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; + +const MAX_IDS_PER_BATCH = 200; +const RATE_LIMIT_MAX = 60; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const bulkDeleteSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH), +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete)); + +async function postBulkDelete(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `mood-entries:bulk-delete:${user.id}`, + RATE_LIMIT_MAX, + RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk deletions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = bulkDeleteSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "mood.bulk-delete.invalid", + }); + } + + const { ids } = parsed.data; + const now = new Date(); + + // Read the soon-to-be-tombstoned rows first so we know which days need + // a mood-rollup recompute. Ownership-scoped + `deletedAt: null` so a + // forged / foreign / already-tombstoned id is excluded here exactly as + // it is from the mutation below — no existence leak. + const affected = await prisma.moodEntry.findMany({ + where: { id: { in: ids }, userId: user.id, deletedAt: null }, + select: { moodLoggedAt: true }, + }); + + // Soft-delete (tombstone) in one statement. The `where` pins `userId` + // so a forged id for another user is silently a no-op; `deletedAt: null` + // keeps the count honest (an already-tombstoned id doesn't re-count). + const { count } = await prisma.moodEntry.updateMany({ + where: { id: { in: ids }, userId: user.id, deletedAt: null }, + data: { deletedAt: now, syncVersion: { increment: 1 } }, + }); + + await auditLog("mood.delete.bulk", { + userId: user.id, + ipAddress: getClientIp(request), + details: { count }, + }); + + annotate({ + action: { name: "mood.delete.bulk" }, + meta: { count }, + }); + + if (count > 0) { + // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. + invalidateUserMood(user.id); + + // v1.4.39 W-MOOD — collapse the deleted rows to the unique + // `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning + // one day fires one recompute, not one per row. Best-effort — a + // populator hiccup never fails the user's delete. + const touchedDayStarts = new Set(); + for (const row of affected) { + const d = row.moodLoggedAt; + touchedDayStarts.add( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()), + ); + } + try { + await Promise.all( + Array.from(touchedDayStarts).map((t) => + recomputeMoodBucketsForEntry(user.id, new Date(t)), + ), + ); + } catch (rollupErr) { + annotate({ + meta: { + mood_rollup_write_failed: true, + mood_rollup_write_error: + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + }, + }); + } + } + + return apiSuccess({ deleted: count }); +} diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 4da453a97..e24894ab6 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -71,13 +71,16 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { mood, from, to, limit, offset, sortBy, sortDir } = parsed.data; + const { mood, source, from, to, limit, offset, sortBy, sortDir } = + parsed.data; const where = { userId: user.id, // v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list. deletedAt: null, ...(mood && { mood }), + // v1.15.13 — management-list source filter. + ...(source && { source }), ...(from || to ? { date: { diff --git a/src/components/data-list/__tests__/selection.test.ts b/src/components/data-list/__tests__/selection.test.ts new file mode 100644 index 000000000..f12ef3fdd --- /dev/null +++ b/src/components/data-list/__tests__/selection.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + toggleId, + toggleSelectAll, + selectAllState, + selectedIdsOnPage, + selectedCountOnPage, +} from "../selection"; + +/** + * v1.15.13 — pure page-scoped selection math for the measurements + mood + * management lists. These helpers back the multi-select chrome; the tests + * pin the set algebra so the select-all tri-state + the bulk-delete + * payload stay correct without needing a DOM render. + */ +describe("data-list selection math", () => { + describe("toggleId", () => { + it("adds an id that is not present", () => { + const next = toggleId(new Set(["a"]), "b"); + expect([...next].sort()).toEqual(["a", "b"]); + }); + + it("removes an id that is present", () => { + const next = toggleId(new Set(["a", "b"]), "a"); + expect([...next]).toEqual(["b"]); + }); + + it("returns a new set (does not mutate the input)", () => { + const input = new Set(["a"]); + const next = toggleId(input, "b"); + expect(next).not.toBe(input); + expect([...input]).toEqual(["a"]); + }); + }); + + describe("selectAllState", () => { + it("is none on an empty page", () => { + expect(selectAllState(new Set(["a"]), [])).toBe("none"); + }); + + it("is none when nothing on the page is selected", () => { + expect(selectAllState(new Set(["x"]), ["a", "b"])).toBe("none"); + }); + + it("is some when at least one but not all are selected", () => { + expect(selectAllState(new Set(["a"]), ["a", "b"])).toBe("some"); + }); + + it("is all when every page id is selected", () => { + expect(selectAllState(new Set(["a", "b"]), ["a", "b"])).toBe("all"); + }); + + it("ignores selected ids that are not on the page", () => { + // `x` is selected but off-page; the page is fully selected. + expect(selectAllState(new Set(["a", "b", "x"]), ["a", "b"])).toBe("all"); + }); + }); + + describe("toggleSelectAll", () => { + it("adds every page id when not all are selected", () => { + const next = toggleSelectAll(new Set(["a"]), ["a", "b", "c"]); + expect([...next].sort()).toEqual(["a", "b", "c"]); + }); + + it("clears the page ids when all are already selected", () => { + const next = toggleSelectAll(new Set(["a", "b"]), ["a", "b"]); + expect([...next]).toEqual([]); + }); + + it("preserves off-page selection when clearing the page", () => { + const next = toggleSelectAll(new Set(["a", "b", "x"]), ["a", "b"]); + expect([...next]).toEqual(["x"]); + }); + + it("is a no-op on an empty page", () => { + const next = toggleSelectAll(new Set(["a"]), []); + expect([...next]).toEqual(["a"]); + }); + }); + + describe("selectedIdsOnPage / selectedCountOnPage", () => { + it("returns only selected ids that are present on the page, in page order", () => { + const selected = new Set(["c", "a", "x"]); + const pageIds = ["a", "b", "c"]; + expect(selectedIdsOnPage(selected, pageIds)).toEqual(["a", "c"]); + expect(selectedCountOnPage(selected, pageIds)).toBe(2); + }); + + it("never includes off-page ids in the bulk-delete payload", () => { + const selected = new Set(["off-page-1", "off-page-2"]); + const pageIds = ["a", "b"]; + expect(selectedIdsOnPage(selected, pageIds)).toEqual([]); + expect(selectedCountOnPage(selected, pageIds)).toBe(0); + }); + }); +}); diff --git a/src/components/data-list/delete-button.tsx b/src/components/data-list/delete-button.tsx new file mode 100644 index 000000000..dd53016e3 --- /dev/null +++ b/src/components/data-list/delete-button.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Shared single-row delete button + confirm dialog for the data- + * management lists (measurements + mood). Lifted from the two + * copy-pasted `DeleteButton` helpers (v1.15.13). + * + * The confirm copy differs per surface (a measurement vs a mood entry), + * so the title/description are passed in rather than reaching for a fixed + * translation key. `cancelLabel` / `confirmLabel` default to the shared + * `common.*` strings. + */ +export function DeleteButton({ + onConfirm, + title, + description, + cancelLabel, + confirmLabel, + className = "size-11", + iconClassName = "h-3.5 w-3.5", +}: { + onConfirm: () => void; + title: string; + description: string; + cancelLabel?: string; + confirmLabel?: string; + className?: string; + iconClassName?: string; +}) { + const { t } = useTranslations(); + return ( + + + + + + + {title} + {description} + + + {cancelLabel ?? t("common.cancel")} + + {confirmLabel ?? t("common.delete")} + + + + + ); +} diff --git a/src/components/data-list/index.ts b/src/components/data-list/index.ts new file mode 100644 index 000000000..e57f9faa4 --- /dev/null +++ b/src/components/data-list/index.ts @@ -0,0 +1,20 @@ +/** + * Shared chrome for the data-management lists (measurements + mood). + * + * v1.15.13 — the `SortableHead` + single-row `DeleteButton` were copy- + * pasted between `measurement-list.tsx` and `mood-list.tsx`; they now + * live here once, alongside the new page-scoped multi-select selection + * bar and its pure selection math. Each list keeps its own columns + + * formatting; only the shared chrome lives in this module. + */ +export { SortableHead } from "./sortable-head"; +export { DeleteButton } from "./delete-button"; +export { SelectionActionBar } from "./selection-action-bar"; +export { + toggleId, + toggleSelectAll, + selectAllState, + selectedIdsOnPage, + selectedCountOnPage, + type SelectAllState, +} from "./selection"; diff --git a/src/components/data-list/selection-action-bar.tsx b/src/components/data-list/selection-action-bar.tsx new file mode 100644 index 000000000..f7a48ea36 --- /dev/null +++ b/src/components/data-list/selection-action-bar.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Loader2, Trash2, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Selection action bar for the data-management lists (measurements + + * mood). Appears when ≥1 row on the current page is selected; shows + * "N selected", a destructive "Delete (N)" button gated behind an + * AlertDialog confirm, and a clear-selection affordance. + * + * Pure / presentational: every behaviour is a callback. The component + * renders nothing when `count === 0`. The bar is sticky to the bottom of + * the viewport so it stays reachable while scrolling a long page, and the + * confirm dialog is focus-trapped by the shared `AlertDialog` primitive. + * + * The confirm copy is passed in because the title/body differ per surface + * (measurements vs mood) and the count is interpolated by the caller. + */ +export function SelectionActionBar({ + count, + onClear, + onConfirmDelete, + isDeleting, + confirmTitle, + confirmBody, +}: { + count: number; + onClear: () => void; + onConfirmDelete: () => void; + isDeleting: boolean; + confirmTitle: string; + confirmBody: string; +}) { + const { t } = useTranslations(); + + if (count <= 0) return null; + + return ( +
+
+ + + {t("dataList.selected", { count: String(count) })} + +
+ + + + + + + + {confirmTitle} + {confirmBody} + + + + {t("common.cancel")} + + + {isDeleting ? ( + + ) : null} + {t("common.delete")} + + + + +
+ ); +} diff --git a/src/components/data-list/selection.ts b/src/components/data-list/selection.ts new file mode 100644 index 000000000..d482aebf3 --- /dev/null +++ b/src/components/data-list/selection.ts @@ -0,0 +1,94 @@ +/** + * Pure, framework-free selection math for the page-scoped multi-select on + * the measurements + mood management lists (v1.15.13). + * + * Selection is scoped to the CURRENT PAGE per the v1.15.x list audit + * (§E) — no "select across 200k rows". Every helper here operates on a + * `Set` of row ids that all belong to the rows currently painted, + * so the math stays a few cheap set operations regardless of total count. + * + * Extracted so the selection logic is unit-testable without a DOM / + * React render (`selection.test.ts`). + */ + +/** Toggle a single id in/out of the selection set, returning a new Set. */ +export function toggleId(selected: ReadonlySet, id: string): Set { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; +} + +/** + * Header "select all on this page" state. + * + * - `"all"` — every selectable id on the page is selected. + * - `"some"` — at least one but not all are selected (indeterminate). + * - `"none"` — nothing on the page is selected. + * + * A page with no selectable rows is `"none"` (the header checkbox should + * be a no-op then). + */ +export type SelectAllState = "all" | "some" | "none"; + +export function selectAllState( + selected: ReadonlySet, + pageIds: readonly string[], +): SelectAllState { + if (pageIds.length === 0) return "none"; + let selectedOnPage = 0; + for (const id of pageIds) { + if (selected.has(id)) selectedOnPage += 1; + } + if (selectedOnPage === 0) return "none"; + if (selectedOnPage === pageIds.length) return "all"; + return "some"; +} + +/** + * Apply a "select all on page" toggle. When every selectable id on the + * page is already selected, the toggle clears the page's ids; otherwise it + * adds the page's ids to the existing selection. Selection from other + * (now-unmounted) pages is dropped on a page change by the caller, so in + * practice `selected` only ever holds current-page ids — but this stays + * correct either way. + */ +export function toggleSelectAll( + selected: ReadonlySet, + pageIds: readonly string[], +): Set { + if (pageIds.length === 0) return new Set(selected); + const allSelected = pageIds.every((id) => selected.has(id)); + const next = new Set(selected); + if (allSelected) { + for (const id of pageIds) next.delete(id); + } else { + for (const id of pageIds) next.add(id); + } + return next; +} + +/** + * The selected ids intersected with the ids actually present on the page. + * The bulk-delete request only ever sends rows the user can currently see, + * which keeps the payload page-bounded (≤ PAGE_SIZE) and matches the + * server's 200-id cap with room to spare. + */ +export function selectedIdsOnPage( + selected: ReadonlySet, + pageIds: readonly string[], +): string[] { + return pageIds.filter((id) => selected.has(id)); +} + +/** Count of selected ids that are present on the current page. */ +export function selectedCountOnPage( + selected: ReadonlySet, + pageIds: readonly string[], +): number { + let n = 0; + for (const id of pageIds) { + if (selected.has(id)) n += 1; + } + return n; +} diff --git a/src/components/data-list/sortable-head.tsx b/src/components/data-list/sortable-head.tsx new file mode 100644 index 000000000..c02c0ac12 --- /dev/null +++ b/src/components/data-list/sortable-head.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react"; + +import { TableHead } from "@/components/ui/table"; + +/** + * Shared clickable sort header for the data-management tables + * (measurements + mood). Lifted verbatim from the two copy-pasted + * `SortableHead` definitions (v1.15.13). Domain specifics — which + * columns are sortable, their labels — stay in each list; only this + * chrome moves. + */ +export function SortableHead({ + column, + label, + currentSort, + currentDir, + onSort, + className, +}: { + column: string; + label: string; + currentSort: string; + currentDir: "asc" | "desc"; + onSort: (col: string) => void; + className?: string; +}) { + const isActive = currentSort === column; + return ( + + + + ); +} diff --git a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx new file mode 100644 index 000000000..53accc4c0 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.15.13 — the new filter bar (source select + date range) and the + * page-scoped multi-select chrome (per-row + select-all checkboxes) on + * the measurements management list. Server-render assertions only — the + * selection action bar's count-driven render is covered by the pure + * `selection.test.ts` math + the integration suite; here we pin that the + * new controls actually mount with their labelled, keyboard-operable + * checkboxes. + */ + +const baseMeasurements = [ + { + id: "m-1", + type: "WEIGHT", + value: 81.5, + unit: "kg", + source: "MANUAL", + measuredAt: "2026-05-09T08:30:00.000Z", + notes: null, + }, + { + id: "m-2", + type: "WEIGHT", + value: 80.9, + unit: "kg", + source: "WITHINGS", + measuredAt: "2026-05-08T08:30:00.000Z", + notes: null, + }, +]; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/measurements", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { measurements: baseMeasurements, meta: { total: 2 } }, + isLoading: false, + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "marc", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MeasurementList } from "../measurement-list"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe("MeasurementList — filter bar + multi-select chrome", () => { + it("renders the source filter control", () => { + const html = render("en"); + expect(html).toContain("Filter by source"); + }); + + it("renders the date-range labels wired to from/to", () => { + const html = render("en"); + expect(html).toContain('for="measurements-from"'); + expect(html).toContain('for="measurements-to"'); + }); + + it("renders labelled selection checkboxes (per-row + select-all)", () => { + const html = render("en"); + // The shared Checkbox primitive carries data-slot="checkbox". + const boxes = html.match(/data-slot="checkbox"/g); + // 1 header select-all + 2 selectable rows (desktop) + 2 rows (mobile). + expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3); + expect(html).toContain('aria-label="Select all on this page"'); + expect(html).toContain('aria-label="Select row"'); + }); + + it("localises the new chrome in German", () => { + const html = render("de"); + expect(html).toContain("Nach Quelle filtern"); + expect(html).toContain('aria-label="Alle auf dieser Seite auswählen"'); + }); +}); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index f1f5bc938..3196c6694 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -21,7 +21,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { @@ -49,19 +48,35 @@ import { ChevronLeft, ChevronRight, ChevronDown, - ArrowUp, - ArrowDown, - ArrowUpDown, MoreHorizontal, } from "lucide-react"; import { Fragment, useId, useState } from "react"; import { createPortal } from "react-dom"; +import { toast } from "sonner"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; -import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; +import { + MEASUREMENT_NOTES_MAX_LENGTH, + measurementSourceEnum, +} from "@/lib/validations/measurement"; +import { DateInput, DateTimeInput } from "@/components/ui/date-input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + SortableHead, + DeleteButton, + SelectionActionBar, + toggleId, + toggleSelectAll, + selectAllState, + selectedIdsOnPage, + selectedCountOnPage, +} from "@/components/data-list"; import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS, MEASUREMENT_TYPE_ICONS as TYPE_ICONS, @@ -191,9 +206,40 @@ function formatMeasurementSource( if (source === "IMPORT") return t("measurements.sourceImport"); if (source === "MANUAL") return t("measurements.sourceManual"); if (source === "APPLE_HEALTH") return t("measurements.sourceAppleHealth"); + if (source === "COMPUTED") return t("measurements.sourceComputed"); + if (source === "WHOOP") return t("measurements.sourceWhoop"); + if (source === "FITBIT") return t("measurements.sourceFitbit"); return source; } +/** + * v1.15.13 — the `MeasurementSource` enum values offered in the + * management-list source filter. Derived from the shared Zod enum so a + * future source addition surfaces here automatically. + */ +const MEASUREMENT_SOURCE_OPTIONS = measurementSourceEnum.options; + +/** + * v1.15.13 — convert a `` value (YYYY-MM-DD) into the + * offset-bearing ISO datetime the measurements list `from`/`to` params + * require. `from` snaps to the local start-of-day, `to` to the local + * end-of-day so an inclusive single-day range returns that whole day. + */ +function dayBoundaryIso( + day: string, + boundary: "start" | "end", +): string | undefined { + if (!day) return undefined; + const [y, m, d] = day.split("-").map(Number); + if (!y || !m || !d) return undefined; + const date = + boundary === "start" + ? new Date(y, m - 1, d, 0, 0, 0, 0) + : new Date(y, m - 1, d, 23, 59, 59, 999); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); +} + /** * Per-source badge colour. `APPLE_HEALTH` gets the Dracula pink that * matches the iOS app's accent; everything else falls back to the @@ -220,10 +266,24 @@ export function MeasurementList({ const [typeFilter, setTypeFilterRaw] = useState( lockedType ?? "ALL", ); + // v1.15.13 — management-list source filter + optional date range. + // `ALL` clears the source filter; empty date strings clear the bound. + const [sourceFilter, setSourceFilterRaw] = useState("ALL"); + const [fromDay, setFromDayRaw] = useState(""); + const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("measuredAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // v1.15.13 — page-scoped multi-select. Holds the ids selected on the + // CURRENT page; cleared on any page / filter / sort change (per the + // v1.15.x audit — no "select across 200k rows"). The bulk-delete + // payload is intersected with the painted page ids, so it is always + // page-bounded (≤ PAGE_SIZE) and well under the server's 200 cap. + const [selectedIds, setSelectedIds] = useState>( + () => new Set(), + ); + // v1.4.37 W7c — set of dayKeys whose drill-down is currently // expanded. Each key maps to a one-shot query that lazy-fetches // the per-sample rows for that calendar day on first open. @@ -256,9 +316,38 @@ export function MeasurementList({ null, ); + // v1.15.13 — every filter / page / sort change resets pagination to + // page 1 AND clears the page-scoped selection (the rows it referred to + // are about to unmount). + const clearSelection = () => setSelectedIds(new Set()); + const setTypeFilter = (value: string) => { setTypeFilterRaw(value); setPage(1); + clearSelection(); + }; + + const setSourceFilter = (value: string) => { + setSourceFilterRaw(value); + setPage(1); + clearSelection(); + }; + + const setFromDay = (value: string) => { + setFromDayRaw(value); + setPage(1); + clearSelection(); + }; + + const setToDay = (value: string) => { + setToDayRaw(value); + setPage(1); + clearSelection(); + }; + + const goToPage = (updater: (p: number) => number) => { + setPage(updater); + clearSelection(); }; function toggleSort(column: string) { @@ -269,6 +358,7 @@ export function MeasurementList({ setSortDir(column === "measuredAt" ? "desc" : "asc"); } setPage(1); + clearSelection(); } // v1.4.37 W7c — when the type filter is a cumulative HK type @@ -287,18 +377,34 @@ export function MeasurementList({ const isSleepFilter = typeFilter === "SLEEP_DURATION"; const isDayGroupedFilter = isCumulativeFilter || isSleepFilter; + // v1.15.13 — derive the ISO datetime window the list `from`/`to` + // params require from the two date inputs (local start/end of day). + const fromIso = dayBoundaryIso(fromDay, "start"); + const toIso = dayBoundaryIso(toDay, "end"); + const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter; + const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter + ? "sleep-night" + : isCumulativeFilter + ? "groupBy=day" + : "raw"; + const { data, isLoading } = useQuery({ - queryKey: [ - "measurements", - typeFilter === "ALL" ? undefined : typeFilter, + queryKey: queryKeys.measurementsList({ + type: typeFilter === "ALL" ? undefined : typeFilter, + sourceEq, + from: fromIso, + to: toIso, page, sortBy, sortDir, - isSleepFilter ? "sleep-night" : isCumulativeFilter ? "groupBy=day" : "raw", - ], + mode: listMode, + }), queryFn: async () => { const params = new URLSearchParams(); if (typeFilter !== "ALL") params.set("type", typeFilter); + if (sourceEq) params.set("sourceEq", sourceEq); + if (fromIso) params.set("from", fromIso); + if (toIso) params.set("to", toIso); params.set("limit", String(PAGE_SIZE)); params.set("offset", String((page - 1) * PAGE_SIZE)); params.set("sortBy", sortBy); @@ -350,6 +456,61 @@ export function MeasurementList({ }, }); + // v1.15.13 — page-scoped bulk soft-delete. Posts the selected ids + // (intersected with the painted page) to the bulk-delete route, then + // invalidates the same dependent-key bundle the single delete busts so + // the dashboard / insights / chart caches stay in lockstep. + const bulkDeleteMutation = useMutation({ + mutationFn: async (ids: string[]) => { + const res = await fetch("/api/measurements/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) throw new Error("Bulk delete failed"); + const json = (await res.json()) as { data: { deleted: number } }; + return json.data.deleted; + }, + onSuccess: async (deleted) => { + await invalidateKeys(queryClient, measurementDependentKeys); + clearSelection(); + toast.success(t("measurements.bulkDeleteSuccess", { count: String(deleted) })); + }, + onError: () => { + toast.error(t("measurements.bulkDeleteError")); + }, + }); + + const pageIds = (data?.measurements ?? []) + // Synthetic day-grouped / sleep-night rows aren't individually + // deletable (they collapse many raw rows behind a `dayKey`), so they + // are excluded from the selectable set. + .filter((m) => !(m.dayKey !== undefined && m.sampleCount !== undefined)) + .map((m) => m.id); + + // Selection is held as a raw id set but every read intersects it with + // the painted page (`selectAllState` / `selectedCountOnPage` / + // `selectedIdsOnPage`), so an id that falls off the page after a + // refetch (e.g. a single-row delete) simply stops counting — no stale + // selection survives a page / filter / sort change either, since those + // call `clearSelection()`. No prune effect is needed. + const selectAll = selectAllState(selectedIds, pageIds); + const selectedOnPage = selectedCountOnPage(selectedIds, pageIds); + + function onToggleRow(id: string) { + setSelectedIds((prev) => toggleId(prev, id)); + } + + function onToggleSelectAll() { + setSelectedIds((prev) => toggleSelectAll(prev, pageIds)); + } + + function onConfirmBulkDelete() { + const ids = selectedIdsOnPage(selectedIds, pageIds); + if (ids.length === 0) return; + bulkDeleteMutation.mutate(ids); + } + const updateMutation = useMutation({ mutationFn: async ({ id, @@ -494,32 +655,103 @@ export function MeasurementList({ Pre-fix the trigger had a fixed `w-48` (192 px) which on Pixel 5 (375 px content width) left only ~120 px for the count, which wrapped to 2 lines. */} -
- {lockedType ? ( - // v1.8.5 — the insights "all readings" subpage knows the - // metric from the route, so the global type selector is - // suppressed; the count caption still anchors the column. - {t("measurements.filterByType")} - ) : ( - - )} +
+
+ {lockedType ? ( + // v1.8.5 — the insights "all readings" subpage knows the + // metric from the route, so the global type selector is + // suppressed; the count caption still anchors the column. + {t("measurements.filterByType")} + ) : ( +
+ + +
+ )} + + {/* v1.15.13 — source filter + date range. Suppressed on the + locked-type insights subpage, which wants a focused list + scoped to the route's single metric (matches the type- + selector suppression above). */} + {!lockedType && ( + <> +
+ + +
+ +
+ + setFromDay(e.target.value)} + /> +
+
+ + setToDay(e.target.value)} + /> +
+ + )} +
+ {data?.meta?.total !== undefined && ( {t("measurements.measurementCount", { @@ -576,7 +808,22 @@ export function MeasurementList({ - + + {/* v1.15.13 — select-all-on-page header checkbox. */} + + + {t("measurements.type")} - + + {/* Grouped/synthetic rows aren't individually + deletable, so they have no checkbox. */} + {!isGrouped && ( + onToggleRow(m.id)} + aria-label={t("dataList.selectRow")} + /> + )} + + deleteMutation.mutate(m.id) } + title={t("measurements.deleteConfirmTitle")} + description={t( + "measurements.deleteConfirmDescription", + )} /> )} @@ -752,7 +1015,7 @@ export function MeasurementList({ {isGrouped && isExpanded && ( - +
-
+
+ {/* v1.15.13 — multi-select checkbox in a 44px tap + target; absent for synthetic grouped rows. */} + {!isGrouped && ( + // v1.15.13 MEDIUM-1 — a Radix Checkbox renders a + // 16px ` + )} {Icon && (
deleteMutation.mutate(m.id)} + title={t("measurements.deleteConfirmTitle")} + description={t( + "measurements.deleteConfirmDescription", + )} /> )} @@ -921,6 +1219,20 @@ export function MeasurementList({ )} + {/* v1.15.13 — page-scoped multi-select action bar. */} + + {/* Pagination */} {totalPages > 1 && (
@@ -936,7 +1248,7 @@ export function MeasurementList({ size="icon" className="size-11" disabled={page <= 1} - onClick={() => setPage((p) => p - 1)} + onClick={() => goToPage((p) => p - 1)} aria-label={t("measurements.previousPage")} > @@ -946,7 +1258,7 @@ export function MeasurementList({ size="icon" className="size-11" disabled={page >= totalPages} - onClick={() => setPage((p) => p + 1)} + onClick={() => goToPage((p) => p + 1)} aria-label={t("measurements.nextPage")} > @@ -1279,83 +1591,3 @@ function SleepNightCaption({ m }: { m: Measurement }) { ); } -function SortableHead({ - column, - label, - currentSort, - currentDir, - onSort, - className, -}: { - column: string; - label: string; - currentSort: string; - currentDir: "asc" | "desc"; - onSort: (col: string) => void; - className?: string; -}) { - const isActive = currentSort === column; - return ( - - - - ); -} - -function DeleteButton({ - onConfirm, - className = "size-11", -}: { - onConfirm: () => void; - className?: string; -}) { - const { t } = useTranslations(); - return ( - - - - - - - - {t("measurements.deleteConfirmTitle")} - - - {t("measurements.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("common.delete")} - - - - - ); -} diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx new file mode 100644 index 000000000..774e8e154 --- /dev/null +++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.15.13 — the new source filter + date range + page-scoped + * multi-select chrome on the mood management list. Mirrors the + * measurements-list guard. + */ + +const baseEntries = [ + { + id: "e-1", + date: "2026-05-09", + mood: "GUT", + score: 4, + tags: ["work"], + tagKeys: [], + note: null, + source: "MANUAL", + moodLoggedAt: "2026-05-09T20:00:00.000Z", + }, + { + id: "e-2", + date: "2026-05-08", + mood: "OKAY", + score: 3, + tags: [], + tagKeys: [], + note: null, + source: "MOODLOG", + moodLoggedAt: "2026-05-08T20:00:00.000Z", + }, +]; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/mood", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { entries: baseEntries, meta: { total: 2 } }, + isLoading: false, + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "marc", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodList } from "../mood-list"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe("MoodList — filter bar + multi-select chrome", () => { + it("renders the source filter control", () => { + const html = render("en"); + expect(html).toContain("Filter by source"); + }); + + it("renders the date-range labels wired to from/to", () => { + const html = render("en"); + expect(html).toContain('for="mood-from"'); + expect(html).toContain('for="mood-to"'); + }); + + it("renders labelled selection checkboxes (per-row + select-all)", () => { + const html = render("en"); + const boxes = html.match(/data-slot="checkbox"/g); + expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3); + expect(html).toContain('aria-label="Select all on this page"'); + expect(html).toContain('aria-label="Select row"'); + }); + + it("renders the localised non-manual source label, not the raw enum", () => { + const html = render("en"); + // MOODLOG row paints its localised badge label. + expect(html).toContain("moodLog"); + expect(html).not.toContain(">MOODLOG<"); + }); +}); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index aeff2d266..cfcb170cd 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -21,7 +21,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { @@ -38,7 +37,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DateTimeInput } from "@/components/ui/date-input"; +import { DateInput, DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { Table, @@ -56,9 +55,6 @@ import { Trash2, ChevronLeft, ChevronRight, - ArrowUp, - ArrowDown, - ArrowUpDown, MoreHorizontal, } from "lucide-react"; import { useId, useState } from "react"; @@ -70,7 +66,23 @@ import { MOOD_LABEL_KEYS, MOOD_SCORE_BY_ENUM, } from "@/lib/mood/labels"; -import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + moodDependentKeys, + queryKeys, +} from "@/lib/query-keys"; +import { moodSourceEnum } from "@/lib/validations/moodlog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + SortableHead, + DeleteButton, + SelectionActionBar, + toggleId, + toggleSelectAll, + selectAllState, + selectedIdsOnPage, + selectedCountOnPage, +} from "@/components/data-list"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { MoodTagPicker } from "./mood-tag-picker"; @@ -125,10 +137,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); const [moodFilter, setMoodFilterRaw] = useState("ALL"); + // v1.15.13 — management-list source filter + optional date range. + const [sourceFilter, setSourceFilterRaw] = useState("ALL"); + const [fromDay, setFromDayRaw] = useState(""); + const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("moodLoggedAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // v1.15.13 — page-scoped multi-select selection (current page ids only). + const [selectedIds, setSelectedIds] = useState>( + () => new Set(), + ); + const [editing, setEditing] = useState(null); const [editMood, setEditMood] = useState(""); const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({ @@ -156,9 +177,37 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { null, ); + // v1.15.13 — every filter / page / sort change resets pagination AND + // clears the page-scoped selection. + const clearSelection = () => setSelectedIds(new Set()); + const setMoodFilter = (value: string) => { setMoodFilterRaw(value); setPage(1); + clearSelection(); + }; + + const setSourceFilter = (value: string) => { + setSourceFilterRaw(value); + setPage(1); + clearSelection(); + }; + + const setFromDay = (value: string) => { + setFromDayRaw(value); + setPage(1); + clearSelection(); + }; + + const setToDay = (value: string) => { + setToDayRaw(value); + setPage(1); + clearSelection(); + }; + + const goToPage = (updater: (p: number) => number) => { + setPage(updater); + clearSelection(); }; function toggleSort(column: string) { @@ -169,19 +218,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { setSortDir(column === "moodLoggedAt" ? "desc" : "asc"); } setPage(1); + clearSelection(); } + // v1.15.13 — mood list `from`/`to` are YYYY-MM-DD (the date inputs hand + // back exactly that), so they pass through unchanged. `ALL` clears the + // source filter. + const fromParam = fromDay || undefined; + const toParam = toDay || undefined; + const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter; + const { data, isLoading } = useQuery({ - queryKey: [ - "mood-entries", - moodFilter === "ALL" ? undefined : moodFilter, + queryKey: queryKeys.moodEntriesList({ + mood: moodFilter === "ALL" ? undefined : moodFilter, + source: sourceParam, + from: fromParam, + to: toParam, page, sortBy, sortDir, - ], + }), queryFn: async () => { const params = new URLSearchParams(); if (moodFilter !== "ALL") params.set("mood", moodFilter); + if (sourceParam) params.set("source", sourceParam); + if (fromParam) params.set("from", fromParam); + if (toParam) params.set("to", toParam); params.set("limit", String(PAGE_SIZE)); params.set("offset", String((page - 1) * PAGE_SIZE)); params.set("sortBy", sortBy); @@ -216,6 +278,49 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, }); + // v1.15.13 — page-scoped bulk soft-delete, mirroring the measurements list. + const bulkDeleteMutation = useMutation({ + mutationFn: async (ids: string[]) => { + const res = await fetch("/api/mood-entries/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) throw new Error("Bulk delete failed"); + const json = (await res.json()) as { data: { deleted: number } }; + return json.data.deleted; + }, + onSuccess: async (deleted) => { + await invalidateKeys(queryClient, moodDependentKeys); + clearSelection(); + toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) })); + }, + onError: () => { + toast.error(t("mood.bulkDeleteError")); + }, + }); + + const pageIds = (data?.entries ?? []).map((e) => e.id); + + // Selection reads intersect with the painted page, so a stale id never + // counts; page / filter / sort changes clear selection. No prune effect. + const selectAll = selectAllState(selectedIds, pageIds); + const selectedOnPage = selectedCountOnPage(selectedIds, pageIds); + + function onToggleRow(id: string) { + setSelectedIds((prev) => toggleId(prev, id)); + } + + function onToggleSelectAll() { + setSelectedIds((prev) => toggleSelectAll(prev, pageIds)); + } + + function onConfirmBulkDelete() { + const ids = selectedIdsOnPage(selectedIds, pageIds); + if (ids.length === 0) return; + bulkDeleteMutation.mutate(ids); + } + const updateMutation = useMutation({ mutationFn: async ({ id, @@ -321,20 +426,77 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { return ( <>
-
- +
+
+
+ + +
+ + {/* v1.15.13 — source filter. */} +
+ + +
+ + {/* v1.15.13 — optional date range, wired to from/to. */} +
+ + setFromDay(e.target.value)} + /> +
+
+ + setToDay(e.target.value)} + /> +
+
+ {data?.meta?.total !== undefined && ( {t("mood.entryCount", { @@ -388,13 +550,28 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
+ + {/* v1.15.13 — select-all-on-page header checkbox. */} + + {t("mood.tags")} - {data.entries.map((entry) => ( - - + {data.entries.map((entry) => { + const isSelected = selectedIds.has(entry.id); + return ( + + + onToggleRow(entry.id)} + aria-label={t("dataList.selectRow")} + /> + + {entry.score}{" "} ( @@ -457,7 +646,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { {entry.source !== "MANUAL" && ( - {t("mood.sourceMoodlog")} + {formatMoodSource(entry.source, t)} )} @@ -474,11 +663,14 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { deleteMutation.mutate(entry.id)} + title={t("mood.deleteConfirmTitle")} + description={t("mood.deleteConfirmDescription")} /> - ))} + ); + })}
@@ -493,13 +685,38 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { guard at `e2e/mood-card-mobile.spec.ts` queries to assert "exactly one occurrence of the score per row". */}
- {data.entries.map((entry) => ( + {data.entries.map((entry) => { + const isSelected = selectedIds.has(entry.id); + return (
-
+
+ {/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px + `
deleteMutation.mutate(entry.id)} - mobile + iconClassName="h-4 w-4" + title={t("mood.deleteConfirmTitle")} + description={t("mood.deleteConfirmDescription")} />
- ))} + ); + })}
)} + {/* v1.15.13 — page-scoped multi-select action bar. */} + + {/* Pagination */} {totalPages > 1 && (
@@ -565,19 +799,25 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { })}
+ {/* v1.15.13 L2 — match the measurements list's 44px pagination + target (was `size="sm"`, below the WCAG 2.5.5 floor). */} @@ -797,85 +1037,31 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ); } -function SortableHead({ - column, - label, - currentSort, - currentDir, - onSort, - className, -}: { - column: string; - label: string; - currentSort: string; - currentDir: "asc" | "desc"; - onSort: (col: string) => void; - className?: string; -}) { - const isActive = currentSort === column; - return ( - - - - ); +/** + * v1.15.13 — render a `MoodEntry.source` value with its localized label. + * MANUAL is shown via the source filter only (a manual row carries no + * badge in the table). MOODLOG keeps its existing `mood.sourceMoodlog` + * key; the rest fall back to their own keys. + */ +function formatMoodSource( + source: string, + t: ReturnType["t"], +): string { + switch (source) { + case "MANUAL": + return t("mood.sourceManual"); + case "MOODLOG": + return t("mood.sourceMoodlog"); + case "WEB": + return t("mood.sourceWeb"); + case "TELEGRAM": + return t("mood.sourceTelegram"); + case "DAYLIO": + return t("mood.sourceDaylio"); + default: + return source; + } } -function DeleteButton({ - onConfirm, - mobile = false, -}: { - onConfirm: () => void; - mobile?: boolean; -}) { - const { t } = useTranslations(); - return ( - - - - - - - {t("mood.deleteConfirmTitle")} - - {t("mood.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("common.delete")} - - - - - ); -} +/** v1.15.13 — mood source enum values offered in the source filter. */ +const MOOD_SOURCE_OPTIONS = moodSourceEnum.options; diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 2acc5601c..18d216228 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -579,6 +579,26 @@ const deleteByExternalIdsResponse = z }) .meta({ id: "MeasurementsDeleteByExternalIdsResult" }); +// v1.15.13 — multi-select bulk soft-delete for the measurements +// management list. Ids are scoped to the caller; forged / foreign ids +// are silent no-ops (no existence leak). Soft-delete (tombstone), so the +// rows surface in the `/api/sync/changes` delta feed. +const bulkDeleteMeasurementsRequest = z + .object({ + ids: z.array(z.string().min(1)).min(1).max(200), + }) + .meta({ + id: "MeasurementsBulkDeleteRequest", + description: + "1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).", + }); + +const bulkDeleteMeasurementsResponse = z + .object({ + deleted: z.number().int().nonnegative(), + }) + .meta({ id: "MeasurementsBulkDeleteResult" }); + const measurementResource = z .object({ id: z.string(), @@ -3444,6 +3464,34 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/measurements/bulk-delete": { + post: { + tags: ["Measurements"], + summary: "Bulk soft-delete measurements", + description: + "Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.", + requestBody: { + required: true, + content: { + "application/json": { schema: bulkDeleteMeasurementsRequest }, + }, + }, + responses: { + "200": { + description: "Bulk delete processed.", + content: { + "application/json": { + schema: dataEnvelope( + bulkDeleteMeasurementsResponse, + "MeasurementsBulkDeleteResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/sleep/night": { get: { tags: ["Measurements"], diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 18d414d8e..13614b656 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -41,6 +41,66 @@ export const queryKeys = { measurements: () => ["measurements"] as const, moodEntries: () => ["mood-entries"] as const, + /** + * v1.15.13 — the measurements management-list read with its full filter + * + sort + pagination state baked into the key so the cache slot is + * correct for every filter combination (a `sourceEq` / date-range / + * type / page / sort change re-keys instead of poisoning a shared + * slot). Rides under the `["measurements"]` prefix so + * `measurementDependentKeys` (and a bulk-delete invalidation) reaches + * every slot at once. `mode` distinguishes the synthetic day-grouped / + * sleep-night branches from the plain `raw` list. + */ + measurementsList: (params: { + type: string | undefined; + sourceEq: string | undefined; + from: string | undefined; + to: string | undefined; + page: number; + sortBy: string; + sortDir: string; + mode: "raw" | "groupBy=day" | "sleep-night"; + }) => + [ + "measurements", + "list", + params.type ?? null, + params.sourceEq ?? null, + params.from ?? null, + params.to ?? null, + params.page, + params.sortBy, + params.sortDir, + params.mode, + ] as const, + + /** + * v1.15.13 — the mood management-list read with its filter + sort + + * pagination state baked into the key, mirroring `measurementsList`. + * Rides under the `["mood-entries"]` prefix so `moodDependentKeys` + * (and a bulk-delete invalidation) reaches every slot. + */ + moodEntriesList: (params: { + mood: string | undefined; + source: string | undefined; + from: string | undefined; + to: string | undefined; + page: number; + sortBy: string; + sortDir: string; + }) => + [ + "mood-entries", + "list", + params.mood ?? null, + params.source ?? null, + params.from ?? null, + params.to ?? null, + params.page, + params.sortBy, + params.sortDir, + ] as const, + /** * v1.11.5 — last-night hypnogram (`GET /api/sleep/night`). The `date` * discriminator lets the night-picker step back through recent nights diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 3b95a933e..403fcf3b2 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -236,6 +236,48 @@ describe("measurement validation", () => { }); }); + describe("listMeasurementsSchema — v1.15.13 sourceEq filter", () => { + it("accepts a valid MeasurementSource on sourceEq", () => { + const parsed = listMeasurementsSchema.safeParse({ sourceEq: "WITHINGS" }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.sourceEq).toBe("WITHINGS"); + } + }); + + it("leaves sourceEq undefined when omitted", () => { + const parsed = listMeasurementsSchema.safeParse({}); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.sourceEq).toBeUndefined(); + } + }); + + it("rejects an unknown sourceEq value", () => { + const parsed = listMeasurementsSchema.safeParse({ sourceEq: "BOGUS" }); + expect(parsed.success).toBe(false); + }); + + it("rejects the rollup opt-in value on sourceEq (distinct from source)", () => { + // `rollup` is the rollup-tier opt-in for `source`, NOT a valid + // MeasurementSource filter — sourceEq must not accept it. + const parsed = listMeasurementsSchema.safeParse({ sourceEq: "rollup" }); + expect(parsed.success).toBe(false); + }); + + it("accepts source=rollup and sourceEq together (independent params)", () => { + const parsed = listMeasurementsSchema.safeParse({ + source: "rollup", + sourceEq: "APPLE_HEALTH", + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.source).toBe("rollup"); + expect(parsed.data.sourceEq).toBe("APPLE_HEALTH"); + } + }); + }); + // v1.4.38 — the per-day drill-down branch always returns at most // 1000 rows (one pathological phone-only stepCount day). Push the // cap into the validator so a caller asking for more sees a 422 diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 0b843fc5b..c2de3706f 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -586,6 +586,12 @@ export const listMeasurementsSchema = z // when the rollup bucket set is empty for the requested window so // brand-new accounts still see correct data on their first chart. source: z.enum(["rollup"]).optional(), + // v1.15.13 — management-list source FILTER. The `source` key above is + // the rollup-tier opt-in (`["rollup"]`), NOT a source filter, so the + // filter rides a distinct param. Validated against `MeasurementSource`; + // threaded into the plain-list `where`. Backed by the `(userId, source, + // measuredAt)` index (migration 0136). + sourceEq: measurementSourceEnum.optional(), // v1.4.37 W7c — list-view "one row per day" mode for cumulative // types (steps, active energy, distance, flights, daylight). When // `groupBy=day` is set and `type` is a cumulative HK type, the route diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index 6597a3358..d093101f3 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -165,6 +165,11 @@ export const updateMoodEntrySchema = z.object({ export const listMoodEntriesSchema = z.object({ mood: moodLevelEnum.optional(), + // v1.15.13 — source filter for the management list. Validated against + // the mood source set; threaded into the list `where` (the `userId` + + // `deletedAt: null` pins stay). Backed by the `(userId, source, + // moodLoggedAt)` index (migration 0136). + source: moodSourceEnum.optional(), from: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) diff --git a/tests/integration/bulk-delete-source-filter.test.ts b/tests/integration/bulk-delete-source-filter.test.ts new file mode 100644 index 000000000..19508683f --- /dev/null +++ b/tests/integration/bulk-delete-source-filter.test.ts @@ -0,0 +1,250 @@ +/** + * v1.15.13 — bulk soft-delete + source filter, real-Postgres integration. + * + * Covers, against the real testcontainer: + * - `POST /api/measurements/bulk-delete` soft-deletes only the caller's + * rows; a foreign id is a silent no-op (no existence leak), bumps + * `syncVersion`, sets `deletedAt`, and the count reflects owned rows + * only. + * - Idempotency replay returns the same `deleted` count without + * re-mutating (a second distinct call would re-bump syncVersion). + * - `POST /api/mood-entries/bulk-delete` mirrors the same contract. + * - `sourceEq` (measurements) + `source` (mood) narrow the list reads. + */ +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_A = "user-bulkdel-a"; +const USER_B = "user-bulkdel-b"; + +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 seedUser(id: string, username: string) { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id, username, email: `${username}@example.test`, timezone: "UTC" }, + }); +} + +async function loginAs(id: string) { + const session = await getPrismaClient().session.create({ + data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", session.id); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await seedUser(USER_A, "bulkdel-a"); + await seedUser(USER_B, "bulkdel-b"); +}); + +function postReq(path: string, body: unknown, idempotencyKey?: string) { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), + }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/measurements/bulk-delete (real Postgres)", () => { + it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => { + const prisma = getPrismaClient(); + const now = new Date(); + const mine = await prisma.measurement.createManyAndReturn({ + data: [ + { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now }, + { userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "MANUAL", measuredAt: new Date(now.getTime() - 1000) }, + ], + }); + const theirs = await prisma.measurement.create({ + data: { userId: USER_B, type: "WEIGHT", value: 70, unit: "kg", source: "MANUAL", measuredAt: now }, + }); + + await loginAs(USER_A); + const { POST } = await import("@/app/api/measurements/bulk-delete/route"); + const res = await POST( + postReq("/api/measurements/bulk-delete", { + ids: [mine[0].id, mine[1].id, theirs.id], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: number } }; + // Only the two owned rows count; the foreign id is a silent no-op. + expect(body.data.deleted).toBe(2); + + const minedAfter = await prisma.measurement.findMany({ + where: { id: { in: [mine[0].id, mine[1].id] } }, + }); + for (const row of minedAfter) { + expect(row.deletedAt).not.toBeNull(); + // Measurement.syncVersion defaults to 1; one bulk delete bumps it to 2. + expect(row.syncVersion).toBe(2); + } + // The other user's row is untouched (default syncVersion = 1). + const theirsAfter = await prisma.measurement.findUnique({ + where: { id: theirs.id }, + }); + expect(theirsAfter!.deletedAt).toBeNull(); + expect(theirsAfter!.syncVersion).toBe(1); + }); + + it("idempotency replay returns the cached count without re-mutating", async () => { + const prisma = getPrismaClient(); + const row = await prisma.measurement.create({ + data: { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: new Date() }, + }); + + await loginAs(USER_A); + const { POST } = await import("@/app/api/measurements/bulk-delete/route"); + const key = "idem-meas-bulk-1"; + + const first = await POST( + postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key), + ); + expect(first.status).toBe(200); + expect(((await first.json()) as { data: { deleted: number } }).data.deleted).toBe(1); + + // Replay with the SAME key → cached body, same count, no re-mutation. + const replay = await POST( + postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key), + ); + expect(replay.status).toBe(200); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(((await replay.json()) as { data: { deleted: number } }).data.deleted).toBe(1); + + // Measurement.syncVersion defaults to 1; the single real delete bumped + // it to 2 and the replay did NOT re-run the mutation (so it stays 2). + const after = await prisma.measurement.findUnique({ where: { id: row.id } }); + expect(after!.syncVersion).toBe(2); + }); + + it("rejects a >200-id batch with 422", async () => { + await loginAs(USER_A); + const { POST } = await import("@/app/api/measurements/bulk-delete/route"); + const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`); + const res = await POST(postReq("/api/measurements/bulk-delete", { ids })); + expect(res.status).toBe(422); + }); +}); + +describe("POST /api/mood-entries/bulk-delete (real Postgres)", () => { + it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => { + const prisma = getPrismaClient(); + const mine = await prisma.moodEntry.createManyAndReturn({ + data: [ + { userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" }, + { userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "MANUAL", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" }, + ], + }); + const theirs = await prisma.moodEntry.create({ + data: { userId: USER_B, date: "2026-01-01", mood: "LAUSIG", score: 1, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T09:00:00Z"), tz: "UTC" }, + }); + + await loginAs(USER_A); + const { POST } = await import("@/app/api/mood-entries/bulk-delete/route"); + const res = await POST( + postReq("/api/mood-entries/bulk-delete", { + ids: [mine[0].id, mine[1].id, theirs.id], + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as { data: { deleted: number } }).data.deleted).toBe(2); + + const after = await prisma.moodEntry.findMany({ + where: { id: { in: [mine[0].id, mine[1].id] } }, + }); + for (const row of after) { + expect(row.deletedAt).not.toBeNull(); + expect(row.syncVersion).toBe(1); + } + const theirsAfter = await prisma.moodEntry.findUnique({ + where: { id: theirs.id }, + }); + expect(theirsAfter!.deletedAt).toBeNull(); + }); +}); + +describe("source filter narrows the list reads (real Postgres)", () => { + it("measurements sourceEq returns only the matching source", async () => { + const prisma = getPrismaClient(); + const now = new Date(); + await prisma.measurement.createMany({ + data: [ + { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now }, + { userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 1000) }, + { userId: USER_A, type: "WEIGHT", value: 82, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 2000) }, + ], + }); + + await loginAs(USER_A); + const { GET } = await import("@/app/api/measurements/route"); + const res = await GET( + new NextRequest("http://localhost/api/measurements?sourceEq=WITHINGS"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + measurements: Array<{ source: string }>; + meta: { total: number }; + }; + }; + expect(body.data.measurements).toHaveLength(2); + expect(body.data.measurements.every((m) => m.source === "WITHINGS")).toBe(true); + expect(body.data.meta.total).toBe(2); + }); + + it("mood source returns only the matching source", async () => { + const prisma = getPrismaClient(); + await prisma.moodEntry.createMany({ + data: [ + { userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" }, + { userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "TELEGRAM", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" }, + ], + }); + + await loginAs(USER_A); + const { GET } = await import("@/app/api/mood-entries/route"); + const res = await GET( + new NextRequest("http://localhost/api/mood-entries?source=TELEGRAM"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ source: string }>; meta: { total: number } }; + }; + expect(body.data.entries).toHaveLength(1); + expect(body.data.entries[0].source).toBe("TELEGRAM"); + expect(body.data.meta.total).toBe(1); + }); +});