diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 64d8de8a3..9a0cd391a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4453,6 +4453,358 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/mood/tags: + get: + tags: + - Mood + summary: Effective per-user mood-tag tree (v1.8.5 / v1.13.0 / v1.17.0) + description: "The fully-resolved, ordered Category → Tag tree: seeded catalogue + the caller's custom tags and groups, + per-user layout (group order + placements) applied server-side, hidden catalogue tags omitted. `include` is a + comma list: `hidden` (hidden catalogue tags, `hidden:true`), `archived` (own inactive custom tags, + `archived:true`), `usage` (per-tag `usageCount`). With any flag set, the caller's own empty groups are kept; the + plain read drops empty categories." + parameters: + - in: query + name: include + schema: + description: Comma list of `hidden`, `archived`, `usage`. + type: string + description: Comma list of `hidden`, `archived`, `usage`. + responses: + "200": + description: The effective tag tree. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagTreeEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/mood/tags/custom: + post: + tags: + - Mood + summary: Create a custom mood tag (v1.13.0 / v1.17.0) + description: "Mints a `custom:` key, encrypts the label at rest, stores a BINARY tag owned by the caller under the + chosen group (default: the seeded `custom` category). 422 over the 50-tag cap or on an unknown / foreign + `categoryKey`." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + categoryKey: + type: string + minLength: 1 + maxLength: 80 + required: + - label + responses: + "201": + description: Custom tag created. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/mood/tags/custom/{key}: + patch: + tags: + - Mood + summary: Update a custom mood tag (v1.13.0 / v1.17.0) + description: "Owner-scoped: another user's key or a catalogue key 404s. Rename (re-encrypts), re-icon, archive/restore + via `isActive`, or move groups via `categoryKey`." + parameters: + - in: path + name: key + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + isActive: + type: boolean + categoryKey: + type: string + minLength: 1 + maxLength: 80 + responses: + "200": + description: Custom tag updated. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" + "401": *a1 + "404": &a5 + description: Not found / not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Mood + summary: Archive or purge a custom mood tag (v1.13.0) + description: "Default: soft-deactivates (`isActive:false`) — history intact, restorable via PATCH. `?purge=true`: + hard-deletes the row; the FK cascade removes its entry links. Owner-scoped 404." + parameters: + - in: path + name: key + schema: + type: string + required: true + - in: query + name: purge + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: Archived (or purged). + content: + application/json: + schema: + $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + /api/mood/tags/{key}/hidden: + put: + tags: + - Mood + summary: Hide / show a catalogue mood tag (v1.13.0) + description: Per-user hide of a CATALOGUE tag (upserts / removes a `mood_tag_hidden` row). 400 on a `custom:` key — + custom tags archive via their own `isActive`. + parameters: + - in: path + name: key + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hidden: + type: boolean + required: + - hidden + responses: + "200": + description: Visibility updated. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagHiddenEnvelope" + "400": + description: Custom-tag key — use the custom PATCH instead. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + /api/mood/tags/groups: + post: + tags: + - Mood + summary: Create a custom mood-tag group (v1.17.0) + description: Mints a `customcat:` key, encrypts the label at rest, stores a group owned by the caller. 422 over + the 12-group cap. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + required: + - label + responses: + "201": + description: Custom group created. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/mood/tags/groups/{key}: + patch: + tags: + - Mood + summary: Update a custom mood-tag group (v1.17.0) + description: "Owner-scoped: another user's key or a seeded category key 404s. Rename (re-encrypts), re-icon, + retire/restore via `isActive`." + parameters: + - in: path + name: key + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "null" + isActive: + type: boolean + responses: + "200": + description: Custom group updated. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + delete: + tags: + - Mood + summary: Delete a custom mood-tag group (v1.17.0) + description: "Non-destructive: the group's custom tags re-home to the seeded `custom` category, catalogue-tag placements + evaporate back to their seeded category, the layout drops the group, then the row soft-deactivates (default) or + hard-deletes (`?purge=true`). No tag and no entry link is deleted." + parameters: + - in: path + name: key + schema: + type: string + required: true + - in: query + name: purge + schema: + type: string + enum: + - "true" + - "false" + responses: + "200": + description: Group deleted; `rehomedCount` custom tags moved. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" + "401": *a1 + "404": *a5 + "422": *a3 + "429": *a4 + /api/mood/tags/layout: + get: + tags: + - Mood + summary: Read the per-user mood-tag layout (v1.17.0) + description: "Returns the stored blob merged over defaults: `groupOrder` resolved against the caller's effective + category set (seeded + own groups), `placements` as stored." + responses: + "200": + description: Resolved layout. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagLayoutGetEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + put: + tags: + - Mood + summary: Update the per-user mood-tag layout (v1.17.0) + description: "Preserve-when-absent merge: a `groupOrder`-only PUT keeps the stored `placements` and vice versa. Bounded: + ≤ 50 groups, ≤ 400 placement entries, keys ≤ 80 chars. Returns the resolved layout." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + groupOrder: + maxItems: 50 + type: array + items: + type: string + minLength: 1 + maxLength: 80 + placements: + type: object + propertyNames: + type: string + minLength: 1 + maxLength: 80 + additionalProperties: + type: array + items: + type: string + minLength: 1 + maxLength: 80 + responses: + "200": + description: Merged + resolved layout. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodTagLayoutPutEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -13213,6 +13565,463 @@ components: - interpretation - lagDays additionalProperties: false + MoodTagTreeEnvelope: + type: object + properties: + data: + type: object + properties: + categories: + type: array + items: + $ref: "#/components/schemas/MoodTagCategoryDTO" + required: + - categories + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagCategoryDTO: + type: object + properties: + key: + type: string + labelKey: + anyOf: + - type: string + - type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + custom: + type: boolean + tags: + type: array + items: + $ref: "#/components/schemas/MoodTagDTO" + required: + - key + - labelKey + - label + - icon + - custom + - tags + additionalProperties: false + description: "One group of the effective tree, already per-user ordered (layout applied server-side). `custom: true` = + the caller's own group (render `label`); seeded groups resolve `labelKey`." + MoodTagDTO: + type: object + properties: + key: + type: string + labelKey: + anyOf: + - type: string + - type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + kind: + type: string + enum: + - BINARY + - RATED + scaleMin: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scaleMax: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inverse: + type: boolean + custom: + type: boolean + hidden: + description: Catalogue tags only, present when `include` contains `hidden`. + type: boolean + archived: + description: Custom tags only, present when `include` contains `archived`. + type: boolean + usageCount: + description: Live-entry link count for this user, present when `include` contains `usage`. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - labelKey + - label + - icon + - kind + - scaleMin + - scaleMax + - inverse + - custom + additionalProperties: false + description: One effective mood tag. Render `label` when `custom`, else resolve `labelKey` against the locale. + MoodCustomTagCreatedEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + kind: + type: string + enum: + - BINARY + - RATED + scaleMin: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scaleMax: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inverse: + type: boolean + custom: + type: boolean + const: true + required: + - key + - labelKey + - label + - icon + - kind + - scaleMin + - scaleMax + - inverse + - custom + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodCustomTagPatchEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + kind: + type: string + enum: + - BINARY + - RATED + scaleMin: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scaleMax: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inverse: + type: boolean + custom: + type: boolean + const: true + isActive: + type: boolean + required: + - key + - labelKey + - label + - icon + - kind + - scaleMin + - scaleMax + - inverse + - custom + - isActive + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodCustomTagDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + purged: + type: boolean + required: + - key + - purged + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagHiddenEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + hidden: + type: boolean + required: + - key + - hidden + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagGroupCreatedEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + custom: + type: boolean + const: true + required: + - key + - labelKey + - label + - icon + - custom + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagGroupPatchEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + labelKey: + type: "null" + label: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "null" + custom: + type: boolean + const: true + isActive: + type: boolean + required: + - key + - labelKey + - label + - icon + - custom + - isActive + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagGroupDeleteEnvelope: + type: object + properties: + data: + type: object + properties: + key: + type: string + purged: + type: boolean + rehomedCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - key + - purged + - rehomedCount + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagLayoutGetEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodTagLayoutResolved" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodTagLayoutResolved: + type: object + properties: + groupOrder: + type: array + items: + type: string + placements: + type: object + propertyNames: + type: string + additionalProperties: + type: array + items: + type: string + required: + - groupOrder + - placements + additionalProperties: false + description: "The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category + set, `placements` as stored." + MoodTagLayoutPutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodTagLayoutResolved" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index 5b09c708a..f2dd7099d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -729,6 +729,15 @@ "logIntake": "Einnahme erfassen", "newMedication": "Neues Medikament" }, + "takeAllDue": { + "button": "Alle fälligen einnehmen", + "dialogTitle": "Fällige Medikamente einnehmen", + "dialogDescription": "Diese Medikamente sind jetzt fällig. Bestätige, um sie alle als genommen zu erfassen.", + "confirm": "Alle einnehmen ({count})", + "successToast": "{count} Einnahmen erfasst", + "partialToast": "{taken} erfasst, {failed} fehlgeschlagen – bitte erneut versuchen", + "failedToast": "Keine Einnahme erfasst – bitte erneut versuchen" + }, "logIntake": { "dialogTitle": "Einnahme erfassen", "dialogDescription": "Erfasse eine Dosis für ein vorhandenes Medikament. Du kannst sie auf ein vergangenes Datum und eine vergangene Uhrzeit zurückdatieren.", diff --git a/messages/en.json b/messages/en.json index 6fdcb076b..06cbc409d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -729,6 +729,15 @@ "logIntake": "Log an intake", "newMedication": "New medication" }, + "takeAllDue": { + "button": "Take all due", + "dialogTitle": "Take due medications", + "dialogDescription": "These medications are due now. Confirm to record all of them as taken.", + "confirm": "Take all ({count})", + "successToast": "{count} intakes recorded", + "partialToast": "{taken} recorded, {failed} failed – please try again", + "failedToast": "No intake recorded – please try again" + }, "logIntake": { "dialogTitle": "Log an intake", "dialogDescription": "Record a dose against an existing medication. You can backdate it to a past date and time.", diff --git a/messages/es.json b/messages/es.json index 6b89d58f1..03b2ec170 100644 --- a/messages/es.json +++ b/messages/es.json @@ -729,6 +729,15 @@ "logIntake": "Registrar una toma", "newMedication": "Nuevo medicamento" }, + "takeAllDue": { + "button": "Tomar todas las pendientes", + "dialogTitle": "Tomar medicamentos pendientes", + "dialogDescription": "Estos medicamentos están pendientes ahora. Confirma para registrarlos todos como tomados.", + "confirm": "Tomar todos ({count})", + "successToast": "{count} tomas registradas", + "partialToast": "{taken} registradas, {failed} fallidas – inténtalo de nuevo", + "failedToast": "No se registró ninguna toma – inténtalo de nuevo" + }, "logIntake": { "dialogTitle": "Registrar una toma", "dialogDescription": "Registra una dosis de un medicamento existente. Puedes posfecharla a una fecha y hora pasadas.", diff --git a/messages/fr.json b/messages/fr.json index ac6e4091e..45039c071 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -729,6 +729,15 @@ "logIntake": "Enregistrer une prise", "newMedication": "Nouveau médicament" }, + "takeAllDue": { + "button": "Prendre toutes les doses dues", + "dialogTitle": "Prendre les médicaments dus", + "dialogDescription": "Ces médicaments sont à prendre maintenant. Confirmez pour les enregistrer tous comme pris.", + "confirm": "Tout prendre ({count})", + "successToast": "{count} prises enregistrées", + "partialToast": "{taken} enregistrées, {failed} échouées – veuillez réessayer", + "failedToast": "Aucune prise enregistrée – veuillez réessayer" + }, "logIntake": { "dialogTitle": "Enregistrer une prise", "dialogDescription": "Enregistrez une dose pour un médicament existant. Vous pouvez l’antidater à une date et une heure passées.", diff --git a/messages/it.json b/messages/it.json index eb6685894..0e5059568 100644 --- a/messages/it.json +++ b/messages/it.json @@ -729,6 +729,15 @@ "logIntake": "Registra un'assunzione", "newMedication": "Nuovo farmaco" }, + "takeAllDue": { + "button": "Assumi tutte le dosi in scadenza", + "dialogTitle": "Assumi i farmaci in scadenza", + "dialogDescription": "Questi farmaci sono da assumere adesso. Conferma per registrarli tutti come assunti.", + "confirm": "Assumi tutti ({count})", + "successToast": "{count} assunzioni registrate", + "partialToast": "{taken} registrate, {failed} non riuscite – riprova", + "failedToast": "Nessuna assunzione registrata – riprova" + }, "logIntake": { "dialogTitle": "Registra un'assunzione", "dialogDescription": "Registra una dose per un farmaco esistente. Puoi retrodatarla a una data e un'ora passate.", diff --git a/messages/pl.json b/messages/pl.json index ec1670839..7864d6bfa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -729,6 +729,15 @@ "logIntake": "Zarejestruj przyjęcie", "newMedication": "Nowy lek" }, + "takeAllDue": { + "button": "Przyjmij wszystkie zaległe", + "dialogTitle": "Przyjmij zaległe leki", + "dialogDescription": "Te leki są teraz do przyjęcia. Potwierdź, aby zarejestrować je wszystkie jako przyjęte.", + "confirm": "Przyjmij wszystkie ({count})", + "successToast": "Zarejestrowano przyjęcia: {count}", + "partialToast": "Zarejestrowano: {taken}, nieudane: {failed} – spróbuj ponownie", + "failedToast": "Nie zarejestrowano żadnego przyjęcia – spróbuj ponownie" + }, "logIntake": { "dialogTitle": "Zarejestruj przyjęcie", "dialogDescription": "Zarejestruj dawkę istniejącego leku. Możesz wstawić ją wstecznie na przeszłą datę i godzinę.", diff --git a/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql b/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql new file mode 100644 index 000000000..9b6b53c7d --- /dev/null +++ b/prisma/migrations/0152_v1170_mood_tag_groups/migration.sql @@ -0,0 +1,29 @@ +-- v1.17.0 — per-user custom mood-tag groups + per-user tag layout. +-- +-- Additive, non-destructive (mirrors 0127_v1130_custom_mood_tags): +-- 1. `mood_tag_categories` gains `user_id` (NULL = global seeded category, +-- set = a user's custom group carrying a `customcat:` key) and +-- `label_encrypted` (AES-256-GCM ciphertext of a custom group's +-- free-text label; NULL for seeded rows, which resolve `label_key` +-- against the locale). +-- 2. `users` gains `mood_tag_layout_json` — the per-user presentation blob +-- (group order + per-group tag placement), display-only, following the +-- `medication_list_layout_json` per-surface-column convention. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS, constraint adds guarded by +-- duplicate_object, CREATE INDEX IF NOT EXISTS. + +-- ─── 1. mood_tag_categories: owner + encrypted custom label ────────── +ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "user_id" TEXT; +ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "label_encrypted" TEXT; + +DO $$ BEGIN + ALTER TABLE "mood_tag_categories" + ADD CONSTRAINT "mood_tag_categories_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE INDEX IF NOT EXISTS "mood_tag_categories_user_id_idx" ON "mood_tag_categories"("user_id"); + +-- ─── 2. users: per-user mood-tag presentation blob ─────────────────── +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mood_tag_layout_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d32c7affd..02bfb1745 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,6 +145,13 @@ model User { // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } medicationListLayoutJson Json? @map("medication_list_layout_json") + // v1.17.0 — per-user mood-tag presentation: group order + per-group tag + // placement (catalogue tags "moved" into a group live here only — their + // `categoryId` never changes). Display-only, preserve-when-absent PUT, + // unknown keys dropped at read. Null = seeded defaults. + // Shape: { "groupOrder": ["", ...], + // "placements": { "": ["", ...] } } + moodTagLayoutJson Json? @map("mood_tag_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.7.0 unit pref — global metric/imperial display preference. @@ -402,6 +409,7 @@ model User { fitbitOAuthStates FitbitOAuthState[] moodEntries MoodEntry[] moodTagsCustom MoodTag[] @relation("UserMoodTags") + moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") moodTagsHidden MoodTagHidden[] notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] @@ -2371,8 +2379,19 @@ model MoodTagCategory { /// Soft-hide a category without deleting its historical links. isActive Boolean @default(true) @map("is_active") - tags MoodTag[] + /// v1.17.0 — owner of a per-user custom group. NULL = the global seeded + /// catalogue (resolves `labelKey` against the locale). Set = a user's own + /// group, carrying a `customcat:` key and a free-text label held in + /// `labelEncrypted` (mirrors the MoodTag custom-tag precedent). + userId String? @map("user_id") + /// v1.17.0 — AES-256-GCM ciphertext of a custom group's free-text label + /// (user prose, possibly health-adjacent). NULL for seeded rows. + labelEncrypted String? @map("label_encrypted") + tags MoodTag[] + user User? @relation("UserMoodTagCategories", fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) @@map("mood_tag_categories") } diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 9b14c2aab..a2eefaa4f 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -230,6 +230,50 @@ async function main() { results.push(r); } + // ───── MoodTag.labelEncrypted ───── + // v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text + // label (catalogue rows carry NULL and are skipped by `shouldRotate`). + const moodTags = await prisma.moodTag.findMany({ + where: { labelEncrypted: { not: null } }, + select: { id: true, labelEncrypted: true }, + }); + results.push( + await rotateField( + "MoodTag", + "labelEncrypted", + moodTags, + (t) => t.labelEncrypted, + async (id, ciphertext) => { + await prisma.moodTag.update({ + where: { id }, + data: { labelEncrypted: ciphertext }, + }); + }, + ), + ); + + // ───── MoodTagCategory.labelEncrypted ───── + // v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's + // free-text label (seeded rows carry NULL and are skipped). + const moodTagCategories = await prisma.moodTagCategory.findMany({ + where: { labelEncrypted: { not: null } }, + select: { id: true, labelEncrypted: true }, + }); + results.push( + await rotateField( + "MoodTagCategory", + "labelEncrypted", + moodTagCategories, + (c) => c.labelEncrypted, + async (id, ciphertext) => { + await prisma.moodTagCategory.update({ + where: { id }, + data: { labelEncrypted: ciphertext }, + }); + }, + ), + ); + // ───── NotificationChannel.config ───── // Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON. // Skipping these on rotation would leave channels permanently undecryptable diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts index 161ff255e..e9d3a3ded 100644 --- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts @@ -14,6 +14,7 @@ const db = vi.hoisted(() => ({ update: vi.fn(), delete: vi.fn(), }, + moodTagCategory: { findFirst: vi.fn() }, moodTagHidden: { upsert: vi.fn(), deleteMany: vi.fn() }, })); @@ -74,6 +75,42 @@ describe("POST /api/mood/tags/custom", () => { expect(res.status).toBe(422); expect(db.moodTag.create).not.toHaveBeenCalled(); }); + + it("defaults to the seeded custom category without a categoryKey (v1.13 contract)", async () => { + db.moodTag.count.mockResolvedValue(0); + db.moodTag.create.mockResolvedValue({ + key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, + }); + await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" })); + expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled(); + expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("mtc_custom"); + }); + + it("resolves categoryKey to a seeded OR own group (v1.17.0) and 422s an unknown one", async () => { + db.moodTag.count.mockResolvedValue(0); + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTag.create.mockResolvedValue({ + key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, + }); + const ok = await POST( + jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:g1" }), + ); + expect(ok.status).toBe(201); + expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("cg1"); + // Resolution is owner-scoped: seeded (userId null) OR the caller's own. + const catWhere = db.moodTagCategory.findFirst.mock.calls[0][0].where; + expect(catWhere).toEqual({ + key: "customcat:g1", + isActive: true, + OR: [{ userId: null }, { userId: "user-1" }], + }); + + db.moodTagCategory.findFirst.mockResolvedValue(null); + const bad = await POST( + jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X", categoryKey: "customcat:theirs" }), + ); + expect(bad.status).toBe(422); + }); }); describe("PATCH/DELETE /api/mood/tags/custom/:key", () => { @@ -94,6 +131,32 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => { expect(db.moodTag.findFirst).not.toHaveBeenCalled(); }); + it("moves a custom tag to another group via categoryKey (v1.17.0)", async () => { + db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTag.update.mockResolvedValue({ + key: "custom:x", icon: null, kind: "BINARY", scaleMin: 1, scaleMax: 5, + inverse: false, isActive: true, labelEncrypted: "enc:X", + }); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "customcat:g1" }), + params("custom:x"), + ); + expect(res.status).toBe(200); + expect(db.moodTag.update.mock.calls[0][0].data).toEqual({ categoryId: "cg1" }); + }); + + it("422s a move to an unknown / foreign group without writing", async () => { + db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); + db.moodTagCategory.findFirst.mockResolvedValue(null); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { categoryKey: "ghost" }), + params("custom:x"), + ); + expect(res.status).toBe(422); + expect(db.moodTag.update).not.toHaveBeenCalled(); + }); + it("soft-deactivates by default and hard-deletes on ?purge=true", async () => { db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); const soft = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"), params("custom:abc")); diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts new file mode 100644 index 000000000..b4441dc31 --- /dev/null +++ b/src/app/api/mood/tags/__tests__/groups-crud.test.ts @@ -0,0 +1,230 @@ +/** + * v1.17.0 — custom mood-tag group CRUD guards: create cap, ownership 404 on + * edit/delete, and the non-destructive delete (re-home own tags → seeded + * `custom`, strip the group from the layout blob, soft-deactivate by default + * vs hard-delete on ?purge=true). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const db = vi.hoisted(() => { + const base = { + moodTagCategory: { + count: vi.fn(), + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + moodTag: { updateMany: vi.fn() }, + user: { findUnique: vi.fn(), update: vi.fn() }, + $transaction: vi.fn(), + }; + // Interactive tx: hand the same mock surface to the callback. + base.$transaction.mockImplementation( + async (fn: (tx: typeof base) => Promise) => fn(base), + ); + return base; +}); + +vi.mock("@/lib/db", () => ({ + prisma: db, + toJson: (v: T) => v, +})); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: 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 "../groups/route"; +import { PATCH, DELETE } from "../groups/[key]/route"; +import { getSession } from "@/lib/auth/session"; +import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "t", role: "USER" as const }, +}; + +function jsonReq(url: string, method: string, body: unknown) { + return new NextRequest(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const params = (key: string) => ({ params: Promise.resolve({ key }) }); + +beforeEach(() => { + vi.clearAllMocks(); + db.$transaction.mockImplementation( + async (fn: (tx: typeof db) => Promise) => fn(db), + ); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("POST /api/mood/tags/groups", () => { + it("creates a group and returns it (custom:true, decrypted label)", async () => { + db.moodTagCategory.count.mockResolvedValue(2); + db.moodTagCategory.create.mockResolvedValue({ + key: "customcat:abc", + icon: "Stethoscope", + }); + const res = await POST( + jsonReq("http://localhost/api/mood/tags/groups", "POST", { + label: "Therapie", + icon: "Stethoscope", + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { key: string; label: string; labelKey: null; custom: boolean }; + }; + expect(body.data).toMatchObject({ + key: "customcat:abc", + label: "Therapie", + labelKey: null, + custom: true, + }); + const data = db.moodTagCategory.create.mock.calls[0][0].data; + // Label is stored encrypted, never plaintext; owner pinned from session. + expect(data.labelEncrypted).toBe("enc:Therapie"); + expect(data.userId).toBe("user-1"); + expect(data.key.startsWith("customcat:")).toBe(true); + }); + + it("rejects over the per-user cap with 422", async () => { + db.moodTagCategory.count.mockResolvedValue(12); + const res = await POST( + jsonReq("http://localhost/api/mood/tags/groups", "POST", { label: "X" }), + ); + expect(res.status).toBe(422); + expect(db.moodTagCategory.create).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /api/mood/tags/groups/:key", () => { + it("404s a seeded category key without touching the DB", async () => { + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/groups/feelings", "PATCH", { label: "x" }), + params("feelings"), + ); + expect(res.status).toBe(404); + expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled(); + }); + + it("404s when the key is not the caller's own group", async () => { + db.moodTagCategory.findFirst.mockResolvedValue(null); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "x" }), + params("customcat:x"), + ); + expect(res.status).toBe(404); + }); + + it("renames (re-encrypts) and returns the updated group", async () => { + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTagCategory.update.mockResolvedValue({ + key: "customcat:x", + icon: null, + isActive: true, + labelEncrypted: "enc:Neu", + }); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", { label: "Neu" }), + params("customcat:x"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { label: string; isActive: boolean } }; + expect(body.data).toMatchObject({ label: "Neu", isActive: true }); + expect(db.moodTagCategory.update.mock.calls[0][0].data).toEqual({ + labelEncrypted: "enc:Neu", + }); + }); +}); + +describe("DELETE /api/mood/tags/groups/:key", () => { + beforeEach(() => { + db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" }); + db.moodTag.updateMany.mockResolvedValue({ count: 2 }); + db.user.findUnique.mockResolvedValue({ + moodTagLayoutJson: { + groupOrder: ["customcat:x", "feelings"], + placements: { "customcat:x": ["happy"], feelings: ["sad"] }, + }, + }); + }); + + it("re-homes own tags, strips the layout, soft-deactivates by default", async () => { + const res = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/customcat:x"), + params("customcat:x"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { purged: boolean; rehomedCount: number }; + }; + expect(body.data).toMatchObject({ purged: false, rehomedCount: 2 }); + + // Own custom tags re-home to the seeded custom category. + expect(db.moodTag.updateMany).toHaveBeenCalledWith({ + where: { categoryId: "cg1", userId: "user-1" }, + data: { categoryId: CUSTOM_CATEGORY_ID }, + }); + // Layout blob drops the group from order + placements, keeps the rest. + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + moodTagLayoutJson: { + groupOrder: ["feelings"], + placements: { feelings: ["sad"] }, + }, + }, + }); + // Default = retire, never delete. + expect(db.moodTagCategory.update).toHaveBeenCalledWith({ + where: { id: "cg1" }, + data: { isActive: false }, + }); + expect(db.moodTagCategory.delete).not.toHaveBeenCalled(); + }); + + it("hard-deletes the emptied group row on ?purge=true", async () => { + const res = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/customcat:x?purge=true"), + params("customcat:x"), + ); + expect(res.status).toBe(200); + expect(db.moodTagCategory.delete).toHaveBeenCalledWith({ + where: { id: "cg1" }, + }); + expect(db.moodTagCategory.update).not.toHaveBeenCalled(); + // Re-home still ran first — purge removes the group, never the tags. + expect(db.moodTag.updateMany).toHaveBeenCalled(); + }); + + it("404s a non-custom key and a foreign group", async () => { + const seeded = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/custom"), + params("custom"), + ); + expect(seeded.status).toBe(404); + + db.moodTagCategory.findFirst.mockResolvedValue(null); + const foreign = await DELETE( + new NextRequest("http://localhost/api/mood/tags/groups/customcat:theirs"), + params("customcat:theirs"), + ); + expect(foreign.status).toBe(404); + expect(db.moodTag.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood/tags/__tests__/layout.test.ts b/src/app/api/mood/tags/__tests__/layout.test.ts new file mode 100644 index 000000000..044b1dacb --- /dev/null +++ b/src/app/api/mood/tags/__tests__/layout.test.ts @@ -0,0 +1,121 @@ +/** + * v1.17.0 — `GET/PUT /api/mood/tags/layout`: resolved groupOrder over the + * effective category set, preserve-when-absent merge on PUT, and the Zod + * bounds on the blob. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const db = vi.hoisted(() => ({ + user: { findUnique: vi.fn(), update: vi.fn() }, + moodTagCategory: { findMany: vi.fn() }, +})); + +vi.mock("@/lib/db", () => ({ + prisma: db, + toJson: (v: T) => v, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: 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 { GET, PUT } from "../layout/route"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "t", role: "USER" as const }, +}; + +const CATEGORY_KEYS = [ + { key: "feelings" }, + { key: "custom" }, + { key: "customcat:g1" }, +]; + +function putReq(body: unknown) { + return new NextRequest("http://localhost/api/mood/tags/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + db.moodTagCategory.findMany.mockResolvedValue(CATEGORY_KEYS); + db.user.findUnique.mockResolvedValue({ moodTagLayoutJson: null }); +}); + +describe("GET /api/mood/tags/layout", () => { + it("returns seeded defaults when nothing is stored", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { groupOrder: string[]; placements: Record }; + }; + expect(body.data).toEqual({ + groupOrder: ["feelings", "custom", "customcat:g1"], + placements: {}, + }); + }); + + it("resolves the stored order: layout first, unknown dropped, missing appended", async () => { + db.user.findUnique.mockResolvedValue({ + moodTagLayoutJson: { + groupOrder: ["customcat:g1", "ghost", "feelings"], + placements: { "customcat:g1": ["happy"] }, + }, + }); + const res = await GET(); + const body = (await res.json()) as { + data: { groupOrder: string[]; placements: Record }; + }; + expect(body.data.groupOrder).toEqual(["customcat:g1", "feelings", "custom"]); + expect(body.data.placements).toEqual({ "customcat:g1": ["happy"] }); + }); +}); + +describe("PUT /api/mood/tags/layout", () => { + it("merges preserve-when-absent: a groupOrder-only PUT keeps stored placements", async () => { + db.user.findUnique.mockResolvedValue({ + moodTagLayoutJson: { placements: { feelings: ["sad"] } }, + }); + const res = await PUT(putReq({ groupOrder: ["custom", "feelings"] })); + expect(res.status).toBe(200); + expect(db.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + moodTagLayoutJson: { + groupOrder: ["custom", "feelings"], + placements: { feelings: ["sad"] }, + }, + }, + }); + const body = (await res.json()) as { + data: { groupOrder: string[]; placements: Record }; + }; + expect(body.data.groupOrder).toEqual(["custom", "feelings", "customcat:g1"]); + expect(body.data.placements).toEqual({ feelings: ["sad"] }); + }); + + it("422s an over-bound blob without writing", async () => { + const res = await PUT( + putReq({ groupOrder: Array.from({ length: 51 }, (_, i) => `g${i}`) }), + ); + expect(res.status).toBe(422); + expect(db.user.update).not.toHaveBeenCalled(); + }); + + it("422s a malformed body (non-array groupOrder)", async () => { + const res = await PUT(putReq({ groupOrder: "feelings" })); + expect(res.status).toBe(422); + expect(db.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts index 6e300d3c2..96c11e5e8 100644 --- a/src/app/api/mood/tags/__tests__/route.test.ts +++ b/src/app/api/mood/tags/__tests__/route.test.ts @@ -1,9 +1,11 @@ /** - * v1.12.0 / v1.13.0 — `GET /api/mood/tags` exposes the rated-factor metadata - * (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the v1.13.0 effective + * v1.12.0 / v1.13.0 / v1.17.0 — `GET /api/mood/tags` exposes the rated-factor + * metadata (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the effective * per-user set: own custom tags merged in (label decrypted, `custom: true`), * hidden catalogue tags omitted by default and surfaced under - * `?include=hidden`. + * `?include=hidden`. v1.17.0 layers custom groups, the layout blob (group + * order + placements), `include=archived` (own inactive customs) and + * `include=usage` (per-tag live-entry counts) on top. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; @@ -11,12 +13,16 @@ import { NextRequest } from "next/server"; const categoryFindMany = vi.fn(); const tagFindMany = vi.fn(); const hiddenFindMany = vi.fn(); +const userFindUnique = vi.fn(); +const linkGroupBy = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) }, moodTag: { findMany: (...a: unknown[]) => tagFindMany(...a) }, moodTagHidden: { findMany: (...a: unknown[]) => hiddenFindMany(...a) }, + user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + moodEntryTagLink: { groupBy: (...a: unknown[]) => linkGroupBy(...a) }, }, })); @@ -52,16 +58,40 @@ const SESSION_OK = { }; const CATEGORIES = [ - { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile" }, - { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag" }, + { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile", userId: null, labelEncrypted: null }, + { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag", userId: null, labelEncrypted: null }, ]; +const OWN_GROUP = { + id: "cg1", + key: "customcat:g1", + labelKey: "customcat:g1", + icon: "Stethoscope", + userId: "user-1", + labelEncrypted: "enc:Therapie", +}; + const TAGS = [ - { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null }, - { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null }, - { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: "user-1", labelEncrypted: "enc:Migräne" }, + { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null }, + { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: null, labelEncrypted: null }, + { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, isActive: true, userId: "user-1", labelEncrypted: "enc:Migräne" }, ]; +const ARCHIVED_TAG = { + id: "t_arch", + categoryId: "mtc_custom", + key: "custom:arch", + labelKey: "custom:arch", + icon: null, + kind: "BINARY", + scaleMin: 1, + scaleMax: 5, + inverse: false, + isActive: false, + userId: "user-1", + labelEncrypted: "enc:Alt", +}; + interface TagOut { key: string; labelKey: string | null; @@ -72,9 +102,19 @@ interface TagOut { scaleMax: number; inverse: boolean; hidden?: boolean; + archived?: boolean; + usageCount?: number; +} +interface CategoryOut { + key: string; + labelKey: string | null; + label: string | null; + icon: string | null; + custom: boolean; + tags: TagOut[]; } interface Body { - data: { categories: Array<{ key: string; tags: TagOut[] }> }; + data: { categories: CategoryOut[] }; } function req(url = "http://localhost/api/mood/tags") { @@ -87,6 +127,8 @@ beforeEach(() => { categoryFindMany.mockResolvedValue(CATEGORIES); tagFindMany.mockResolvedValue(TAGS); hiddenFindMany.mockResolvedValue([{ moodTagId: "t_sad" }]); + userFindUnique.mockResolvedValue({ moodTagLayoutJson: null }); + linkGroupBy.mockResolvedValue([]); }); function flat(body: Body): Record { @@ -126,6 +168,9 @@ describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => { // Tag query scopes to catalogue OR the caller's own customs. const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where; expect(tagWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]); + + // Plain picker read never pays the usage groupBy. + expect(linkGroupBy).not.toHaveBeenCalled(); }); it("surfaces hidden catalogue tags with hidden:true under ?include=hidden", async () => { @@ -137,3 +182,113 @@ describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => { expect(tags["custom:abc"].hidden).toBeUndefined(); }); }); + +describe("GET /api/mood/tags — groups + layout + archived + usage (v1.17.0)", () => { + it("returns own custom groups with decrypted label and custom:true on categories", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + tagFindMany.mockResolvedValue([ + ...TAGS, + { ...TAGS[2], id: "t_c2", categoryId: "cg1", key: "custom:ing1", labelKey: "custom:ing1", labelEncrypted: "enc:Sitzung" }, + ]); + const res = await GET(req()); + const body = (await res.json()) as Body; + const group = body.data.categories.find((c) => c.key === "customcat:g1"); + expect(group).toMatchObject({ + custom: true, + label: "Therapie", + labelKey: null, + icon: "Stethoscope", + }); + expect(group?.tags.map((t) => t.key)).toEqual(["custom:ing1"]); + // Seeded categories stay labelKey-driven. + const feelings = body.data.categories.find((c) => c.key === "feelings"); + expect(feelings).toMatchObject({ + custom: false, + label: null, + labelKey: "mood.tagCategory.feelings", + }); + // Category query scopes to seeded OR own groups. + const catWhere = categoryFindMany.mock.calls[0]?.[0]?.where; + expect(catWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]); + }); + + it("applies the layout blob: group order + catalogue-tag placement", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + userFindUnique.mockResolvedValue({ + moodTagLayoutJson: { + groupOrder: ["customcat:g1", "custom", "feelings"], + placements: { "customcat:g1": ["happy"] }, + }, + }); + const res = await GET(req()); + const body = (await res.json()) as Body; + // `feelings` ends empty (happy placed away, sad hidden) → dropped. + expect(body.data.categories.map((c) => c.key)).toEqual([ + "customcat:g1", + "custom", + ]); + // Catalogue tag `happy` rendered inside the user's group (placement, + // not a categoryId change) — gone from its home category. + const group = body.data.categories.find((c) => c.key === "customcat:g1"); + expect(group?.tags.map((t) => t.key)).toEqual(["happy"]); + const feelings = body.data.categories.find((c) => c.key === "feelings"); + expect(feelings).toBeUndefined(); // only hidden `sad` left → dropped + }); + + it("drops a stale layout placement (hidden tag) silently", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + userFindUnique.mockResolvedValue({ + moodTagLayoutJson: { + placements: { "customcat:g1": ["sad", "ghost"] }, // sad is hidden + }, + }); + const res = await GET(req()); + const body = (await res.json()) as Body; + expect( + body.data.categories.find((c) => c.key === "customcat:g1"), + ).toBeUndefined(); // nothing visible placed → empty → dropped (plain read) + }); + + it("returns archived own customs with archived:true under include=archived and keeps empty own groups", async () => { + categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]); + tagFindMany.mockResolvedValue([...TAGS, ARCHIVED_TAG]); + const res = await GET( + req("http://localhost/api/mood/tags?include=hidden,archived"), + ); + const body = (await res.json()) as Body; + const tags = flat(body); + expect(tags["custom:arch"]).toMatchObject({ + custom: true, + archived: true, + label: "Alt", + }); + expect(tags["custom:abc"]).toMatchObject({ archived: false }); + // Catalogue rows never carry the archived flag. + expect(tags.happy.archived).toBeUndefined(); + // The archived read lifts isActive only for the caller's own rows. + const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where; + expect(tagWhere).toEqual({ + OR: [{ userId: null, isActive: true }, { userId: "user-1" }], + }); + // Empty own group kept on a management read. + expect( + body.data.categories.find((c) => c.key === "customcat:g1"), + ).toMatchObject({ custom: true, tags: [] }); + }); + + it("emits usageCount per tag under include=usage (live entries only)", async () => { + linkGroupBy.mockResolvedValue([ + { moodTagId: "t_happy", _count: { _all: 7 } }, + ]); + const res = await GET(req("http://localhost/api/mood/tags?include=usage")); + const tags = flat((await res.json()) as Body); + expect(tags.happy.usageCount).toBe(7); + expect(tags["custom:abc"].usageCount).toBe(0); + expect(linkGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ["moodTagId"], + where: { moodEntry: { userId: "user-1", deletedAt: null } }, + }), + ); + }); +}); diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts index 697489bbf..3b28228e7 100644 --- a/src/app/api/mood/tags/custom/[key]/route.ts +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -15,20 +15,27 @@ import { decryptCustomLabel, isCustomTagKey, } from "@/lib/mood/custom-tags"; +import { resolveCategoryKeyForUser } from "@/lib/mood/tag-groups"; export const dynamic = "force-dynamic"; type RouteParams = { params: Promise<{ key: string }> }; /** - * v1.13.0 — update / delete a per-user custom mood tag. + * v1.13.0 / v1.17.0 — update / delete a per-user custom mood tag. * * Both handlers resolve the `custom:`-prefixed key against the CALLER's own * rows only — another user's key (or a catalogue key) is a 404, so a tag can * never be edited or removed across the ownership boundary. */ -/** `PATCH /api/mood/tags/custom/:key` — rename / recolour / (de)activate. */ +/** + * `PATCH /api/mood/tags/custom/:key` — rename / re-icon / (de)activate / + * move to another group (v1.17.0 `categoryKey`: any seeded category or an + * own `customcat:` group; unknown / foreign → 422). The move is a real + * `categoryId` update — custom tags belong relationally, only catalogue + * tags "move" via the layout blob. + */ export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -48,6 +55,16 @@ export const PATCH = apiHandler( }); if (!owned) return apiError("Custom tag not found", 404); + let categoryId: string | undefined; + if (parsed.data.categoryKey !== undefined) { + const resolved = await resolveCategoryKeyForUser( + parsed.data.categoryKey, + user.id, + ); + if (!resolved) return apiError("Unknown category", 422); + categoryId = resolved; + } + const updated = await prisma.moodTag.update({ where: { id: owned.id }, data: { @@ -58,6 +75,7 @@ export const PATCH = apiHandler( ...(parsed.data.isActive !== undefined ? { isActive: parsed.data.isActive } : {}), + ...(categoryId !== undefined ? { categoryId } : {}), }, select: { key: true, diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts index d5cf6d30e..4aece227e 100644 --- a/src/app/api/mood/tags/custom/route.ts +++ b/src/app/api/mood/tags/custom/route.ts @@ -16,15 +16,20 @@ import { CUSTOM_CATEGORY_ID, MAX_CUSTOM_TAGS_PER_USER, } from "@/lib/mood/custom-tags"; +import { resolveCategoryKeyForUser } from "@/lib/mood/tag-groups"; export const dynamic = "force-dynamic"; /** - * v1.13.0 — create a per-user custom mood tag. + * v1.13.0 / v1.17.0 — create a per-user custom mood tag. * * `POST /api/mood/tags/custom` — body `{ label, icon?, categoryKey? }`. Mints * a `custom:` key, encrypts the label at rest, and stores a BINARY tag - * under the global `custom` category owned by the caller. Capped per user. + * owned by the caller. `categoryKey` (v1.17.0) picks the home group — any + * seeded category key or one of the caller's own `customcat:` groups; an + * unknown or foreign key is a 422. Omitted → the seeded `custom` category + * (the v1.13.0 behaviour, byte-identical for existing clients). Capped per + * user. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -46,10 +51,20 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } + let categoryId = CUSTOM_CATEGORY_ID; + if (parsed.data.categoryKey !== undefined) { + const resolved = await resolveCategoryKeyForUser( + parsed.data.categoryKey, + user.id, + ); + if (!resolved) return apiError("Unknown category", 422); + categoryId = resolved; + } + const key = mintCustomTagKey(); const created = await prisma.moodTag.create({ data: { - categoryId: CUSTOM_CATEGORY_ID, + categoryId, key, // Catalogue rows resolve `labelKey` against the locale; a custom tag // renders its decrypted `label` instead, so labelKey just mirrors the diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts new file mode 100644 index 000000000..cc94d9483 --- /dev/null +++ b/src/app/api/mood/tags/groups/[key]/route.ts @@ -0,0 +1,152 @@ +import { NextRequest } from "next/server"; + +import { prisma, toJson } from "@/lib/db"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + updateCustomGroupSchema, + encryptCustomLabel, + decryptCustomLabel, + isCustomCategoryKey, + CUSTOM_CATEGORY_ID, +} from "@/lib/mood/custom-tags"; +import { + parseStoredMoodTagLayout, + stripGroupFromLayout, +} from "@/lib/mood/tag-layout"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ key: string }> }; + +/** + * v1.17.0 — update / delete a per-user custom mood-tag group. + * + * Both handlers resolve the `customcat:`-prefixed key against the CALLER's + * own rows only — another user's key (or a seeded category key) is a 404, + * so a group can never be edited or removed across the ownership boundary. + */ + +/** `PATCH /api/mood/tags/groups/:key` — rename / re-icon / (de)activate. */ +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { key } = await params; + if (!isCustomCategoryKey(key)) return apiError("Not a custom group", 404); + + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateCustomGroupSchema.safeParse(rawJsonBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const owned = await prisma.moodTagCategory.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Custom group not found", 404); + + const updated = await prisma.moodTagCategory.update({ + where: { id: owned.id }, + data: { + ...(parsed.data.label !== undefined + ? { labelEncrypted: encryptCustomLabel(parsed.data.label) } + : {}), + ...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + }, + select: { + key: true, + icon: true, + isActive: true, + labelEncrypted: true, + }, + }); + + annotate({ action: { name: "mood.tag.group.update" } }); + + return apiSuccess({ + key: updated.key, + labelKey: null, + label: decryptCustomLabel(updated.labelEncrypted), + icon: updated.icon, + isActive: updated.isActive, + custom: true, + }); + }, +); + +/** + * `DELETE /api/mood/tags/groups/:key` — non-destructive by construction: + * the group's own custom tags re-home to the seeded `custom` category + * (catalogue-tag placements simply evaporate back to their seeded category + * when the layout entry is stripped), the layout blob drops the group, and + * the row soft-deactivates. `?purge=true` hard-deletes the (now empty) + * group row instead. No tag and no entry link is ever deleted here. + */ +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { key } = await params; + if (!isCustomCategoryKey(key)) return apiError("Not a custom group", 404); + + const owned = await prisma.moodTagCategory.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Custom group not found", 404); + + const purge = request.nextUrl.searchParams.get("purge") === "true"; + + const rehomedCount = await prisma.$transaction(async (tx) => { + // 1. Re-home the caller's custom tags living in this group. Only own + // tags can reference an own group (the categoryKey resolvers + // enforce it), so this empties the group. + const rehomed = await tx.moodTag.updateMany({ + where: { categoryId: owned.id, userId: user.id }, + data: { categoryId: CUSTOM_CATEGORY_ID }, + }); + + // 2. Strip the group from the layout blob (groupOrder + placements). + const userRow = await tx.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }); + const layout = parseStoredMoodTagLayout(userRow?.moodTagLayoutJson); + if (layout.groupOrder !== undefined || layout.placements !== undefined) { + await tx.user.update({ + where: { id: user.id }, + data: { moodTagLayoutJson: toJson(stripGroupFromLayout(layout, key)) }, + }); + } + + // 3. Retire (default) or hard-delete (purge) the empty group row. + if (purge) { + await tx.moodTagCategory.delete({ where: { id: owned.id } }); + } else { + await tx.moodTagCategory.update({ + where: { id: owned.id }, + data: { isActive: false }, + }); + } + + return rehomed.count; + }); + + annotate({ + action: { name: "mood.tag.group.delete" }, + meta: { purge, rehomed_count: rehomedCount }, + }); + + return apiSuccess({ key, purged: purge, rehomedCount }); + }, +); diff --git a/src/app/api/mood/tags/groups/route.ts b/src/app/api/mood/tags/groups/route.ts new file mode 100644 index 000000000..b0b385809 --- /dev/null +++ b/src/app/api/mood/tags/groups/route.ts @@ -0,0 +1,85 @@ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + createCustomGroupSchema, + mintCustomCategoryKey, + encryptCustomLabel, + MAX_CUSTOM_GROUPS_PER_USER, +} from "@/lib/mood/custom-tags"; + +export const dynamic = "force-dynamic"; + +/** + * v1.17.0 — create a per-user custom mood-tag group. + * + * `POST /api/mood/tags/groups` — body `{ label, icon? }`. Mints a + * `customcat:` key, encrypts the label at rest, and stores a + * category owned by the caller. Capped per user. The group is + * presentation-plus-home only: entry writes are key-based and never see + * categories, so no group change can break `tagKeys` / `ratedFactors` + * ingestion. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createCustomGroupSchema.safeParse(rawJsonBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const activeCount = await prisma.moodTagCategory.count({ + where: { userId: user.id, isActive: true }, + }); + if (activeCount >= MAX_CUSTOM_GROUPS_PER_USER) { + return apiError( + `Custom group limit reached (${MAX_CUSTOM_GROUPS_PER_USER})`, + 422, + ); + } + + const key = mintCustomCategoryKey(); + const created = await prisma.moodTagCategory.create({ + data: { + key, + // Seeded rows resolve `labelKey` against the locale; a custom group + // renders its decrypted `label` instead, so labelKey just mirrors the + // key for a stable, non-empty value. + labelKey: key, + labelEncrypted: encryptCustomLabel(parsed.data.label), + icon: parsed.data.icon ?? null, + // After the seeded set (the `custom` category sits at 100) so a fresh + // group appends rather than jumping the seeded categories; the + // per-user layout blob owns the real display order. + sortOrder: 101 + activeCount, + userId: user.id, + }, + select: { key: true, icon: true }, + }); + + annotate({ + action: { name: "mood.tag.group.create" }, + meta: { icon: created.icon }, + }); + + return apiSuccess( + { + key: created.key, + labelKey: null, + label: parsed.data.label, + icon: created.icon, + custom: true, + }, + 201, + ); +}); diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts new file mode 100644 index 000000000..fee4b79d4 --- /dev/null +++ b/src/app/api/mood/tags/layout/route.ts @@ -0,0 +1,109 @@ +import { NextRequest } from "next/server"; + +import { prisma, toJson } from "@/lib/db"; +import { + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + moodTagLayoutSchema, + parseStoredMoodTagLayout, + mergeMoodTagLayout, + resolveGroupOrder, + type MoodTagLayout, +} from "@/lib/mood/tag-layout"; + +export const dynamic = "force-dynamic"; + +/** + * v1.17.0 — per-user mood-tag layout (group order + tag placements). + * + * GET returns the stored blob merged over defaults: `groupOrder` fully + * resolved against the user's effective category set (layout order first, + * unmentioned categories appended in seeded order) + the raw `placements`. + * PUT updates with preserve-when-absent semantics — a groupOrder-only PUT + * keeps the stored placements and vice versa. Mirrors + * `/api/medications/layout`; the blob lives on its own `User` column + * (`mood_tag_layout_json`) per the per-surface-column convention. + * + * Display-only: keys here are opaque, unknown / stale keys are dropped at + * read time by `GET /api/mood/tags`, so the schema bounds size and shape + * but does not assert ownership of every key. + */ + +async function resolveLayoutResponse( + userId: string, + layout: MoodTagLayout, +): Promise<{ + groupOrder: string[]; + placements: Record; +}> { + const categories = await prisma.moodTagCategory.findMany({ + where: { isActive: true, OR: [{ userId: null }, { userId }] }, + orderBy: { sortOrder: "asc" }, + select: { key: true }, + }); + return { + groupOrder: resolveGroupOrder( + categories.map((c) => c.key), + layout.groupOrder, + ), + placements: layout.placements ?? {}, + }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }); + const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson); + return apiSuccess(await resolveLayoutResponse(user.id, stored)); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = moodTagLayoutSchema.safeParse(rawJsonBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // Preserve-when-absent: a PUT carrying only `groupOrder` must not wipe + // the stored placements, and a placements-only PUT must not reset the + // group order. + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }); + const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson); + const merged: MoodTagLayout = mergeMoodTagLayout(stored, { + ...(parsed.data.groupOrder !== undefined + ? { groupOrder: parsed.data.groupOrder } + : {}), + ...(parsed.data.placements !== undefined + ? { placements: parsed.data.placements } + : {}), + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { moodTagLayoutJson: toJson(merged) }, + }); + + annotate({ + action: { name: "mood.tag.layout.update" }, + meta: { + group_order_count: merged.groupOrder?.length ?? 0, + placement_group_count: Object.keys(merged.placements ?? {}).length, + }, + }); + + return apiSuccess(await resolveLayoutResponse(user.id, merged)); +}); diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts index c4589267d..d5b784515 100644 --- a/src/app/api/mood/tags/route.ts +++ b/src/app/api/mood/tags/route.ts @@ -5,110 +5,196 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { decryptCustomLabel } from "@/lib/mood/custom-tags"; +import { + parseStoredMoodTagLayout, + resolveMoodTagPlacement, +} from "@/lib/mood/tag-layout"; export const dynamic = "force-dynamic"; /** - * v1.8.5 / v1.13.0 — the EFFECTIVE per-user mood-tag taxonomy. + * v1.8.5 / v1.13.0 / v1.17.0 — the EFFECTIVE per-user mood-tag taxonomy. * - * Returns the active Category → Tag tree the mood-logging form renders as a - * pick-from-catalogue surface. The catalogue is global reference data (seeded - * by migration); v1.13.0 layers two per-user adjustments on top so the read - * is now user-scoped: - * - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys) - * appear under the seeded `custom` category, their label decrypted; + * Returns the fully-resolved, ordered Category → Tag tree the mood-logging + * form renders as a pick-from-catalogue surface — for web AND iOS, in one + * read. The catalogue is global reference data (seeded by migration); + * layered per-user adjustments: + * - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys), + * label decrypted; + * - the user's own custom groups (`mood_tag_categories.user_id = me`, + * `customcat:` keys), label decrypted, `custom: true` on the DTO; + * - the per-user layout blob (`User.moodTagLayoutJson`): group display + * order + per-group tag placement. A CATALOGUE tag "moved" into a group + * is placement only — its `categoryId` never changes, and entry writes + * are key-based, so no grouping change can break `tagKeys` / + * `ratedFactors` ingestion; * - catalogue tags the user hid (`mood_tag_hidden`) are omitted — unless - * `?include=hidden` is set, in which case they're returned with - * `hidden: true` so the management screen can toggle them back on. + * `include` contains `hidden`, in which case they return `hidden: true`. + * + * `?include=` is a comma list (v1.17.0; the bare v1.13 `hidden` still + * works): `hidden` (hidden catalogue tags), `archived` (the user's own + * inactive custom tags, `archived: true`), `usage` (per-tag `usageCount` + * over the user's live entries — management read only). When any flag is + * set the user's own EMPTY groups are kept (the management screen needs + * them); the plain picker read keeps dropping empty categories. * * Every tag carries `custom: boolean` + `label: string | null`: render * `label` for a custom tag, resolve `labelKey` against the locale for a - * catalogue tag. Backward-compatible — a client that ignores the new fields - * just sees the same categories → tags shape. + * catalogue tag. Backward-compatible — a v1.13 client ignoring the new + * fields sees the same tree, now per-user ordered. */ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const includeHidden = - request.nextUrl.searchParams.get("include") === "hidden"; - const [categories, tags, hiddenRows] = await Promise.all([ - prisma.moodTagCategory.findMany({ - where: { isActive: true }, - orderBy: { sortOrder: "asc" }, - select: { id: true, key: true, labelKey: true, icon: true }, - }), - // Catalogue tags (user_id NULL) + this user's own custom tags. Another - // user's customs are excluded by construction. - prisma.moodTag.findMany({ - where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] }, - orderBy: { sortOrder: "asc" }, - select: { - id: true, - categoryId: true, - key: true, - labelKey: true, - icon: true, - kind: true, - scaleMin: true, - scaleMax: true, - inverse: true, - userId: true, - labelEncrypted: true, - }, - }), - prisma.moodTagHidden.findMany({ - where: { userId: user.id }, - select: { moodTagId: true }, - }), - ]); + const includeFlags = new Set( + (request.nextUrl.searchParams.get("include") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + const includeHidden = includeFlags.has("hidden"); + const includeArchived = includeFlags.has("archived"); + const includeUsage = includeFlags.has("usage"); + const managementRead = includeFlags.size > 0; + + const [categories, tags, hiddenRows, layoutRow, usageRows] = + await Promise.all([ + // Seeded categories + this user's own custom groups. + prisma.moodTagCategory.findMany({ + where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] }, + orderBy: { sortOrder: "asc" }, + select: { + id: true, + key: true, + labelKey: true, + icon: true, + userId: true, + labelEncrypted: true, + }, + }), + // Catalogue tags (user_id NULL) + this user's own custom tags. Another + // user's customs are excluded by construction. `archived` lifts the + // isActive pin for the user's OWN rows only — retired catalogue tags + // (0126 precedent) never resurface. + prisma.moodTag.findMany({ + where: includeArchived + ? { OR: [{ userId: null, isActive: true }, { userId: user.id }] } + : { isActive: true, OR: [{ userId: null }, { userId: user.id }] }, + orderBy: { sortOrder: "asc" }, + select: { + id: true, + categoryId: true, + key: true, + labelKey: true, + icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, + isActive: true, + userId: true, + labelEncrypted: true, + }, + }), + prisma.moodTagHidden.findMany({ + where: { userId: user.id }, + select: { moodTagId: true }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { moodTagLayoutJson: true }, + }), + includeUsage + ? prisma.moodEntryTagLink.groupBy({ + by: ["moodTagId"], + _count: { _all: true }, + where: { moodEntry: { userId: user.id, deletedAt: null } }, + }) + : Promise.resolve( + [] as Array<{ moodTagId: string; _count: { _all: number } }>, + ), + ]); const hiddenIds = new Set(hiddenRows.map((r) => r.moodTagId)); + const usageByTagId = new Map( + usageRows.map((r) => [r.moodTagId, r._count._all]), + ); + const categoryKeyById = new Map(categories.map((c) => [c.id, c.key])); - const tagsByCategory = new Map(); - for (const t of tags) { + // Visible tag rows (global sortOrder preserved by the query). + const visibleTags = tags.filter((t) => { const isCustom = t.userId !== null; const isHidden = !isCustom && hiddenIds.has(t.id); - if (isHidden && !includeHidden) continue; - const list = tagsByCategory.get(t.categoryId) ?? []; - list.push(t); - tagsByCategory.set(t.categoryId, list); - } + if (isHidden && !includeHidden) return false; + return categoryKeyById.has(t.categoryId); + }); - const responseCategories = categories - .map((c) => { - const catTags = (tagsByCategory.get(c.id) ?? []).map((t) => { - const isCustom = t.userId !== null; - return { - key: t.key, - labelKey: isCustom ? null : t.labelKey, - label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null, - icon: t.icon, - kind: t.kind, - scaleMin: t.scaleMin, - scaleMax: t.scaleMax, - inverse: t.inverse, - custom: isCustom, - ...(includeHidden && !isCustom - ? { hidden: hiddenIds.has(t.id) } - : {}), - }; - }); + const layout = parseStoredMoodTagLayout(layoutRow?.moodTagLayoutJson); + const { orderedCategoryKeys, tagKeysByCategory } = resolveMoodTagPlacement({ + categoryKeysInSeededOrder: categories.map((c) => c.key), + tags: visibleTags.map((t) => ({ + key: t.key, + homeCategoryKey: categoryKeyById.get(t.categoryId) as string, + })), + layout, + }); + + const tagByKey = new Map(visibleTags.map((t) => [t.key, t])); + const categoryByKey = new Map(categories.map((c) => [c.key, c])); + + const responseCategories = orderedCategoryKeys + .map((categoryKey) => { + const c = categoryByKey.get(categoryKey); + if (!c) return null; + const isCustomCategory = c.userId !== null; + const catTags = (tagKeysByCategory.get(categoryKey) ?? []) + .map((tagKey) => tagByKey.get(tagKey)) + .filter((t) => t !== undefined) + .map((t) => { + const isCustom = t.userId !== null; + return { + key: t.key, + labelKey: isCustom ? null : t.labelKey, + label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null, + icon: t.icon, + kind: t.kind, + scaleMin: t.scaleMin, + scaleMax: t.scaleMax, + inverse: t.inverse, + custom: isCustom, + ...(includeHidden && !isCustom + ? { hidden: hiddenIds.has(t.id) } + : {}), + ...(includeArchived && isCustom + ? { archived: !t.isActive } + : {}), + ...(includeUsage + ? { usageCount: usageByTagId.get(t.id) ?? 0 } + : {}), + }; + }); return { key: c.key, - labelKey: c.labelKey, + labelKey: isCustomCategory ? null : c.labelKey, + label: isCustomCategory ? decryptCustomLabel(c.labelEncrypted) : null, icon: c.icon, + custom: isCustomCategory, tags: catTags, }; }) + .filter((c) => c !== null) // Drop a category with no visible tags (e.g. `custom` for a user with - // none yet) so the picker grid stays tight. - .filter((c) => c.tags.length > 0); + // none yet) so the picker grid stays tight — EXCEPT the user's own + // groups on a management read, which must surface even when empty. + .filter((c) => c.tags.length > 0 || (managementRead && c.custom)); annotate({ action: { name: "mood.tags.catalog.read" }, meta: { category_count: responseCategories.length, include_hidden: includeHidden, + include_archived: includeArchived, + include_usage: includeUsage, }, }); diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index f630e8338..31865a6d3 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useState } from "react"; +import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; @@ -9,7 +10,7 @@ import { MoodList } from "@/components/mood/mood-list"; import { Button } from "@/components/ui/button"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { Plus, Loader2 } from "lucide-react"; +import { Plus, Loader2, Wrench } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { useTranslations } from "@/lib/i18n/context"; @@ -66,10 +67,34 @@ export default function MoodPage() { {t("mood.subtitle")}

- +
+ {/* v1.17 — the wrench is the one customize entry point: it + links to /settings/mood, which owns the tag groups, custom + tags, visibility, and picker order. Same glyph, slot (left + of the add button) and responsive 44-px mobile tap floor + as the medications header. */} + + +
, api: ApiSection, diff --git a/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx b/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx new file mode 100644 index 000000000..cbdbe6c36 --- /dev/null +++ b/src/components/mood/__tests__/mood-tag-icon-picker.test.tsx @@ -0,0 +1,100 @@ +/** + * v1.17 — searchable icon picker over the shared curated catalog. + * The filter is a pure function (name + English keyword aids, + * case-insensitive, undrawable names excluded) so the search contract + * is pinned without DOM events; the SSR render covers the radiogroup + * grid, the selected-tile accent, and the localised search affordances. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + MOOD_TAG_ICON_CATALOG, + type MoodTagIconCatalogEntry, +} from "@/lib/mood/icon-catalog"; +import { isMoodTagIconName } from "../mood-tag-icons"; +import { MoodTagIconPicker, filterIconCatalog } from "../mood-tag-icon-picker"; + +describe("filterIconCatalog", () => { + it("returns the full drawable catalog for an empty query", () => { + const all = filterIconCatalog(MOOD_TAG_ICON_CATALOG, ""); + expect(all.length).toBeGreaterThan(40); + expect(all.every((entry) => isMoodTagIconName(entry.name))).toBe(true); + }); + + it("matches on the icon name, case-insensitive", () => { + const hits = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "dumbbell"); + expect(hits.map((entry) => entry.name)).toContain("Dumbbell"); + }); + + it("matches on keyword aids", () => { + const byKeyword = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "happy"); + expect(byKeyword.length).toBeGreaterThan(0); + expect( + byKeyword.some((entry) => + entry.keywords.some((k) => k.includes("happy")), + ) || + byKeyword.some((entry) => entry.name.toLowerCase().includes("happy")), + ).toBe(true); + }); + + it("returns nothing for a miss", () => { + expect(filterIconCatalog(MOOD_TAG_ICON_CATALOG, "zzzznope")).toEqual([]); + }); + + it("excludes catalog names the client bundle cannot draw", () => { + const withGhost: MoodTagIconCatalogEntry[] = [ + ...MOOD_TAG_ICON_CATALOG, + { name: "NotARealIcon", keywords: ["ghost"], group: "misc" }, + ]; + const filtered = filterIconCatalog(withGhost, ""); + expect(filtered.some((entry) => entry.name === "NotARealIcon")).toBe(false); + }); + + it("every shipped catalog entry resolves to a real glyph (allowlist ⊆ client map)", () => { + for (const entry of MOOD_TAG_ICON_CATALOG) { + expect( + isMoodTagIconName(entry.name), + `unmapped icon: ${entry.name}`, + ).toBe(true); + } + }); +}); + +describe(" — SSR", () => { + function render(value: string | null, locale: "en" | "de" = "en"): string { + return renderToStaticMarkup( + + {}} /> + , + ); + } + + it("renders the search input + one radio tile per drawable catalog entry", () => { + const html = render(null); + expect(html).toContain('type="search"'); + const tiles = html.match(/data-slot="mood-icon-tile"/g); + expect(tiles?.length ?? 0).toBe( + filterIconCatalog(MOOD_TAG_ICON_CATALOG, "").length, + ); + }); + + it("marks the current value with the selected accent + aria-checked", () => { + const html = render("Heart"); + expect(html).toMatch( + /data-icon="Heart"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-icon="Heart"/, + ); + }); + + it("resolves the German search placeholder", () => { + const html = render(null, "de"); + expect(html).toContain("Symbole durchsuchen…"); + }); + + it("renders localised group sub-headers", () => { + const html = render(null, "de"); + expect(html).toContain("Gefühle"); + expect(html).toContain("Aktivitäten"); + }); +}); diff --git a/src/components/mood/__tests__/mood-tag-picker-custom.test.tsx b/src/components/mood/__tests__/mood-tag-picker-custom.test.tsx new file mode 100644 index 000000000..835e99511 --- /dev/null +++ b/src/components/mood/__tests__/mood-tag-picker-custom.test.tsx @@ -0,0 +1,152 @@ +/** + * v1.17 — picker-side management-suite additions: + * - a custom tag renders its decrypted `label` (the v1.13 API field + * the web picker previously ignored — the raw `custom:` key + * used to paint through the `t()` fallback); + * - every group carries a trailing ghost "+" tile that opens the + * inline create sheet, plus a synthetic Custom bootstrap group when + * the plain read carries no custom node yet; + * - render order is exactly the server-resolved order (no client + * sorting). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +interface FixtureCatalog { + categories: unknown[]; +} + +const queryState: { catalog: FixtureCatalog | null } = { catalog: null }; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { + if ( + Array.isArray(queryKey) && + queryKey[0] === "mood-tag-catalog" && + queryKey.length === 1 + ) { + return { data: queryState.catalog, isLoading: false }; + } + return { data: null, isLoading: false }; + }, + useQueryClient: () => ({ + setQueryData: vi.fn(), + getQueryData: vi.fn(), + cancelQueries: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/hooks/use-is-mobile", () => ({ + useIsMobile: () => false, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, isLoading: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodTagPicker } from "../mood-tag-picker"; + +const CATALOG = { + categories: [ + { + key: "feelings", + labelKey: "mood.tagCategory.feelings", + label: null, + icon: "Smile", + custom: false, + tags: [ + { + key: "happy", + labelKey: "mood.tag.happy", + label: null, + icon: "Smile", + kind: "BINARY", + scaleMin: 0, + scaleMax: 1, + inverse: false, + custom: false, + }, + ], + }, + { + key: "custom", + labelKey: "mood.tagCategory.custom", + label: null, + icon: "SlidersHorizontal", + custom: false, + tags: [ + { + key: "custom:11111111-aaaa", + labelKey: "custom:11111111-aaaa", + label: "Gartenarbeit", + icon: "Leaf", + kind: "BINARY", + scaleMin: 0, + scaleMax: 1, + inverse: false, + custom: true, + }, + ], + }, + ], +}; + +function render(locale: "en" | "de" = "de"): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +beforeEach(() => { + queryState.catalog = CATALOG; +}); + +describe(" — custom labels + inline create", () => { + it("renders a custom tag by its decrypted label, never the raw key", () => { + const html = render(); + expect(html).toContain("Gartenarbeit"); + expect(html).not.toContain("custom:11111111-aaaa"); + }); + + it("keeps resolving catalogue tags through their i18n key", () => { + const html = render("en"); + expect(html).toContain("happy"); + expect(html).not.toContain("mood.tag.happy"); + }); + + it("renders a trailing + tile per group with the localised aria-label", () => { + const html = render(); + const tiles = html.match(/data-slot="mood-tag-add-tile"/g); + expect(tiles?.length ?? 0).toBe(2); // one per group + expect(html).toContain("Tag hinzufügen"); + }); + + it("synthesizes the Custom bootstrap group when the plain read has no custom node", () => { + queryState.catalog = { categories: [CATALOG.categories[0]] }; + const html = render(); + expect(html).toContain('data-slot="mood-tag-custom-bootstrap"'); + expect(html).toContain("Eigene"); // mood.tagCategory.custom (de) + }); + + it("does NOT synthesize the bootstrap group when the custom node exists", () => { + const html = render(); + expect(html).not.toContain('data-slot="mood-tag-custom-bootstrap"'); + }); + + it("renders groups and tags in exactly the server order", () => { + const html = render(); + expect(html.indexOf("Feelings") === -1).toBe(true); // de locale + const feelings = html.indexOf("Gefühle"); + const custom = html.indexOf("Gartenarbeit"); + expect(feelings).toBeGreaterThanOrEqual(0); + expect(custom).toBeGreaterThan(feelings); + }); +}); diff --git a/src/components/mood/manage/__tests__/catalog-helpers.test.ts b/src/components/mood/manage/__tests__/catalog-helpers.test.ts new file mode 100644 index 000000000..4e360af13 --- /dev/null +++ b/src/components/mood/manage/__tests__/catalog-helpers.test.ts @@ -0,0 +1,212 @@ +/** + * v1.17 — pure catalog-tree surgery behind the mood-tag management + * cards. The optimistic-update path applies these to the cached manage + * read before the PATCH/PUT settles, so their contracts (unknown keys + * tolerated, archived rows preserved, placement maps complete) are + * pinned here without any DOM. + */ +import { describe, expect, it } from "vitest"; + +import { + buildGroupOrder, + buildPlacements, + moveTagToGroup, + removeTag, + reorderGroupTags, + reorderGroups, + setTagArchived, + setTagHidden, + type ManageCatalog, + type ManageTag, +} from "../use-mood-tag-manage"; +import { + buildVisiblePlacements, + mergeBack, + visibleCatalog, +} from "../tag-manager-card"; +import { archivedTags } from "../archived-tags-card"; +import { moveGroupKey } from "../tag-groups-card"; +import { insertTagIntoCatalog } from "../tag-editor-sheet"; + +function tag(key: string, overrides: Partial = {}): ManageTag { + return { + key, + labelKey: `mood.tag.${key}`, + label: null, + icon: "Tag", + kind: "BINARY", + scaleMin: 0, + scaleMax: 1, + inverse: false, + custom: false, + ...overrides, + }; +} + +function catalog(): ManageCatalog { + return { + categories: [ + { + key: "feelings", + labelKey: "mood.tagCategory.feelings", + label: null, + icon: "Smile", + custom: false, + tags: [tag("happy"), tag("stressed", { hidden: true })], + }, + { + key: "customcat:g1", + labelKey: "customcat:g1", + label: "Garten", + icon: "Leaf", + custom: true, + tags: [ + tag("custom:1", { custom: true, label: "Gartenarbeit" }), + tag("custom:2", { + custom: true, + label: "Altes Tag", + archived: true, + usageCount: 5, + }), + ], + }, + ], + }; +} + +describe("setTagHidden / setTagArchived", () => { + it("flips only the addressed tag", () => { + const next = setTagHidden(catalog(), "happy", true); + expect(next.categories[0]!.tags[0]!.hidden).toBe(true); + expect(next.categories[0]!.tags[1]!.hidden).toBe(true); // untouched + expect(next.categories[1]!.tags[0]!.hidden).toBeUndefined(); + }); + + it("archive flag round-trips", () => { + const archived = setTagArchived(catalog(), "custom:1", true); + expect(archived.categories[1]!.tags[0]!.archived).toBe(true); + const restored = setTagArchived(archived, "custom:1", false); + expect(restored.categories[1]!.tags[0]!.archived).toBe(false); + }); +}); + +describe("removeTag", () => { + it("drops the row from its group and leaves everything else", () => { + const next = removeTag(catalog(), "custom:2"); + expect(next.categories[1]!.tags.map((t) => t.key)).toEqual(["custom:1"]); + expect(next.categories[0]!.tags).toHaveLength(2); + }); +}); + +describe("moveTagToGroup", () => { + it("appends the tag to the target group", () => { + const next = moveTagToGroup(catalog(), "happy", "customcat:g1"); + expect(next.categories[0]!.tags.map((t) => t.key)).toEqual(["stressed"]); + expect(next.categories[1]!.tags.map((t) => t.key)).toEqual([ + "custom:1", + "custom:2", + "happy", + ]); + }); + + it("is a no-op for an unknown tag key", () => { + const before = catalog(); + expect(moveTagToGroup(before, "nope", "customcat:g1")).toEqual(before); + }); +}); + +describe("reorderGroupTags / reorderGroups", () => { + it("applies the given order and keeps unknown keys last", () => { + const next = reorderGroupTags(catalog(), "feelings", [ + "stressed", + "ghost", + "happy", + ]); + expect(next.categories[0]!.tags.map((t) => t.key)).toEqual([ + "stressed", + "happy", + ]); + }); + + it("keeps unmentioned tags after the placed block", () => { + const next = reorderGroupTags(catalog(), "feelings", ["stressed"]); + expect(next.categories[0]!.tags.map((t) => t.key)).toEqual([ + "stressed", + "happy", + ]); + }); + + it("reorders groups and keeps unmentioned groups after the placed block", () => { + const next = reorderGroups(catalog(), ["customcat:g1"]); + expect(next.categories.map((c) => c.key)).toEqual([ + "customcat:g1", + "feelings", + ]); + }); +}); + +describe("buildPlacements / buildGroupOrder", () => { + it("emits the complete map in display order", () => { + expect(buildPlacements(catalog())).toEqual({ + feelings: ["happy", "stressed"], + "customcat:g1": ["custom:1", "custom:2"], + }); + expect(buildGroupOrder(catalog())).toEqual(["feelings", "customcat:g1"]); + }); +}); + +describe("visibleCatalog / archivedTags / buildVisiblePlacements", () => { + it("splits archived rows out of the visible projection", () => { + const visible = visibleCatalog(catalog()); + expect(visible.categories[1]!.tags.map((t) => t.key)).toEqual(["custom:1"]); + expect(archivedTags(catalog()).map((t) => t.key)).toEqual(["custom:2"]); + }); + + it("placement map of the visible tree excludes archived keys", () => { + expect(buildVisiblePlacements(catalog())).toEqual({ + feelings: ["happy", "stressed"], + "customcat:g1": ["custom:1"], + }); + }); +}); + +describe("mergeBack", () => { + it("re-attaches archived rows after a visible-tree edit", () => { + const full = catalog(); + const moved = moveTagToGroup(visibleCatalog(full), "happy", "customcat:g1"); + const merged = mergeBack(full, moved); + expect(merged.categories[1]!.tags.map((t) => t.key)).toEqual([ + "custom:1", + "happy", + "custom:2", + ]); + }); +}); + +describe("moveGroupKey", () => { + it("swaps within bounds and refuses to cross either end", () => { + expect(moveGroupKey(["a", "b", "c"], "b", -1)).toEqual(["b", "a", "c"]); + expect(moveGroupKey(["a", "b", "c"], "a", -1)).toEqual(["a", "b", "c"]); + expect(moveGroupKey(["a", "b", "c"], "c", 1)).toEqual(["a", "b", "c"]); + }); +}); + +describe("insertTagIntoCatalog", () => { + it("appends the fresh DTO to its group", () => { + const created = tag("custom:3", { custom: true, label: "Neu" }); + const next = insertTagIntoCatalog(catalog(), "customcat:g1", created); + expect(next.categories[1]!.tags.map((t) => t.key)).toEqual([ + "custom:1", + "custom:2", + "custom:3", + ]); + }); + + it("leaves the tree untouched when the group node is absent (empty group dropped by the plain read)", () => { + const before = catalog(); + const created = tag("custom:3", { custom: true }); + expect(insertTagIntoCatalog(before, "customcat:none", created)).toEqual( + before, + ); + }); +}); diff --git a/src/components/mood/manage/archived-tags-card.tsx b/src/components/mood/manage/archived-tags-card.tsx new file mode 100644 index 000000000..989fd401f --- /dev/null +++ b/src/components/mood/manage/archived-tags-card.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ArchiveRestore, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { moodTagIcon } from "../mood-tag-icons"; +import { + invalidateMoodTagCaches, + removeTag, + setTagArchived, + snapshotManageCache, + tagDisplayName, + updateManageCache, + type ManageCatalog, + type ManageTag, +} from "./use-mood-tag-manage"; + +/** + * v1.17 — archived custom tags for `/settings/mood`. Archiving keeps + * every historical entry resolving the tag's label (the 0126 retire + * precedent: reads join by id without the active filter) — the card + * copy says so explicitly. Restore flips `isActive` back (the server + * re-checks the 50-tag cap). The hard delete sits behind an explicit + * confirm dialog that names the number of past entries the purge will + * strip the tag from; the FK cascade removes those links for good. + */ + +interface ArchivedTagsCardProps { + catalog: ManageCatalog; +} + +/** Every archived custom tag across the tree, in tree order. */ +export function archivedTags(catalog: ManageCatalog): ManageTag[] { + return catalog.categories.flatMap((category) => + category.tags.filter((tag) => tag.archived === true), + ); +} + +export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [purgeTag, setPurgeTag] = useState(null); + + const archived = archivedTags(catalog); + + async function restore(tag: ManageTag) { + const rollback = await snapshotManageCache(queryClient); + updateManageCache(queryClient, (c) => setTagArchived(c, tag.key, false)); + try { + await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, { + isActive: true, + }); + toast.success(t("common.saved")); + } catch (err) { + rollback(); + if (err instanceof ApiError && err.status === 422) { + toast.error(t("mood.manage.tagLimitReached")); + } else { + toast.error(err instanceof ApiError ? err.message : t("common.error")); + } + } finally { + void invalidateMoodTagCaches(queryClient); + } + } + + async function purge(tag: ManageTag) { + const rollback = await snapshotManageCache(queryClient); + updateManageCache(queryClient, (c) => removeTag(c, tag.key)); + try { + await apiDelete( + `/api/mood/tags/custom/${encodeURIComponent(tag.key)}?purge=true`, + ); + toast.success(t("mood.manage.purged")); + } catch (err) { + rollback(); + toast.error(err instanceof ApiError ? err.message : t("common.error")); + } finally { + void invalidateMoodTagCaches(queryClient); + } + } + + return ( +
+

+ {t("mood.manage.archivedDescription")} +

+ + {archived.length === 0 ? ( +

+ {t("mood.manage.archivedEmpty")} +

+ ) : ( +
+ {archived.map((tag) => { + const Icon = moodTagIcon(tag.icon); + const name = tagDisplayName(tag, t); + return ( +
+
+ ); + })} +
+ )} + + { + if (!open) setPurgeTag(null); + }} + > + + + {t("mood.manage.purgeTitle")} + + {t("mood.manage.purgeBody", { + count: String(purgeTag?.usageCount ?? 0), + })} + + + + {t("common.cancel")} + { + const tag = purgeTag; + setPurgeTag(null); + if (tag) void purge(tag); + }} + > + {t("mood.manage.purgeAction")} + + + + +
+ ); +} diff --git a/src/components/mood/manage/tag-editor-sheet.tsx b/src/components/mood/manage/tag-editor-sheet.tsx new file mode 100644 index 000000000..bb678c8ac --- /dev/null +++ b/src/components/mood/manage/tag-editor-sheet.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useId, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { MoodTagIconPicker } from "../mood-tag-icon-picker"; +import { + invalidateMoodTagCaches, + type ManageCatalog, + type ManageTag, +} from "./use-mood-tag-manage"; + +/** + * v1.17 — create / edit sheet for a custom mood tag, shared by the + * settings tag manager and the picker's inline "+" tile. Label (40-char + * counter, mirrors the server Zod bound), curated icon picker, group + * select (any seeded category or one of the caller's own groups — the + * widened `categoryKey` contract). + * + * Create POSTs `/api/mood/tags/custom` and inserts the 201 DTO into the + * picker cache immediately (so an inline create lands in the form + * without a refetch round-trip), then invalidates the + * `["mood-tag-catalog"]` prefix. Edit PATCHes `/custom/[key]` + * (label / icon / categoryKey move). + */ + +const LABEL_MAX_LENGTH = 40; + +export interface TagEditorGroupOption { + key: string; + name: string; +} + +export interface TagEditorSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Group choices: every category of the effective tree, display-named. */ + groups: TagEditorGroupOption[]; + /** Preselected group for create mode (inline "+" tile passes its group). */ + initialGroupKey?: string; + /** Present → edit mode for this custom tag. */ + tag?: { + key: string; + label: string; + icon: string | null; + groupKey: string; + }; + /** Fires with the server DTO after a successful create. */ + onCreated?: (tag: ManageTag) => void; +} + +/** Append a fresh tag DTO to its group in a cached catalog tree. */ +export function insertTagIntoCatalog( + catalog: ManageCatalog, + groupKey: string, + tag: ManageTag, +): ManageCatalog { + let inserted = false; + const categories = catalog.categories.map((category) => { + if (category.key !== groupKey) return category; + inserted = true; + return { ...category, tags: [...category.tags, tag] }; + }); + // The plain picker read drops empty categories, so a first tag in a + // fresh group has no node to land in — the prefix invalidation that + // follows fetches the tree with the group present. + return inserted ? { categories } : catalog; +} + +export function TagEditorSheet({ + open, + onOpenChange, + groups, + initialGroupKey, + tag, + onCreated, +}: TagEditorSheetProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const formId = useId(); + const labelInputId = useId(); + const groupSelectId = useId(); + + // Hosts mount this sheet conditionally (`{open && }`), + // so the field state seeds fresh from props on every open — no + // re-seeding effect needed. + const isEdit = tag !== undefined; + const [label, setLabel] = useState(tag?.label ?? ""); + const [icon, setIcon] = useState(tag?.icon ?? "Tag"); + const [groupKey, setGroupKey] = useState( + tag?.groupKey ?? initialGroupKey ?? "custom", + ); + const [saving, setSaving] = useState(false); + + const trimmed = label.trim(); + const valid = trimmed.length > 0 && trimmed.length <= LABEL_MAX_LENGTH; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!valid || saving) return; + setSaving(true); + try { + if (isEdit) { + await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, { + ...(trimmed !== tag.label ? { label: trimmed } : {}), + ...(icon !== tag.icon ? { icon } : {}), + ...(groupKey !== tag.groupKey ? { categoryKey: groupKey } : {}), + }); + toast.success(t("common.saved")); + } else { + const created = await apiPost("/api/mood/tags/custom", { + label: trimmed, + icon, + categoryKey: groupKey, + }); + // Land the fresh tag in the picker cache immediately so the + // inline-create flow pre-selects it without a refetch beat. + if (created) { + queryClient.setQueryData( + queryKeys.moodTagCatalog(), + (current) => + current + ? insertTagIntoCatalog(current, groupKey, created) + : current, + ); + onCreated?.(created); + } + toast.success(t("common.saved")); + } + onOpenChange(false); + void invalidateMoodTagCaches(queryClient); + } catch (err) { + if (err instanceof ApiError && err.status === 422) { + toast.error(t("mood.manage.tagLimitReached")); + } else { + toast.error(err instanceof ApiError ? err.message : t("common.error")); + } + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+
+
+ + = LABEL_MAX_LENGTH + ? "text-destructive" + : "text-muted-foreground" + }`} + aria-live="polite" + > + {t("mood.noteCharCount", { + count: String(label.length), + max: String(LABEL_MAX_LENGTH), + })} + +
+ setLabel(e.target.value)} + maxLength={LABEL_MAX_LENGTH} + autoFocus + autoComplete="off" + autoCapitalize="sentences" + enterKeyHint="done" + required + aria-required="true" + /> +
+ +
+ + setGroupKey(e.target.value)} + > + {groups.map((group) => ( + + ))} + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/mood/manage/tag-groups-card.tsx b/src/components/mood/manage/tag-groups-card.tsx new file mode 100644 index 000000000..bed35e2b5 --- /dev/null +++ b/src/components/mood/manage/tag-groups-card.tsx @@ -0,0 +1,603 @@ +"use client"; + +import { useId, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + ArrowDown, + ArrowUp, + FolderPlus, + GripVertical, + Loader2, + MoreHorizontal, + Pencil, + Trash2, + type LucideIcon, +} from "lucide-react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { + ApiError, + apiDelete, + apiPatch, + apiPost, + apiPut, +} from "@/lib/api/api-fetch"; +import { reorderById } from "@/lib/insights-layout-reorder"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { useTranslations } from "@/lib/i18n/context"; +import { moodTagIcon } from "../mood-tag-icons"; +import { MoodTagIconPicker } from "../mood-tag-icon-picker"; +import { + buildGroupOrder, + categoryDisplayName, + invalidateMoodTagCaches, + reorderGroups, + snapshotManageCache, + updateManageCache, + type ManageCatalog, +} from "./use-mood-tag-manage"; + +/** + * v1.17 — group management card for `/settings/mood`. Lists every + * group of the effective tree (seeded + own) in picker order and reuses + * the medication-order-editor interaction grammar: a drag handle + * (@dnd-kit, pointer + keyboard sensors) buffers a draft flushed by an + * explicit Save, while the per-row arrow buttons write through + * immediately (one optimistic layout PUT per press). Own groups carry a + * kebab with rename (label + icon) and delete; seeded groups reorder + * only — they are shared reference data. + * + * Group delete is non-destructive by contract: the server re-homes the + * group's custom tags back to the seeded Custom group and catalogue + * placements evaporate to their original category; the confirm dialog + * says exactly that. + */ + +const GROUP_LABEL_MAX = 40; + +interface TagGroupsCardProps { + catalog: ManageCatalog; +} + +/** Arrow move bounded to the list ends (same contract as medications). */ +export function moveGroupKey( + keys: readonly string[], + key: string, + delta: -1 | 1, +): string[] { + const idx = keys.indexOf(key); + const target = idx + delta; + if (idx < 0 || target < 0 || target >= keys.length) return [...keys]; + return reorderById( + keys.map((id, order) => ({ id, order })), + key, + keys[target], + ).map((row) => row.id); +} + +export function TagGroupsCard({ catalog }: TagGroupsCardProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const dragHintId = useId(); + + const [draftOrder, setDraftOrder] = useState(null); + const [saving, setSaving] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + const [editingKey, setEditingKey] = useState(null); + const [deleteKey, setDeleteKey] = useState(null); + + const serverOrder = buildGroupOrder(catalog); + const order = draftOrder ?? serverOrder; + const byKey = new Map(catalog.categories.map((c) => [c.key, c])); + const dirty = draftOrder !== null; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + async function putGroupOrder(nextOrder: string[]): Promise { + const rollback = await snapshotManageCache(queryClient); + updateManageCache(queryClient, (current) => + reorderGroups(current, nextOrder), + ); + try { + await apiPut("/api/mood/tags/layout", { groupOrder: nextOrder }); + toast.success(t("mood.manage.orderSaved")); + return true; + } catch (err) { + rollback(); + toast.error(err instanceof ApiError ? err.message : t("common.error")); + return false; + } finally { + void invalidateMoodTagCaches(queryClient); + } + } + + /** Arrow press: immediate optimistic PUT unless a drag draft is open. */ + function moveByArrow(key: string, delta: -1 | 1) { + const next = moveGroupKey(order, key, delta); + if (next.every((v, i) => v === order[i])) return; + if (dirty) { + setDraftOrder(next); + } else { + void putGroupOrder(next); + } + } + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + setDraftOrder( + reorderById( + order.map((id, idx) => ({ id, order: idx })), + String(active.id), + String(over.id), + ).map((row) => row.id), + ); + } + + async function saveDraft() { + if (!draftOrder) return; + setSaving(true); + const ok = await putGroupOrder(draftOrder); + setSaving(false); + if (ok) setDraftOrder(null); + } + + const editing = editingKey ? byKey.get(editingKey) : undefined; + const deleting = deleteKey ? byKey.get(deleteKey) : undefined; + const customGroupCount = catalog.categories.filter((c) => c.custom).length; + + return ( +
+
+

+ {t("mood.manage.groupsDescription")} +

+ +
+ + {customGroupCount === 0 && ( +

+ {t("mood.manage.groupEmpty")} +

+ )} + + + +
+ {order.map((key, index) => { + const group = byKey.get(key); + if (!group) return null; + return ( + { + setEditingKey(key); + setEditorOpen(true); + }} + onDelete={() => setDeleteKey(key)} + /> + ); + })} +
+
+
+

+ {t("dashboard.dragHandleHint")} +

+ + {dirty && ( +
+ + +
+ )} + + {editorOpen && ( + { + if (!next) setEditorOpen(false); + }} + group={ + editing && editing.custom + ? { + key: editing.key, + label: categoryDisplayName(editing, t), + icon: editing.icon, + } + : undefined + } + /> + )} + + { + if (!open) setDeleteKey(null); + }} + > + + + + {t("mood.manage.groupDeleteTitle")} + + + {t("mood.manage.groupDeleteBody")} + + + + {t("common.cancel")} + { + const key = deleting?.key; + setDeleteKey(null); + if (!key) return; + void (async () => { + try { + await apiDelete( + `/api/mood/tags/groups/${encodeURIComponent(key)}`, + ); + toast.success(t("mood.manage.groupDeleted")); + } catch (err) { + toast.error( + err instanceof ApiError ? err.message : t("common.error"), + ); + } finally { + void invalidateMoodTagCaches(queryClient); + } + })(); + }} + > + {t("common.delete")} + + + + +
+ ); +} + +interface SortableGroupRowProps { + groupKey: string; + name: string; + /** Resolved by the host's map callback (react-hooks/static-components). */ + Icon: LucideIcon; + custom: boolean; + tagCount: number; + index: number; + total: number; + dragHintId: string; + disabled: boolean; + onMove: (key: string, delta: -1 | 1) => void; + onEdit: () => void; + onDelete: () => void; +} + +function SortableGroupRow({ + groupKey, + name, + Icon, + custom, + tagCount, + index, + total, + dragHintId, + disabled, + onMove, + onEdit, + onDelete, +}: SortableGroupRowProps) { + const { t } = useTranslations(); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: groupKey }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: prefersReducedMotion() ? "none" : transition, + }; + + return ( +
+ +
+ ); +} + +interface GroupEditorSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Present → rename mode for this custom group. */ + group?: { key: string; label: string; icon: string | null }; +} + +function GroupEditorSheet({ + open, + onOpenChange, + group, +}: GroupEditorSheetProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const formId = useId(); + const labelInputId = useId(); + + // The host mounts this sheet conditionally, so the field state seeds + // fresh from props on every open — no re-seeding effect needed. + const isEdit = group !== undefined; + const [label, setLabel] = useState(group?.label ?? ""); + const [icon, setIcon] = useState(group?.icon ?? "Tag"); + const [saving, setSaving] = useState(false); + + const trimmed = label.trim(); + const valid = trimmed.length > 0 && trimmed.length <= GROUP_LABEL_MAX; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!valid || saving) return; + setSaving(true); + try { + if (isEdit) { + await apiPatch( + `/api/mood/tags/groups/${encodeURIComponent(group.key)}`, + { + ...(trimmed !== group.label ? { label: trimmed } : {}), + ...(icon !== group.icon ? { icon } : {}), + }, + ); + toast.success(t("mood.manage.groupRenamed")); + } else { + await apiPost("/api/mood/tags/groups", { label: trimmed, icon }); + toast.success(t("mood.manage.groupCreated")); + } + onOpenChange(false); + void invalidateMoodTagCaches(queryClient); + } catch (err) { + if (err instanceof ApiError && err.status === 422) { + toast.error(t("mood.manage.groupLimitReached")); + } else { + toast.error(err instanceof ApiError ? err.message : t("common.error")); + } + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+
+
+ + = GROUP_LABEL_MAX + ? "text-destructive" + : "text-muted-foreground" + }`} + aria-live="polite" + > + {t("mood.noteCharCount", { + count: String(label.length), + max: String(GROUP_LABEL_MAX), + })} + +
+ setLabel(e.target.value)} + maxLength={GROUP_LABEL_MAX} + autoFocus + autoComplete="off" + autoCapitalize="sentences" + enterKeyHint="done" + required + aria-required="true" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx new file mode 100644 index 000000000..1e7a25fa6 --- /dev/null +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -0,0 +1,641 @@ +"use client"; + +import { useId, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Archive, + ArrowDown, + ArrowUp, + Eye, + EyeOff, + FolderInput, + GripVertical, + Loader2, + MoreHorizontal, + Pencil, + Plus, + type LucideIcon, +} from "lucide-react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch"; +import { reorderById } from "@/lib/insights-layout-reorder"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { useTranslations } from "@/lib/i18n/context"; +import { moodTagIcon } from "../mood-tag-icons"; +import { TagEditorSheet } from "./tag-editor-sheet"; +import { + categoryDisplayName, + invalidateMoodTagCaches, + moveTagToGroup, + reorderGroupTags, + setTagArchived, + setTagHidden, + snapshotManageCache, + tagDisplayName, + updateManageCache, + type ManageCatalog, + type ManageCategory, + type ManageTag, +} from "./use-mood-tag-manage"; + +/** + * v1.17 — per-group tag management for `/settings/mood`. Mirrors the + * picker's grouping; every row carries: icon, label (custom label first, + * i18n key second), a scale badge for RATED factors, the historical + * usage count, an eye toggle (catalogue tag → per-user hide via + * `PUT /[key]/hidden`; custom tag → archive via `PATCH … isActive`), + * and a kebab with edit (custom only), move-to-group, and archive. + * + * Ordering follows the medication-order-editor grammar: drag buffers a + * draft (explicit Save, one layout PUT), arrows write through + * immediately (optimistic, rollback on error). Moving a catalogue tag + * between groups is a per-user PLACEMENT (layout blob) — the shared + * catalogue row itself never changes group; a custom tag moves for real + * (`categoryKey` PATCH) with the placement map updated alongside so the + * resolved order stays deterministic. + */ + +interface TagManagerCardProps { + catalog: ManageCatalog; +} + +/** The card manages visible (non-archived) tags; archived rows live in the archived card. */ +export function visibleCatalog(catalog: ManageCatalog): ManageCatalog { + return { + categories: catalog.categories.map((category) => ({ + ...category, + tags: category.tags.filter((tag) => tag.archived !== true), + })), + }; +} + +/** Placement map of the visible tree: every group → its visible tag keys in order. */ +export function buildVisiblePlacements( + catalog: ManageCatalog, +): Record { + const placements: Record = {}; + for (const category of visibleCatalog(catalog).categories) { + placements[category.key] = category.tags.map((tag) => tag.key); + } + return placements; +} + +export function TagManagerCard({ catalog }: TagManagerCardProps) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const dragHintId = useId(); + + const [drafts, setDrafts] = useState>({}); + const [saving, setSaving] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + const [editorTag, setEditorTag] = useState< + | { key: string; label: string; icon: string | null; groupKey: string } + | undefined + >(undefined); + + const visible = visibleCatalog(catalog); + const dirty = Object.keys(drafts).length > 0; + + const groupOptions = visible.categories.map((category) => ({ + key: category.key, + name: categoryDisplayName(category, t), + })); + + function orderedKeys(category: ManageCategory): string[] { + return drafts[category.key] ?? category.tags.map((tag) => tag.key); + } + + /** Apply current drafts onto the visible tree, then PUT the placement map. */ + async function putPlacements(nextCatalog: ManageCatalog): Promise { + const rollback = await snapshotManageCache(queryClient); + updateManageCache(queryClient, () => mergeBack(catalog, nextCatalog)); + try { + await apiPut("/api/mood/tags/layout", { + placements: buildVisiblePlacements(nextCatalog), + }); + toast.success(t("mood.manage.orderSaved")); + return true; + } catch (err) { + rollback(); + toast.error(err instanceof ApiError ? err.message : t("common.error")); + return false; + } finally { + void invalidateMoodTagCaches(queryClient); + } + } + + /** Arrow press: immediate optimistic PUT unless a drag session is open. */ + function moveByArrow(groupKey: string, tagKey: string, delta: -1 | 1) { + const category = visible.categories.find((c) => c.key === groupKey); + if (!category) return; + const keys = orderedKeys(category); + const idx = keys.indexOf(tagKey); + const target = idx + delta; + if (idx < 0 || target < 0 || target >= keys.length) return; + const next = reorderById( + keys.map((id, order) => ({ id, order })), + tagKey, + keys[target], + ).map((row) => row.id); + if (dirty) { + setDrafts((prev) => ({ ...prev, [groupKey]: next })); + } else { + void putPlacements(reorderGroupTags(visible, groupKey, next)); + } + } + + function handleDragEnd(groupKey: string, event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const category = visible.categories.find((c) => c.key === groupKey); + if (!category) return; + const keys = orderedKeys(category); + const next = reorderById( + keys.map((id, order) => ({ id, order })), + String(active.id), + String(over.id), + ).map((row) => row.id); + setDrafts((prev) => ({ ...prev, [groupKey]: next })); + } + + async function saveDrafts() { + setSaving(true); + let next = visible; + for (const [groupKey, keys] of Object.entries(drafts)) { + next = reorderGroupTags(next, groupKey, keys); + } + const ok = await putPlacements(next); + setSaving(false); + if (ok) setDrafts({}); + } + + /** Eye toggle — catalogue hide/show or custom archive. */ + async function toggleVisibility(tag: ManageTag) { + const rollback = await snapshotManageCache(queryClient); + try { + if (tag.custom) { + updateManageCache(queryClient, (c) => setTagArchived(c, tag.key, true)); + await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, { + isActive: false, + }); + } else { + const nextHidden = tag.hidden !== true; + updateManageCache(queryClient, (c) => + setTagHidden(c, tag.key, nextHidden), + ); + await apiPut(`/api/mood/tags/${encodeURIComponent(tag.key)}/hidden`, { + hidden: nextHidden, + }); + } + } catch (err) { + rollback(); + toast.error(err instanceof ApiError ? err.message : t("common.error")); + } finally { + void invalidateMoodTagCaches(queryClient); + } + } + + /** Move a tag to another group (custom: relational; catalogue: placement). */ + async function moveToGroup(tag: ManageTag, targetGroupKey: string) { + const moved = moveTagToGroup(visible, tag.key, targetGroupKey); + const rollback = await snapshotManageCache(queryClient); + updateManageCache(queryClient, () => mergeBack(catalog, moved)); + try { + if (tag.custom) { + await apiPatch(`/api/mood/tags/custom/${encodeURIComponent(tag.key)}`, { + categoryKey: targetGroupKey, + }); + } + // Both kinds pin the resulting per-group order in the layout blob + // so the resolved tree matches what the user just saw happen. + await apiPut("/api/mood/tags/layout", { + placements: buildVisiblePlacements(moved), + }); + toast.success(t("common.saved")); + } catch (err) { + rollback(); + toast.error(err instanceof ApiError ? err.message : t("common.error")); + } finally { + void invalidateMoodTagCaches(queryClient); + } + } + + return ( +
+
+

+ {t("mood.manage.tagsDescription")} +

+ +
+ + {visible.categories.length === 0 && ( +

{t("common.noData")}

+ )} + + {visible.categories.map((category) => { + const keys = orderedKeys(category); + const byKey = new Map(category.tags.map((tag) => [tag.key, tag])); + return ( + void toggleVisibility(tag)} + onMoveToGroup={(tag, target) => void moveToGroup(tag, target)} + onEdit={(tag) => { + setEditorTag({ + key: tag.key, + label: tagDisplayName(tag, t), + icon: tag.icon, + groupKey: category.key, + }); + setEditorOpen(true); + }} + /> + ); + })} + +

+ {t("dashboard.dragHandleHint")} +

+ + {dirty && ( +
+ + +
+ )} + + {editorOpen && ( + { + if (!next) setEditorOpen(false); + }} + groups={groupOptions} + tag={editorTag} + /> + )} +
+ ); +} + +/** + * Write an updated VISIBLE tree back over the full manage tree without + * losing the archived rows the visible projection filtered out. + */ +export function mergeBack( + full: ManageCatalog, + nextVisible: ManageCatalog, +): ManageCatalog { + const archivedByGroup = new Map(); + for (const category of full.categories) { + archivedByGroup.set( + category.key, + category.tags.filter((tag) => tag.archived === true), + ); + } + return { + categories: nextVisible.categories.map((category) => ({ + ...category, + tags: [...category.tags, ...(archivedByGroup.get(category.key) ?? [])], + })), + }; +} + +interface TagGroupSectionProps { + heading: string; + /** Resolved by the host's map callback (react-hooks/static-components). */ + HeadingIcon: LucideIcon; + groupKey: string; + keys: string[]; + byKey: ReadonlyMap; + dragHintId: string; + disabled: boolean; + groupOptions: { key: string; name: string }[]; + onDragEnd: (groupKey: string, event: DragEndEvent) => void; + onMove: (groupKey: string, tagKey: string, delta: -1 | 1) => void; + onToggleVisibility: (tag: ManageTag) => void; + onMoveToGroup: (tag: ManageTag, targetGroupKey: string) => void; + onEdit: (tag: ManageTag) => void; +} + +function TagGroupSection({ + heading, + HeadingIcon, + groupKey, + keys, + byKey, + dragHintId, + disabled, + groupOptions, + onDragEnd, + onMove, + onToggleVisibility, + onMoveToGroup, + onEdit, +}: TagGroupSectionProps) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const rows = keys + .map((key) => byKey.get(key)) + .filter((tag): tag is ManageTag => tag !== undefined); + + return ( +
+
+
+ onDragEnd(groupKey, event)} + > + + {rows.map((tag, index) => ( + + ))} + + +
+ ); +} + +interface SortableTagRowProps { + tag: ManageTag; + /** Resolved by the host's map callback (react-hooks/static-components). */ + Icon: LucideIcon; + groupKey: string; + index: number; + total: number; + dragHintId: string; + disabled: boolean; + groupOptions: { key: string; name: string }[]; + onMove: (groupKey: string, tagKey: string, delta: -1 | 1) => void; + onToggleVisibility: (tag: ManageTag) => void; + onMoveToGroup: (tag: ManageTag, targetGroupKey: string) => void; + onEdit: (tag: ManageTag) => void; +} + +function SortableTagRow({ + tag, + Icon, + groupKey, + index, + total, + dragHintId, + disabled, + groupOptions, + onMove, + onToggleVisibility, + onMoveToGroup, + onEdit, +}: SortableTagRowProps) { + const { t } = useTranslations(); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: tag.key }); + const name = tagDisplayName(tag, t); + const isHidden = tag.hidden === true; + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: prefersReducedMotion() ? "none" : transition, + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/mood/manage/use-mood-tag-manage.ts b/src/components/mood/manage/use-mood-tag-manage.ts new file mode 100644 index 000000000..fa179b887 --- /dev/null +++ b/src/components/mood/manage/use-mood-tag-manage.ts @@ -0,0 +1,279 @@ +"use client"; + +import { useQuery, type QueryClient } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.17 — shared data layer for the mood-tag management surface + * (`/settings/mood`). One catalog read + one layout read feed every + * card; both fire in parallel at section mount so the section never + * waterfalls. + * + * Wire contract (frozen for the v1.17 management suite): + * - `GET /api/mood/tags?include=hidden,archived,usage` — the fully + * resolved per-user Category → Tag tree, including hidden catalogue + * tags (`hidden: true`), the caller's archived custom tags + * (`archived: true`), per-tag historical link counts + * (`usageCount`), the caller's own custom groups (`custom: true` + * on the category) and empty own groups (kept when an `include` + * flag is set, dropped on the plain picker read). + * - `GET /api/mood/tags/layout` — the per-user presentation blob + * (`groupOrder` + `placements`), merged over the seeded defaults + * server-side. Display-only; a PUT is preserve-when-absent. + * + * Cache discipline: the manage read rides under the + * `["mood-tag-catalog"]` prefix (see `queryKeys.moodTagManage`), so a + * single prefix invalidation after any management mutation refreshes + * both this read and the picker's plain read. + */ + +export interface ManageTag { + key: string; + labelKey: string; + /** Decrypted custom label; null/absent for catalogue tags. */ + label?: string | null; + icon: string | null; + kind: "BINARY" | "RATED"; + scaleMin: number; + scaleMax: number; + inverse: boolean; + custom?: boolean; + /** Catalogue tag hidden for this user (`mood_tag_hidden`). */ + hidden?: boolean; + /** Custom tag soft-deactivated (history intact). */ + archived?: boolean; + /** Historical entry-link count; present on the `usage` include. */ + usageCount?: number; +} + +export interface ManageCategory { + key: string; + labelKey: string; + /** Decrypted custom group label; null/absent for seeded categories. */ + label?: string | null; + icon: string | null; + custom?: boolean; + tags: ManageTag[]; +} + +export interface ManageCatalog { + categories: ManageCategory[]; +} + +export interface MoodTagLayout { + groupOrder?: string[]; + placements?: Record; +} + +/** Per-user ceilings — mirror the server's 422 caps for friendly copy. */ +export const MAX_CUSTOM_TAGS = 50; +export const MAX_CUSTOM_GROUPS = 12; + +export function useMoodTagManage(enabled: boolean) { + return useQuery({ + queryKey: queryKeys.moodTagManage(), + queryFn: async () => + apiGet("/api/mood/tags?include=hidden,archived,usage"), + enabled, + }); +} + +export function useMoodTagLayout(enabled: boolean) { + return useQuery({ + queryKey: queryKeys.moodTagLayout(), + queryFn: async () => apiGet("/api/mood/tags/layout"), + enabled, + }); +} + +/** + * Invalidate every mood-tag read after a management mutation: the + * `["mood-tag-catalog"]` prefix catches both the picker read and the + * manage read; the layout key rides along because group/order writes + * change the resolved tree AND the blob. + */ +export function invalidateMoodTagCaches( + queryClient: QueryClient, +): Promise { + return Promise.allSettled([ + queryClient.invalidateQueries({ queryKey: queryKeys.moodTagCatalog() }), + queryClient.invalidateQueries({ queryKey: queryKeys.moodTagLayout() }), + ]); +} + +/** + * Cancel in-flight catalog fetches and snapshot the manage cache so an + * optimistic update can roll back on error. Returns the restore thunk. + */ +export async function snapshotManageCache( + queryClient: QueryClient, +): Promise<() => void> { + await queryClient.cancelQueries({ queryKey: queryKeys.moodTagCatalog() }); + const manage = queryClient.getQueryData( + queryKeys.moodTagManage(), + ); + return () => { + queryClient.setQueryData(queryKeys.moodTagManage(), manage); + }; +} + +/** Apply a pure updater to the manage cache (no-op when not yet loaded). */ +export function updateManageCache( + queryClient: QueryClient, + updater: (catalog: ManageCatalog) => ManageCatalog, +): void { + queryClient.setQueryData( + queryKeys.moodTagManage(), + (current) => (current ? updater(current) : current), + ); +} + +/* ------------------------------------------------------------------ */ +/* Pure catalog-tree surgery — exported for unit tests. */ +/* ------------------------------------------------------------------ */ + +/** Display name for a category: custom label first, i18n key second. */ +export function categoryDisplayName( + category: Pick, + t: (key: string) => string, +): string { + return category.label ?? t(category.labelKey); +} + +/** Display name for a tag: custom label first, i18n key second. */ +export function tagDisplayName( + tag: Pick, + t: (key: string) => string, +): string { + return tag.label ?? t(tag.labelKey); +} + +/** Flip a tag's `hidden` flag in place (catalogue eye-toggle). */ +export function setTagHidden( + catalog: ManageCatalog, + tagKey: string, + hidden: boolean, +): ManageCatalog { + return mapTag(catalog, tagKey, (tag) => ({ ...tag, hidden })); +} + +/** Flip a custom tag's `archived` flag in place (archive / restore). */ +export function setTagArchived( + catalog: ManageCatalog, + tagKey: string, + archived: boolean, +): ManageCatalog { + return mapTag(catalog, tagKey, (tag) => ({ ...tag, archived })); +} + +/** Remove a tag from the tree entirely (purge). */ +export function removeTag( + catalog: ManageCatalog, + tagKey: string, +): ManageCatalog { + return { + categories: catalog.categories.map((category) => ({ + ...category, + tags: category.tags.filter((tag) => tag.key !== tagKey), + })), + }; +} + +/** Move a tag to another group, appended after that group's tags. */ +export function moveTagToGroup( + catalog: ManageCatalog, + tagKey: string, + groupKey: string, +): ManageCatalog { + let moved: ManageTag | undefined; + const stripped = catalog.categories.map((category) => { + const hit = category.tags.find((tag) => tag.key === tagKey); + if (hit) moved = hit; + return { + ...category, + tags: category.tags.filter((tag) => tag.key !== tagKey), + }; + }); + if (!moved) return catalog; + return { + categories: stripped.map((category) => + category.key === groupKey + ? { ...category, tags: [...category.tags, moved!] } + : category, + ), + }; +} + +/** Re-order the tags of one group to `orderedKeys` (unknown keys kept last). */ +export function reorderGroupTags( + catalog: ManageCatalog, + groupKey: string, + orderedKeys: readonly string[], +): ManageCatalog { + return { + categories: catalog.categories.map((category) => { + if (category.key !== groupKey) return category; + const byKey = new Map(category.tags.map((tag) => [tag.key, tag])); + const placed = orderedKeys + .map((key) => byKey.get(key)) + .filter((tag): tag is ManageTag => tag !== undefined); + const placedKeys = new Set(orderedKeys); + const rest = category.tags.filter((tag) => !placedKeys.has(tag.key)); + return { ...category, tags: [...placed, ...rest] }; + }), + }; +} + +/** Re-order the groups to `orderedKeys` (unknown keys kept last). */ +export function reorderGroups( + catalog: ManageCatalog, + orderedKeys: readonly string[], +): ManageCatalog { + const byKey = new Map( + catalog.categories.map((category) => [category.key, category]), + ); + const placed = orderedKeys + .map((key) => byKey.get(key)) + .filter((category): category is ManageCategory => category !== undefined); + const placedKeys = new Set(orderedKeys); + const rest = catalog.categories.filter( + (category) => !placedKeys.has(category.key), + ); + return { categories: [...placed, ...rest] }; +} + +/** + * The complete placement map the layout PUT carries: every category → + * its tag keys in current display order. Sending the full map (instead + * of a delta) keeps the stored blob deterministic — the server resolves + * unknown / stale keys by dropping them at read time. + */ +export function buildPlacements( + catalog: ManageCatalog, +): Record { + const placements: Record = {}; + for (const category of catalog.categories) { + placements[category.key] = category.tags.map((tag) => tag.key); + } + return placements; +} + +/** Current group-key order, for the layout PUT's `groupOrder`. */ +export function buildGroupOrder(catalog: ManageCatalog): string[] { + return catalog.categories.map((category) => category.key); +} + +function mapTag( + catalog: ManageCatalog, + tagKey: string, + fn: (tag: ManageTag) => ManageTag, +): ManageCatalog { + return { + categories: catalog.categories.map((category) => ({ + ...category, + tags: category.tags.map((tag) => (tag.key === tagKey ? fn(tag) : tag)), + })), + }; +} diff --git a/src/components/mood/mood-tag-icon-picker.tsx b/src/components/mood/mood-tag-icon-picker.tsx new file mode 100644 index 000000000..08ba30015 --- /dev/null +++ b/src/components/mood/mood-tag-icon-picker.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { Search, X } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; +import { + MOOD_TAG_ICON_CATALOG, + type MoodTagIconCatalogEntry, +} from "@/lib/mood/icon-catalog"; +import { isMoodTagIconName, moodTagIcon } from "./mood-tag-icons"; + +/** + * v1.17 — searchable icon picker over the shared curated catalog + * (`src/lib/mood/icon-catalog.ts`: the server half feeds the custom-tag + * icon allowlist, this client half renders the searchable grid). Search + * filters on the icon name plus its English keyword aids; the grid is + * sub-headed by catalog group and behaves as one ARIA radiogroup with a + * roving tab stop, so Arrow keys walk the filtered set and Enter/Space + * picks the focused tile. Tile selection language matches the mood-tag + * tiles (`border-primary bg-primary/15 text-primary`). + */ + +/** i18n keys for the catalog's fixed group headers. */ +const ICON_GROUP_LABEL_KEYS: Record = { + emotions: "mood.manage.iconGroupEmotions", + activities: "mood.manage.iconGroupActivities", + health: "mood.manage.iconGroupHealth", + food: "mood.manage.iconGroupFood", + weather: "mood.manage.iconGroupWeather", + places: "mood.manage.iconGroupPlaces", + misc: "mood.manage.iconGroupMisc", +}; + +/** + * Pure search filter — name + keyword substring match, case-insensitive. + * Exported for unit tests. Entries the client bundle cannot draw are + * excluded up front so the picker never offers a fallback-glyph tile. + */ +export function filterIconCatalog( + catalog: readonly MoodTagIconCatalogEntry[], + query: string, +): MoodTagIconCatalogEntry[] { + const drawable = catalog.filter((entry) => isMoodTagIconName(entry.name)); + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return drawable; + return drawable.filter( + (entry) => + entry.name.toLowerCase().includes(needle) || + entry.keywords.some((keyword) => keyword.toLowerCase().includes(needle)), + ); +} + +export interface MoodTagIconPickerProps { + /** Currently selected icon name (null → nothing selected yet). */ + value: string | null; + onChange: (iconName: string) => void; +} + +export function MoodTagIconPicker({ value, onChange }: MoodTagIconPickerProps) { + const { t } = useTranslations(); + const [query, setQuery] = useState(""); + const groupLabelId = useId(); + + const filtered = useMemo( + () => filterIconCatalog(MOOD_TAG_ICON_CATALOG, query), + [query], + ); + + // One flat roving radiogroup across the (filtered) grid; the group + // sub-headers are presentation only. + const selectedIndex = filtered.findIndex((entry) => entry.name === value); + const { getRadioProps } = useRovingRadioGroup({ + count: filtered.length, + selectedIndex, + onSelect: (index) => { + const entry = filtered[index]; + if (entry) onChange(entry.name); + }, + }); + + // Group the filtered list while preserving catalog order; remember + // each entry's flat index for the roving wiring. + const groups: { + group: string; + entries: { entry: MoodTagIconCatalogEntry; index: number }[]; + }[] = []; + filtered.forEach((entry, index) => { + const last = groups[groups.length - 1]; + if (last && last.group === entry.group) { + last.entries.push({ entry, index }); + } else { + groups.push({ group: entry.group, entries: [{ entry, index }] }); + } + }); + + return ( +
+ + {t("mood.manage.iconPickerTitle")} + +
+
+ + {filtered.length === 0 ? ( +
+ {t("mood.manage.iconSearchEmpty")} + +
+ ) : ( +
+ {groups.map(({ group, entries }) => ( +
+

+ {t(ICON_GROUP_LABEL_KEYS[group] ?? "mood.manage.iconGroupMisc")} +

+
+ {entries.map(({ entry, index }) => { + const Icon = moodTagIcon(entry.name); + const isActive = entry.name === value; + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/mood/mood-tag-icons.ts b/src/components/mood/mood-tag-icons.ts index 0d6516ff9..2592593ba 100644 --- a/src/components/mood/mood-tag-icons.ts +++ b/src/components/mood/mood-tag-icons.ts @@ -1,42 +1,70 @@ import { + Activity, AlertTriangle, Angry, Apple, + Baby, + Banknote, + Bath, + Bed, BedDouble, + Bike, + Book, BookOpen, Brain, Briefcase, + Camera, CandyOff, + Car, + Cat, CheckCircle, + Cigarette, + CigaretteOff, Clock, + Cloud, CloudMoon, CloudRain, CloudSun, + Coffee, + Dog, Dumbbell, Film, Flame, Footprints, Frown, Gamepad2, + Gift, GlassWater, + GraduationCap, HandHeart, + Headphones, Heart, HeartPulse, HelpCircle, Home, + House, Laugh, + Leaf, LogOut, Meh, Moon, MoonStar, + Mountain, Music, Palette, PartyPopper, + Phone, + Pill, Pizza, Plane, + ShoppingCart, SlidersHorizontal, Smile, + Star, + Stethoscope, + Sun, Swords, + Syringe, Tag, Thermometer, ThumbsUp, @@ -55,44 +83,80 @@ import { * server stays icon-library-agnostic); this map resolves them on the * client. Mirrors the static-map convention used by the workout / sport * icon lookups. `Tag` is the fallback for an unmapped name. + * + * v1.17 — extended to a superset of the server's custom-tag icon + * allowlist (`src/lib/mood/icon-catalog.ts`): the allowlist is the + * contract, this map must always cover at least every allowed name so a + * user-picked icon never falls back to the generic glyph on web. */ const MOOD_TAG_ICONS: Record = { + Activity, AlertTriangle, + Angry, Apple, + Baby, + Banknote, + Bath, + Bed, BedDouble, + Bike, + Book, BookOpen, Brain, Briefcase, + Camera, CandyOff, + Car, + Cat, CheckCircle, + Cigarette, + CigaretteOff, Clock, + Cloud, CloudMoon, CloudRain, CloudSun, + Coffee, + Dog, Dumbbell, Film, Flame, Footprints, Frown, Gamepad2, + Gift, GlassWater, + GraduationCap, HandHeart, + Headphones, Heart, HeartPulse, HelpCircle, Home, + House, + Laugh, + Leaf, LogOut, Meh, Moon, MoonStar, + Mountain, Music, Palette, PartyPopper, + Phone, + Pill, Pizza, Plane, + ShoppingCart, SlidersHorizontal, Smile, + Star, + Stethoscope, + Sun, Swords, + Syringe, + Tag, Thermometer, ThumbsUp, Trees, @@ -108,6 +172,15 @@ export function moodTagIcon(name: string | null | undefined): LucideIcon { return MOOD_TAG_ICONS[name] ?? Tag; } +/** + * v1.17 — every icon name this client can resolve to a real glyph. + * The searchable icon picker filters the shared catalog against this + * set so a catalog entry the bundle can't draw is never offered. + */ +export function isMoodTagIconName(name: string): boolean { + return name in MOOD_TAG_ICONS; +} + /** * v1.12.0 — face icon per mood enum for the "How are you?" 5-face hero. * diff --git a/src/components/mood/mood-tag-picker.tsx b/src/components/mood/mood-tag-picker.tsx index 796b97aaf..9de3fd129 100644 --- a/src/components/mood/mood-tag-picker.tsx +++ b/src/components/mood/mood-tag-picker.tsx @@ -1,12 +1,15 @@ "use client"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Plus, Tag } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { moodTagIcon } from "./mood-tag-icons"; +import { TagEditorSheet } from "./manage/tag-editor-sheet"; import { apiGet } from "@/lib/api/api-fetch"; /** @@ -27,22 +30,36 @@ import { apiGet } from "@/lib/api/api-fetch"; * Additive next to the legacy free-text tag input: a user can use * either or both. Renders nothing if the catalog is empty so a * deployment that cleared the taxonomy degrades to the free-text field. + * + * v1.17 — three management-suite additions: (1) a custom tag renders + * its decrypted `label` (the v1.13 API field the web picker previously + * ignored — a custom tag used to paint its raw key through the `t()` + * fallback); (2) a trailing ghost "+" tile per group opens the shared + * create sheet with that group preselected, and the fresh tag is + * selected in the form right away; (3) the render order is exactly the + * server-resolved per-user order — no client-side sorting. */ interface CatalogTag { key: string; labelKey: string; + /** Decrypted custom label (v1.13+); null/absent for catalogue tags. */ + label?: string | null; icon: string | null; kind: "BINARY" | "RATED"; scaleMin: number; scaleMax: number; inverse: boolean; + custom?: boolean; } interface CatalogCategory { key: string; labelKey: string; + /** Decrypted custom group label; null/absent for seeded categories. */ + label?: string | null; icon: string | null; + custom?: boolean; tags: CatalogTag[]; } @@ -75,6 +92,9 @@ export function MoodTagPicker({ }) { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); + // v1.17 — inline-create sheet state: the group key the tapped "+" + // tile belongs to, or null when closed. + const [createGroupKey, setCreateGroupKey] = useState(null); const { data, isLoading } = useQuery({ queryKey: queryKeys.moodTagCatalog(), @@ -97,6 +117,16 @@ export function MoodTagPicker({ const selectedSet = new Set(selected); const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating])); + const groupOptions = data.categories.map((category) => ({ + key: category.key, + name: category.label ?? t(category.labelKey), + })); + // The plain read drops empty categories, so before the first custom + // tag exists there is no Custom node to hang the "+" tile on — + // synthesize the bootstrap group so inline creation is always + // reachable. + const hasCustomGroup = data.categories.some((c) => c.key === "custom"); + return (
{data.categories.map((category) => { @@ -114,15 +144,17 @@ export function MoodTagPicker({
- {binaryTags.length > 0 && ( + {
{binaryTags.map((tag) => { const TagIcon = moodTagIcon(tag.icon); const isActive = selectedSet.has(tag.key); - const label = t(tag.labelKey); + // v1.17 — a custom tag carries its decrypted label; + // catalogue tags keep resolving their i18n key. + const label = tag.label ?? t(tag.labelKey); return (
- )} + } {ratedTags.length > 0 && onRateFactor && (
{ratedTags.map((tag) => { const TagIcon = moodTagIcon(tag.icon); - const label = t(tag.labelKey); + const label = tag.label ?? t(tag.labelKey); const current = ratingByKey.get(tag.key); const isBinaryScale = tag.scaleMax - tag.scaleMin === 1; const steps: number[] = []; @@ -223,6 +259,69 @@ export function MoodTagPicker({
); })} + + {/* Bootstrap node for the seeded Custom group: before the first + custom tag exists the plain read carries no `custom` category, + so the inline-create entry point renders synthetically. */} + {!hasCustomGroup && ( +
+
+
+
+ setCreateGroupKey("custom")} + /> +
+
+ )} + + {createGroupKey !== null && ( + { + if (!open) setCreateGroupKey(null); + }} + groups={ + hasCustomGroup + ? groupOptions + : [ + ...groupOptions, + { key: "custom", name: t("mood.tagCategory.custom") }, + ] + } + initialGroupKey={createGroupKey} + onCreated={(created) => onToggle(created.key)} + /> + )}
); } + +/** + * v1.17 — trailing ghost "+" tile: same footprint as a tag tile, + * dashed border, opens the inline create sheet for its group. + */ +function AddTagTile({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx new file mode 100644 index 000000000..edfb4d47a --- /dev/null +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -0,0 +1,252 @@ +/** + * v1.17 — the "Stimmungs-Tags" settings section (`/settings/mood`). + * + * SSR smoke assertions matching the rest of the settings suite: the + * three management cards (groups / tags / archived) render from one + * mocked manage read, custom labels paint decrypted (never the raw + * key), hidden catalogue rows dim, archived custom tags land in the + * archived card with restore + purge affordances, and both locales + * resolve end-to-end. The interactive contracts (optimistic flips, + * reorder PUTs) ride the pure helpers pinned in + * `src/components/mood/manage/__tests__/catalog-helpers.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { ManageCatalog } from "@/components/mood/manage/use-mood-tag-manage"; + +const FIXTURE: ManageCatalog = { + categories: [ + { + key: "feelings", + labelKey: "mood.tagCategory.feelings", + label: null, + icon: "Smile", + custom: false, + tags: [ + { + key: "happy", + labelKey: "mood.tag.happy", + label: null, + icon: "Smile", + kind: "BINARY", + scaleMin: 0, + scaleMax: 1, + inverse: false, + custom: false, + hidden: false, + usageCount: 12, + }, + { + key: "stressed", + labelKey: "mood.tag.stressed", + label: null, + icon: "AlertTriangle", + kind: "BINARY", + scaleMin: 0, + scaleMax: 1, + inverse: true, + custom: false, + hidden: true, + usageCount: 0, + }, + ], + }, + { + key: "customcat:g1", + labelKey: "customcat:g1", + label: "Garten", + icon: "Leaf", + custom: true, + tags: [ + { + key: "custom:1", + labelKey: "custom:1", + label: "Gartenarbeit", + icon: "Leaf", + kind: "BINARY", + scaleMin: 0, + scaleMax: 1, + inverse: false, + custom: true, + archived: false, + usageCount: 3, + }, + { + key: "custom:2", + labelKey: "custom:2", + label: "Altes Tag", + icon: "Tag", + kind: "BINARY", + scaleMin: 0, + scaleMax: 1, + inverse: false, + custom: true, + archived: true, + usageCount: 5, + }, + ], + }, + ], +}; + +const queryState: { catalog: ManageCatalog | null; loading: boolean } = { + catalog: FIXTURE, + loading: false, +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { + if ( + Array.isArray(queryKey) && + queryKey[0] === "mood-tag-catalog" && + queryKey[1] === "manage" + ) { + return { data: queryState.catalog, isLoading: queryState.loading }; + } + return { data: null, isLoading: false }; + }, + useQueryClient: () => ({ + setQueryData: vi.fn(), + getQueryData: vi.fn(), + cancelQueries: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/charts/reduced-motion", () => ({ + prefersReducedMotion: () => true, +})); + +vi.mock("@/hooks/use-is-mobile", () => ({ + useIsMobile: () => false, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, isLoading: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodSection } from "../mood-section"; + +function render(locale: "en" | "de" = "en"): string { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + queryState.catalog = FIXTURE; + queryState.loading = false; +}); + +describe(" — SSR smoke", () => { + it("renders the section heading + description via i18n (no raw keys)", () => { + const html = render(); + expect(html).toContain("settings-section-mood-title"); + expect(html).toContain("Groups, tags, and order of the mood picker."); + expect(html).not.toContain("settings.sections."); + expect(html).not.toContain("mood.manage."); + }); + + it("renders the German copy end-to-end", () => { + const html = render("de"); + expect(html).toContain("Gruppen, Tags und Reihenfolge der Tag-Auswahl."); + expect(html).toContain("Neue Gruppe"); + expect(html).toContain("Neuer Tag"); + expect(html).toContain("Archivierte Tags"); + }); + + it("hosts the three management cards from ONE manage read", () => { + const html = render(); + expect(html).toContain('data-slot="mood-tag-groups-card"'); + expect(html).toContain('data-slot="mood-tag-manager-card"'); + expect(html).toContain('data-slot="mood-archived-tags-card"'); + }); + + it("lists every group — seeded by i18n key, custom by decrypted label", () => { + const html = render(); + expect(html).toContain("Feelings"); + expect(html).toContain("Garten"); + // The custom group's raw key must never paint as text (it remains + // present as a `data-group` hook for the reorder wiring). + expect(html).not.toContain(">customcat:g1<"); + expect(html).not.toContain(">customcat:g1<"); + }); + + it("seeded groups reorder only — the kebab is reserved for own groups", () => { + const html = render(); + const groupRows = html.match(/data-slot="mood-group-row"/g); + expect(groupRows?.length ?? 0).toBe(2); + // One kebab per CUSTOM group row; the seeded row carries none. + const kebabs = html.match(/More options — Garten/g); + expect(kebabs?.length ?? 0).toBeGreaterThanOrEqual(1); + expect(html).not.toContain("More options — Feelings"); + }); + + it("renders custom tags by decrypted label, never the raw key", () => { + const html = render(); + expect(html).toContain("Gartenarbeit"); + expect(html).not.toContain("custom:1 { + const html = render(); + expect(html).toMatch( + /data-slot="mood-tag-manage-row"[^>]*data-hidden="true"/, + ); + expect(html).toContain("Show tag — stressed"); + }); + + it("surfaces the usage count badge", () => { + const html = render(); + expect(html).toContain("12 entries"); + }); + + it("archived card lists the archived custom tag with restore + purge", () => { + const html = render(); + expect(html).toContain('data-slot="mood-archived-row"'); + expect(html).toContain("Altes Tag"); + expect(html).toContain("Restore"); + expect(html).toContain("Delete permanently — Altes Tag"); + // The active custom tag must NOT appear in the archived card. + const archivedCard = html.slice( + html.indexOf('data-slot="mood-archived-tags-card"'), + ); + expect(archivedCard).not.toContain("Gartenarbeit"); + }); + + it("archived wording makes clear history stays (German)", () => { + const html = render("de"); + expect(html).toContain( + "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten", + ); + }); + + it("shows the groups empty-state when the user owns no groups", () => { + queryState.catalog = { + categories: FIXTURE.categories.filter((c) => !c.custom), + }; + const html = render(); + expect(html).toContain('data-slot="mood-groups-empty"'); + expect(html).toContain("No own groups yet."); + }); + + it("shows the archived empty-state when nothing is archived", () => { + queryState.catalog = { + categories: FIXTURE.categories.map((c) => ({ + ...c, + tags: c.tags.filter((t) => t.archived !== true), + })), + }; + const html = render(); + expect(html).toContain('data-slot="mood-archived-empty"'); + expect(html).toContain("Nothing archived."); + }); +}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 3a857c10a..c613b9692 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -53,6 +53,9 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.16.10 — `medications` sits between `insights` and `thresholds`: // the view-preference + manual-order customise surface for // `/medications`, reached from the page header's Settings2 glyph. + // v1.17 — `mood` sits between `medications` and `thresholds`: the + // mood-tag management surface (groups, custom tags, hide/archive, + // picker order), reached from the /mood page header's wrench glyph. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "integrations", @@ -60,6 +63,7 @@ describe("SETTINGS_SECTION_SLUGS", () => { "dashboard", "insights", "medications", + "mood", "thresholds", "sources", "ai", @@ -157,6 +161,10 @@ describe("", () => { // between Insights and Targets. expect(html).toContain('href="/settings/medications"'); expect(html).toContain("Medications"); + // v1.17 — the mood-tag management surface sits between Medications + // and Targets. + expect(html).toContain('href="/settings/mood"'); + expect(html).toContain("Mood tags"); // The ampersand is HTML-escaped by React SSR — assert on the encoded // form so we don't accidentally match a parser that double-escapes. expect(html).toContain("API & Tokens"); @@ -192,6 +200,9 @@ describe("", () => { // v1.16.10 — the /medications customise surface ("Medikamente"). expect(html).toContain('href="/settings/medications"'); expect(html).toContain("Medikamente"); + // v1.17 — the mood-tag management surface ("Stimmungs-Tags"). + expect(html).toContain('href="/settings/mood"'); + expect(html).toContain("Stimmungs-Tags"); // v1.8.7.1 — Targets and Sources are two separate German nav // entries again: "Zielwerte" and "Quellen". expect(html).toContain("Zielwerte"); diff --git a/src/components/settings/mood-section.tsx b/src/components/settings/mood-section.tsx new file mode 100644 index 000000000..d8a82248e --- /dev/null +++ b/src/components/settings/mood-section.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Archive, Folders, Loader2, Tags } from "lucide-react"; + +import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card"; +import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card"; +import { TagManagerCard } from "@/components/mood/manage/tag-manager-card"; +import { useMoodTagManage } from "@/components/mood/manage/use-mood-tag-manage"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.17 — the "Stimmungs-Tags" settings section (`/settings/mood`), + * following the medications-section card pattern. Hosts the whole + * mood-tag management surface in three blocks: + * 1. Groups — create / rename / delete own groups, drag + arrow + * reorder of the picker's group order. + * 2. Tags — per-group tag list mirroring the picker: hide/show + * catalogue tags, edit / move / archive custom tags, drag + arrow + * reorder, create. + * 3. Archived — archived custom tags with restore and the explicit + * hard-delete path. + * + * One management read (`useMoodTagManage`) feeds all three cards — the + * section never waterfalls; every mutation invalidates the + * `["mood-tag-catalog"]` prefix so the mood form's picker repaints in + * the same tick. + */ + +export function MoodSection() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const { data: catalog, isLoading } = useMoodTagManage(isAuthenticated); + + const loading = ( +
+ + {t("common.loading")} +
+ ); + + return ( +
+
+

+ {t("settings.sections.mood.title")} +

+

+ {t("settings.sections.mood.description")} +

+
+ + {/* Groups — picker group order + own groups. */} +
+
+ +

+ {t("mood.manage.groupsTitle")} +

+
+ {isLoading || !catalog ? loading : } +
+ + {/* Tags — visibility, order, edit, move, create. */} +
+
+ +

+ {t("mood.manage.tagsTitle")} +

+
+ {isLoading || !catalog ? loading : } +
+ + {/* Archived custom tags — restore / hard delete. */} +
+
+ +

+ {t("mood.manage.archivedTitle")} +

+
+ {isLoading || !catalog ? ( + loading + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index e3c6bff7c..241654206 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -22,6 +22,7 @@ import { Pill, Settings2, SlidersHorizontal, + Smile, Sparkles, TrendingUp, User, @@ -39,6 +40,7 @@ const SLUG_ICON: Record = { dashboard: LayoutDashboard, insights: TrendingUp, medications: Pill, + mood: Smile, thresholds: SlidersHorizontal, sources: Layers, ai: Sparkles, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 9a92818fb..28b812cbf 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -17,6 +17,9 @@ // for the /medications list (view preference + manual order), following // the dashboard / insights pattern of a dedicated settings section // reached from the page header's Settings2 glyph. +// v1.17 — `mood` sits after `medications`: the mood-tag management +// surface (groups, custom tags, hide/archive, picker order), reached +// from the /mood page header's wrench glyph. export const SETTINGS_SECTION_SLUGS = [ "account", "integrations", @@ -24,6 +27,7 @@ export const SETTINGS_SECTION_SLUGS = [ "dashboard", "insights", "medications", + "mood", "thresholds", "sources", "ai", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index bf979d8fc..af5532ac2 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -28,6 +28,7 @@ import { Pill, Settings2, SlidersHorizontal, + Smile, Sparkles, TrendingUp, User, @@ -106,6 +107,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.medications.title", icon: Pill, }, + { + slug: "mood", + titleKey: "settings.sections.mood.title", + icon: Smile, + }, { slug: "thresholds", titleKey: "settings.sections.thresholds.title", diff --git a/src/lib/mood/__tests__/custom-tags.test.ts b/src/lib/mood/__tests__/custom-tags.test.ts index 13697e6a9..c8652e7f7 100644 --- a/src/lib/mood/__tests__/custom-tags.test.ts +++ b/src/lib/mood/__tests__/custom-tags.test.ts @@ -11,13 +11,20 @@ vi.mock("@/lib/crypto", () => ({ import { isCustomTagKey, mintCustomTagKey, + isCustomCategoryKey, + mintCustomCategoryKey, encryptCustomLabel, decryptCustomLabel, createCustomTagSchema, updateCustomTagSchema, + createCustomGroupSchema, + updateCustomGroupSchema, hideCatalogueTagSchema, CUSTOM_TAG_KEY_PREFIX, + CUSTOM_CATEGORY_KEY_PREFIX, + CUSTOM_TAG_ICON_ALLOWLIST, } from "@/lib/mood/custom-tags"; +import { MOOD_TAG_ICON_CATALOG } from "@/lib/mood/icon-catalog"; import { resolveTagKeysToIds } from "@/lib/mood/tag-links"; describe("custom-tag helpers", () => { @@ -53,6 +60,81 @@ describe("custom-tag schemas", () => { expect(hideCatalogueTagSchema.safeParse({ hidden: true }).success).toBe(true); expect(hideCatalogueTagSchema.safeParse({ hidden: "yes" }).success).toBe(false); }); + + it("accepts a categoryKey on create + update (v1.17.0) within bounds", () => { + expect( + createCustomTagSchema.safeParse({ label: "x", categoryKey: "feelings" }) + .success, + ).toBe(true); + expect( + createCustomTagSchema.safeParse({ + label: "x", + categoryKey: `customcat:${"a".repeat(36)}`, + }).success, + ).toBe(true); + expect( + createCustomTagSchema.safeParse({ + label: "x", + categoryKey: "k".repeat(81), + }).success, + ).toBe(false); + // A lone categoryKey satisfies the at-least-one-field update refine. + expect( + updateCustomTagSchema.safeParse({ categoryKey: "custom" }).success, + ).toBe(true); + }); +}); + +describe("custom-group helpers + schemas (v1.17.0)", () => { + it("mints prefixed, unique group keys and recognises them", () => { + const a = mintCustomCategoryKey(); + const b = mintCustomCategoryKey(); + expect(a.startsWith(CUSTOM_CATEGORY_KEY_PREFIX)).toBe(true); + expect(a).not.toBe(b); + expect(isCustomCategoryKey(a)).toBe(true); + expect(isCustomCategoryKey("feelings")).toBe(false); + // A custom TAG key is not a custom GROUP key and vice versa. + expect(isCustomCategoryKey(mintCustomTagKey())).toBe(false); + expect(isCustomTagKey(a)).toBe(false); + }); + + it("validates group create / update bodies like the tag schemas", () => { + expect(createCustomGroupSchema.safeParse({ label: "Hobbies 2" }).success).toBe(true); + expect( + createCustomGroupSchema.safeParse({ label: "x", icon: "Stethoscope" }).success, + ).toBe(true); + expect( + createCustomGroupSchema.safeParse({ label: "x", icon: "NotAnIcon" }).success, + ).toBe(false); + expect(createCustomGroupSchema.safeParse({ label: "" }).success).toBe(false); + expect( + createCustomGroupSchema.safeParse({ label: "a".repeat(41) }).success, + ).toBe(false); + expect(updateCustomGroupSchema.safeParse({}).success).toBe(false); + expect(updateCustomGroupSchema.safeParse({ isActive: false }).success).toBe(true); + }); +}); + +describe("icon catalog → allowlist seam (v1.17.0)", () => { + it("derives the allowlist from the catalog and keeps the pre-v1.17 names", () => { + expect(CUSTOM_TAG_ICON_ALLOWLIST).toEqual( + MOOD_TAG_ICON_CATALOG.map((e) => e.name), + ); + // The v1.13 22-name allowlist must survive — icons already stored on + // rows keep validating. + const legacy = [ + "Tag", "Heart", "Smile", "Frown", "Dumbbell", "Moon", "Sun", "Wine", + "Coffee", "House", "Briefcase", "Book", "Music", "Plane", "Car", + "Users", "Pill", "Activity", "Brain", "Cloud", "Star", "Zap", + ]; + for (const name of legacy) { + expect(CUSTOM_TAG_ICON_ALLOWLIST).toContain(name); + } + // No duplicate names in the catalog (the picker keys on them). + expect(new Set(CUSTOM_TAG_ICON_ALLOWLIST).size).toBe( + CUSTOM_TAG_ICON_ALLOWLIST.length, + ); + }); }); describe("resolveTagKeysToIds — custom-tag ownership scoping (v1.13.0)", () => { diff --git a/src/lib/mood/__tests__/tag-layout.test.ts b/src/lib/mood/__tests__/tag-layout.test.ts new file mode 100644 index 000000000..6d4b833ab --- /dev/null +++ b/src/lib/mood/__tests__/tag-layout.test.ts @@ -0,0 +1,180 @@ +/** + * v1.17.0 — layout-blob schema bounds, preserve-when-absent merge, group + * stripping, and the read-time placement resolution (unknown-key drops, + * duplicate claims, home-category fallback ordering). + */ +import { describe, expect, it } from "vitest"; + +import { + moodTagLayoutSchema, + parseStoredMoodTagLayout, + mergeMoodTagLayout, + stripGroupFromLayout, + resolveGroupOrder, + resolveMoodTagPlacement, + MOOD_TAG_LAYOUT_MAX_GROUPS, + MOOD_TAG_LAYOUT_MAX_PLACEMENTS, +} from "@/lib/mood/tag-layout"; + +describe("moodTagLayoutSchema bounds", () => { + it("accepts the empty blob and partial blobs", () => { + expect(moodTagLayoutSchema.safeParse({}).success).toBe(true); + expect( + moodTagLayoutSchema.safeParse({ groupOrder: ["feelings"] }).success, + ).toBe(true); + expect( + moodTagLayoutSchema.safeParse({ placements: { feelings: ["happy"] } }) + .success, + ).toBe(true); + }); + + it("bounds groupOrder length, placement totals, and key length", () => { + expect( + moodTagLayoutSchema.safeParse({ + groupOrder: Array.from( + { length: MOOD_TAG_LAYOUT_MAX_GROUPS + 1 }, + (_, i) => `g${i}`, + ), + }).success, + ).toBe(false); + expect( + moodTagLayoutSchema.safeParse({ + placements: { g1: Array.from({ length: MOOD_TAG_LAYOUT_MAX_PLACEMENTS + 1 }, (_, i) => `t${i}`) }, + }).success, + ).toBe(false); + expect( + moodTagLayoutSchema.safeParse({ groupOrder: ["k".repeat(81)] }).success, + ).toBe(false); + expect( + moodTagLayoutSchema.safeParse({ placements: { g: [""] } }).success, + ).toBe(false); + }); +}); + +describe("parseStoredMoodTagLayout", () => { + it("degrades a malformed / legacy blob to the empty layout", () => { + expect(parseStoredMoodTagLayout(null)).toEqual({}); + expect(parseStoredMoodTagLayout(undefined)).toEqual({}); + expect(parseStoredMoodTagLayout("garbage")).toEqual({}); + expect(parseStoredMoodTagLayout({ groupOrder: "not-an-array" })).toEqual({}); + expect(parseStoredMoodTagLayout({ groupOrder: ["a"] })).toEqual({ + groupOrder: ["a"], + }); + }); +}); + +describe("mergeMoodTagLayout — preserve-when-absent", () => { + const stored = { groupOrder: ["a"], placements: { a: ["t1"] } }; + + it("keeps stored placements on a groupOrder-only PUT and vice versa", () => { + expect(mergeMoodTagLayout(stored, { groupOrder: ["b"] })).toEqual({ + groupOrder: ["b"], + placements: { a: ["t1"] }, + }); + expect(mergeMoodTagLayout(stored, { placements: {} })).toEqual({ + groupOrder: ["a"], + placements: {}, + }); + expect(mergeMoodTagLayout({}, { groupOrder: ["x"] })).toEqual({ + groupOrder: ["x"], + }); + }); +}); + +describe("stripGroupFromLayout", () => { + it("drops the group from order + its placement bucket, keeps the rest", () => { + const layout = { + groupOrder: ["a", "customcat:x", "b"], + placements: { "customcat:x": ["t1"], b: ["t2"] }, + }; + expect(stripGroupFromLayout(layout, "customcat:x")).toEqual({ + groupOrder: ["a", "b"], + placements: { b: ["t2"] }, + }); + // Absent fields stay absent (a strip never materialises a blob). + expect(stripGroupFromLayout({}, "customcat:x")).toEqual({}); + }); +}); + +describe("resolveGroupOrder", () => { + it("layout order first (unknown dropped, dupes collapsed), missing appended in seeded order", () => { + expect( + resolveGroupOrder( + ["feelings", "sleep", "custom"], + ["custom", "ghost", "custom", "feelings"], + ), + ).toEqual(["custom", "feelings", "sleep"]); + expect(resolveGroupOrder(["a", "b"], undefined)).toEqual(["a", "b"]); + }); +}); + +describe("resolveMoodTagPlacement", () => { + const categoryKeys = ["feelings", "custom", "customcat:g1"]; + const tags = [ + { key: "happy", homeCategoryKey: "feelings" }, + { key: "sad", homeCategoryKey: "feelings" }, + { key: "custom:a", homeCategoryKey: "custom" }, + { key: "custom:b", homeCategoryKey: "custom" }, + ]; + + it("renders placed tags at their slot and un-placed ones after, in sortOrder", () => { + const { orderedCategoryKeys, tagKeysByCategory } = resolveMoodTagPlacement({ + categoryKeysInSeededOrder: categoryKeys, + tags, + layout: { + groupOrder: ["customcat:g1", "feelings"], + placements: { "customcat:g1": ["sad", "custom:b"] }, + }, + }); + expect(orderedCategoryKeys).toEqual(["customcat:g1", "feelings", "custom"]); + expect(tagKeysByCategory.get("customcat:g1")).toEqual(["sad", "custom:b"]); + expect(tagKeysByCategory.get("feelings")).toEqual(["happy"]); + expect(tagKeysByCategory.get("custom")).toEqual(["custom:a"]); + }); + + it("drops placements referencing unknown tags or deleted groups", () => { + const { tagKeysByCategory } = resolveMoodTagPlacement({ + categoryKeysInSeededOrder: categoryKeys, + tags, + layout: { + placements: { + "customcat:g1": ["ghost-tag", "happy"], + "customcat:deleted": ["sad"], + }, + }, + }); + expect(tagKeysByCategory.get("customcat:g1")).toEqual(["happy"]); + // `sad` was placed into an unknown group → falls back to its home. + expect(tagKeysByCategory.get("feelings")).toEqual(["sad"]); + }); + + it("resolves a duplicate claim to the first group in display order", () => { + const { tagKeysByCategory } = resolveMoodTagPlacement({ + categoryKeysInSeededOrder: categoryKeys, + tags, + layout: { + placements: { + feelings: ["custom:a"], + "customcat:g1": ["custom:a"], + }, + }, + }); + expect(tagKeysByCategory.get("feelings")).toEqual([ + "custom:a", + "happy", + "sad", + ]); + expect(tagKeysByCategory.get("customcat:g1")).toEqual([]); + }); + + it("returns the seeded tree untouched for the empty layout", () => { + const { orderedCategoryKeys, tagKeysByCategory } = resolveMoodTagPlacement({ + categoryKeysInSeededOrder: categoryKeys, + tags, + layout: {}, + }); + expect(orderedCategoryKeys).toEqual(categoryKeys); + expect(tagKeysByCategory.get("feelings")).toEqual(["happy", "sad"]); + expect(tagKeysByCategory.get("custom")).toEqual(["custom:a", "custom:b"]); + }); +}); diff --git a/src/lib/mood/custom-tags.ts b/src/lib/mood/custom-tags.ts index 69078d5f9..f18280e2b 100644 --- a/src/lib/mood/custom-tags.ts +++ b/src/lib/mood/custom-tags.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { z } from "zod/v4"; import { encrypt, decrypt } from "@/lib/crypto"; +import { MOOD_TAG_ICON_NAMES } from "@/lib/mood/icon-catalog"; /** * v1.13.0 — per-user custom mood-tag helpers (iOS v1.13.0 contract). @@ -26,36 +27,21 @@ export const CUSTOM_CATEGORY_ID = "mtc_custom"; /** Per-user ceiling on custom tags (422 over the cap). */ export const MAX_CUSTOM_TAGS_PER_USER = 50; +/** v1.17.0 — reserved key prefix for a user-minted custom group. */ +export const CUSTOM_CATEGORY_KEY_PREFIX = "customcat:"; + +/** v1.17.0 — per-user ceiling on custom groups (422 over the cap). */ +export const MAX_CUSTOM_GROUPS_PER_USER = 12; + /** - * Allow-list of Lucide icon names a custom tag may use. Kept to names the iOS - * client maps to an SF Symbol (`MoodTagSFSymbol`) so a custom tag never falls - * back to the generic glyph on one platform; iOS extends its map to cover the - * final list. An unknown name → 422. `Tag` is the default. + * Allow-list of Lucide icon names a custom tag or group may use — derived + * from the curated catalog in `icon-catalog.ts` (the shared server/client + * seam). Every name has an iOS SF-Symbol mapping (`MoodTagSFSymbol`) so a + * custom tag never falls back to the generic glyph on one platform; iOS + * extends its map before the catalog grows. An unknown name → 422. `Tag` + * is the default. */ -export const CUSTOM_TAG_ICON_ALLOWLIST = [ - "Tag", - "Heart", - "Smile", - "Frown", - "Dumbbell", - "Moon", - "Sun", - "Wine", - "Coffee", - "House", - "Briefcase", - "Book", - "Music", - "Plane", - "Car", - "Users", - "Pill", - "Activity", - "Brain", - "Cloud", - "Star", - "Zap", -] as const; +export const CUSTOM_TAG_ICON_ALLOWLIST: readonly string[] = MOOD_TAG_ICON_NAMES; const ICON_SET = new Set(CUSTOM_TAG_ICON_ALLOWLIST); @@ -69,6 +55,16 @@ export function mintCustomTagKey(): string { return `${CUSTOM_TAG_KEY_PREFIX}${randomUUID()}`; } +/** v1.17.0 — is `key` a custom-group key (vs a seeded category slug)? */ +export function isCustomCategoryKey(key: string): boolean { + return key.startsWith(CUSTOM_CATEGORY_KEY_PREFIX); +} + +/** v1.17.0 — mint a fresh, collision-proof custom-group key. */ +export function mintCustomCategoryKey(): string { + return `${CUSTOM_CATEGORY_KEY_PREFIX}${randomUUID()}`; +} + /** Encrypt a custom label for storage in `label_encrypted`. */ export function encryptCustomLabel(label: string): string { return encrypt(label); @@ -98,13 +94,20 @@ const iconSchema = z .refine((v) => ICON_SET.has(v), "Unknown icon") .nullish(); +/** + * v1.17.0 — a category key on the wire: a seeded slug (`feelings`, `custom`, + * …) or a user's own `customcat:` group key. The handler resolves it + * against the seeded set OR the caller's own groups; anything else → 422. + */ +const categoryKeySchema = z.string().trim().min(1).max(80); + /** POST body — create a custom tag. */ export const createCustomTagSchema = z.object({ label: labelSchema, icon: iconSchema, - // Reserved for a future per-tag category choice; v1 pins everything to the - // `custom` category, so a supplied value other than `custom` is rejected. - categoryKey: z.literal(CUSTOM_CATEGORY_KEY).optional(), + // v1.17.0 — home group for the new tag. Omitted → the seeded `custom` + // category (the v1.13.0 behaviour). + categoryKey: categoryKeySchema.optional(), }); /** PATCH body — update a custom tag (every field optional). */ @@ -113,9 +116,34 @@ export const updateCustomTagSchema = z label: labelSchema.optional(), icon: iconSchema, isActive: z.boolean().optional(), + // v1.17.0 — move the tag to another group (real `categoryId` move). + categoryKey: categoryKeySchema.optional(), }) .refine( - (v) => v.label !== undefined || v.icon !== undefined || v.isActive !== undefined, + (v) => + v.label !== undefined || + v.icon !== undefined || + v.isActive !== undefined || + v.categoryKey !== undefined, + "At least one field is required", + ); + +/** v1.17.0 — POST body — create a custom group. */ +export const createCustomGroupSchema = z.object({ + label: labelSchema, + icon: iconSchema, +}); + +/** v1.17.0 — PATCH body — update a custom group (every field optional). */ +export const updateCustomGroupSchema = z + .object({ + label: labelSchema.optional(), + icon: iconSchema, + isActive: z.boolean().optional(), + }) + .refine( + (v) => + v.label !== undefined || v.icon !== undefined || v.isActive !== undefined, "At least one field is required", ); diff --git a/src/lib/mood/icon-catalog.ts b/src/lib/mood/icon-catalog.ts new file mode 100644 index 000000000..adb69e854 --- /dev/null +++ b/src/lib/mood/icon-catalog.ts @@ -0,0 +1,136 @@ +/** + * v1.17.0 — curated mood-tag icon catalog (the shared server/client seam). + * + * One module, two consumers: + * - the server derives `CUSTOM_TAG_ICON_ALLOWLIST` (custom-tags.ts) from + * the `name` column, so an icon a tag or group may store is exactly an + * entry of this catalog; + * - the client renders the searchable icon picker from the full rows + * (keywords are English-only search aids, not i18n keys; the visible + * label under each icon is the name itself). + * + * Constraints: + * - every `name` is a Lucide icon name the iOS client maps to an SF + * Symbol (`MoodTagSFSymbol`) — extend the iOS map before extending + * this list (see the ios-coord note for the published set); + * - the catalog is a strict superset of the pre-v1.17 22-name allowlist + * AND of the 44-name client map in `mood-tag-icons.ts`, so every icon + * already stored on a row keeps resolving. Never remove a name. + * + * No `lucide-react` import here — the server stays icon-library-agnostic + * (it stores and validates names only). + */ + +export type MoodTagIconGroup = + | "emotions" + | "activities" + | "health" + | "food" + | "weather" + | "places" + | "misc"; + +export interface MoodTagIconCatalogEntry { + /** Lucide icon name — the value stored on `mood_tags.icon` / `mood_tag_categories.icon`. */ + name: string; + /** English search aids for the picker's filter input. */ + keywords: string[]; + /** Picker sub-header bucket. */ + group: MoodTagIconGroup; +} + +export const MOOD_TAG_ICON_CATALOG: readonly MoodTagIconCatalogEntry[] = [ + // ── emotions ───────────────────────────────────────────────────────── + { name: "Smile", keywords: ["happy", "good", "content"], group: "emotions" }, + { name: "Laugh", keywords: ["joy", "fun", "laughter"], group: "emotions" }, + { name: "Meh", keywords: ["neutral", "okay", "indifferent"], group: "emotions" }, + { name: "Frown", keywords: ["sad", "down", "unhappy"], group: "emotions" }, + { name: "Angry", keywords: ["mad", "anger", "frustration"], group: "emotions" }, + { name: "Heart", keywords: ["love", "romance", "date"], group: "emotions" }, + { name: "HandHeart", keywords: ["kindness", "gratitude", "care"], group: "emotions" }, + { name: "PartyPopper", keywords: ["party", "celebration", "birthday"], group: "emotions" }, + { name: "ThumbsUp", keywords: ["good", "win", "approve"], group: "emotions" }, + { name: "CheckCircle", keywords: ["done", "achievement", "complete"], group: "emotions" }, + { name: "AlertTriangle", keywords: ["stress", "warning", "alert"], group: "emotions" }, + { name: "HelpCircle", keywords: ["uncertainty", "confusion", "question"], group: "emotions" }, + { name: "Swords", keywords: ["conflict", "argument", "fight"], group: "emotions" }, + { name: "Flame", keywords: ["energy", "motivation", "streak"], group: "emotions" }, + { name: "Zap", keywords: ["energy", "electric", "boost"], group: "emotions" }, + { name: "Star", keywords: ["favorite", "special", "highlight"], group: "emotions" }, + { name: "Gift", keywords: ["present", "surprise", "birthday"], group: "emotions" }, + + // ── activities ─────────────────────────────────────────────────────── + { name: "Dumbbell", keywords: ["gym", "workout", "strength"], group: "activities" }, + { name: "Activity", keywords: ["exercise", "pulse", "movement"], group: "activities" }, + { name: "Footprints", keywords: ["walk", "steps", "stroll"], group: "activities" }, + { name: "Bike", keywords: ["cycling", "bicycle", "ride"], group: "activities" }, + { name: "Music", keywords: ["concert", "song", "instrument"], group: "activities" }, + { name: "Headphones", keywords: ["podcast", "listening", "audio"], group: "activities" }, + { name: "Gamepad2", keywords: ["gaming", "videogame", "play"], group: "activities" }, + { name: "Film", keywords: ["movie", "cinema", "series"], group: "activities" }, + { name: "BookOpen", keywords: ["reading", "book", "novel"], group: "activities" }, + { name: "Book", keywords: ["journal", "study", "diary"], group: "activities" }, + { name: "Briefcase", keywords: ["work", "office", "job"], group: "activities" }, + { name: "GraduationCap", keywords: ["school", "study", "learning"], group: "activities" }, + { name: "Palette", keywords: ["art", "creative", "painting"], group: "activities" }, + { name: "Camera", keywords: ["photo", "photography", "picture"], group: "activities" }, + { name: "Trees", keywords: ["nature", "outdoors", "forest"], group: "activities" }, + { name: "Mountain", keywords: ["hike", "climbing", "summit"], group: "activities" }, + { name: "Plane", keywords: ["travel", "flight", "vacation"], group: "activities" }, + { name: "Car", keywords: ["drive", "commute", "road"], group: "activities" }, + { name: "ShoppingCart", keywords: ["shopping", "groceries", "errands"], group: "activities" }, + { name: "Phone", keywords: ["call", "telephone", "contact"], group: "activities" }, + { name: "Users", keywords: ["friends", "social", "family"], group: "activities" }, + { name: "User", keywords: ["alone", "solo", "me"], group: "activities" }, + { name: "LogOut", keywords: ["out", "leave", "going out"], group: "activities" }, + { name: "Banknote", keywords: ["money", "finance", "spending"], group: "activities" }, + { name: "Clock", keywords: ["time", "late", "schedule"], group: "activities" }, + { name: "SlidersHorizontal", keywords: ["adjust", "factor", "balance"], group: "activities" }, + + // ── health ─────────────────────────────────────────────────────────── + { name: "Pill", keywords: ["medication", "medicine", "dose"], group: "health" }, + { name: "Stethoscope", keywords: ["doctor", "appointment", "checkup"], group: "health" }, + { name: "Syringe", keywords: ["injection", "vaccine", "shot"], group: "health" }, + { name: "Thermometer", keywords: ["fever", "sick", "temperature"], group: "health" }, + { name: "HeartPulse", keywords: ["heart rate", "cardio", "pulse"], group: "health" }, + { name: "Brain", keywords: ["mental", "focus", "therapy"], group: "health" }, + { name: "Bath", keywords: ["bath", "relax", "selfcare"], group: "health" }, + { name: "Bed", keywords: ["rest", "nap", "lie down"], group: "health" }, + { name: "BedDouble", keywords: ["sleep", "bedtime", "night"], group: "health" }, + { name: "Moon", keywords: ["night", "sleep", "dark"], group: "health" }, + { name: "MoonStar", keywords: ["deep sleep", "night", "dream"], group: "health" }, + { name: "Cigarette", keywords: ["smoking", "tobacco", "nicotine"], group: "health" }, + { name: "CigaretteOff", keywords: ["no smoking", "quit", "smoke-free"], group: "health" }, + { name: "Baby", keywords: ["baby", "kids", "parenting"], group: "health" }, + + // ── food ───────────────────────────────────────────────────────────── + { name: "Apple", keywords: ["fruit", "healthy", "snack"], group: "food" }, + { name: "Pizza", keywords: ["fastfood", "junk", "takeout"], group: "food" }, + { name: "UtensilsCrossed", keywords: ["meal", "dinner", "restaurant"], group: "food" }, + { name: "Coffee", keywords: ["caffeine", "espresso", "morning"], group: "food" }, + { name: "Wine", keywords: ["alcohol", "drinks", "glass"], group: "food" }, + { name: "GlassWater", keywords: ["water", "hydration", "drink"], group: "food" }, + { name: "CandyOff", keywords: ["no sugar", "diet", "sweets"], group: "food" }, + + // ── weather ────────────────────────────────────────────────────────── + { name: "Sun", keywords: ["sunny", "warm", "bright"], group: "weather" }, + { name: "CloudSun", keywords: ["partly cloudy", "mild", "mixed"], group: "weather" }, + { name: "Cloud", keywords: ["overcast", "grey", "cloudy"], group: "weather" }, + { name: "CloudRain", keywords: ["rain", "wet", "storm"], group: "weather" }, + { name: "CloudMoon", keywords: ["clear night", "evening", "dusk"], group: "weather" }, + { name: "Leaf", keywords: ["autumn", "plant", "season"], group: "weather" }, + + // ── places ─────────────────────────────────────────────────────────── + { name: "House", keywords: ["home", "house", "indoors"], group: "places" }, + { name: "Home", keywords: ["home", "house", "indoors"], group: "places" }, + + // ── misc ───────────────────────────────────────────────────────────── + { name: "Tag", keywords: ["default", "generic", "label"], group: "misc" }, + { name: "Cat", keywords: ["cat", "pet", "animal"], group: "misc" }, + { name: "Dog", keywords: ["dog", "pet", "animal"], group: "misc" }, +] as const; + +/** Flat name list — the server-side allowlist source. */ +export const MOOD_TAG_ICON_NAMES: readonly string[] = MOOD_TAG_ICON_CATALOG.map( + (entry) => entry.name, +); diff --git a/src/lib/mood/tag-groups.ts b/src/lib/mood/tag-groups.ts new file mode 100644 index 000000000..6cd73d84d --- /dev/null +++ b/src/lib/mood/tag-groups.ts @@ -0,0 +1,27 @@ +import { prisma } from "@/lib/db"; + +/** + * v1.17.0 — group resolution for the custom-tag write paths. + * + * A `categoryKey` on the wire is either a seeded category slug + * (`feelings`, `custom`, …) or the caller's own `customcat:` group + * key. Resolution is owner-scoped by construction: another user's group — + * or an inactive / unknown key — resolves to null, which the handlers + * surface as a 422. This is the structural guard that keeps a custom tag's + * `categoryId` pointing only at shared reference data or the owner's own + * groups (and is what makes group deletion's re-home step sufficient). + */ +export async function resolveCategoryKeyForUser( + categoryKey: string, + userId: string, +): Promise { + const category = await prisma.moodTagCategory.findFirst({ + where: { + key: categoryKey, + isActive: true, + OR: [{ userId: null }, { userId }], + }, + select: { id: true }, + }); + return category?.id ?? null; +} diff --git a/src/lib/mood/tag-layout.ts b/src/lib/mood/tag-layout.ts new file mode 100644 index 000000000..bbc4ae814 --- /dev/null +++ b/src/lib/mood/tag-layout.ts @@ -0,0 +1,222 @@ +import { z } from "zod/v4"; + +/** + * v1.17.0 — per-user mood-tag layout blob (`User.moodTagLayoutJson`). + * + * Display-only presentation state, byte-for-byte the + * `medicationListLayoutJson` posture: the blob never carries authority over + * data (a placement referencing a hidden / archived / unknown tag or a + * deleted group is silently dropped at read time), PUT merges + * preserve-when-absent, and unknown keys are tolerated rather than rejected + * so a stale client can never wedge the read. + * + * Shape: + * groupOrder — category keys in display order; unknown dropped, missing + * appended in seeded `sortOrder`. + * placements — categoryKey → ordered tag keys ("what lives where, in + * what order"). A tag key appearing in some `placements[g]` + * renders in group `g` at that index; un-placed tags render + * in their home category after placed ones, in `sortOrder`. + * This is how a CATALOGUE tag "moves" into a user's group — + * its `categoryId` never changes. + */ + +/** Upper bound on a single category/tag key on the wire. */ +export const MOOD_TAG_LAYOUT_KEY_MAX_LENGTH = 80; +/** Upper bound on `groupOrder` entries (and `placements` record keys). */ +export const MOOD_TAG_LAYOUT_MAX_GROUPS = 50; +/** Upper bound on placement tag keys summed across every group. */ +export const MOOD_TAG_LAYOUT_MAX_PLACEMENTS = 400; + +const layoutKeySchema = z + .string() + .min(1) + .max(MOOD_TAG_LAYOUT_KEY_MAX_LENGTH); + +/** + * PUT body / stored-blob schema. Both fields optional so a client can PUT + * exactly the field it changed (preserve-when-absent merge in the handler). + */ +export const moodTagLayoutSchema = z + .object({ + groupOrder: z + .array(layoutKeySchema) + .max(MOOD_TAG_LAYOUT_MAX_GROUPS) + .optional(), + placements: z + .record(layoutKeySchema, z.array(layoutKeySchema)) + .optional(), + }) + .superRefine((value, ctx) => { + if (!value.placements) return; + const groupCount = Object.keys(value.placements).length; + if (groupCount > MOOD_TAG_LAYOUT_MAX_GROUPS) { + ctx.addIssue({ + code: "custom", + path: ["placements"], + message: `At most ${MOOD_TAG_LAYOUT_MAX_GROUPS} placement groups`, + }); + } + const total = Object.values(value.placements).reduce( + (sum, keys) => sum + keys.length, + 0, + ); + if (total > MOOD_TAG_LAYOUT_MAX_PLACEMENTS) { + ctx.addIssue({ + code: "custom", + path: ["placements"], + message: `At most ${MOOD_TAG_LAYOUT_MAX_PLACEMENTS} placement entries`, + }); + } + }); + +export interface MoodTagLayout { + groupOrder?: string[]; + placements?: Record; +} + +/** + * Parse the stored JSON column into a layout. A malformed / legacy blob + * degrades to the empty layout (seeded defaults) rather than failing the + * read — the blob is presentation, never data. + */ +export function parseStoredMoodTagLayout(value: unknown): MoodTagLayout { + const parsed = moodTagLayoutSchema.safeParse(value); + if (!parsed.success) return {}; + return { + ...(parsed.data.groupOrder !== undefined + ? { groupOrder: parsed.data.groupOrder } + : {}), + ...(parsed.data.placements !== undefined + ? { placements: parsed.data.placements } + : {}), + }; +} + +/** + * Preserve-when-absent merge: a PUT carrying only `groupOrder` must not wipe + * the stored `placements` and vice versa (the medications-layout contract). + */ +export function mergeMoodTagLayout( + stored: MoodTagLayout, + incoming: MoodTagLayout, +): MoodTagLayout { + return { + ...(incoming.groupOrder !== undefined + ? { groupOrder: incoming.groupOrder } + : stored.groupOrder !== undefined + ? { groupOrder: stored.groupOrder } + : {}), + ...(incoming.placements !== undefined + ? { placements: incoming.placements } + : stored.placements !== undefined + ? { placements: stored.placements } + : {}), + }; +} + +/** + * Drop every reference to a (deleted) group from the layout: its + * `groupOrder` entry and its `placements` bucket. Placements of OTHER + * groups are untouched — a tag placed elsewhere keeps its slot. + */ +export function stripGroupFromLayout( + layout: MoodTagLayout, + groupKey: string, +): MoodTagLayout { + const next: MoodTagLayout = {}; + if (layout.groupOrder !== undefined) { + next.groupOrder = layout.groupOrder.filter((k) => k !== groupKey); + } + if (layout.placements !== undefined) { + const rest = { ...layout.placements }; + delete rest[groupKey]; + next.placements = rest; + } + return next; +} + +/** + * Resolve the display order of category keys: layout order first (unknown + * keys dropped, duplicates collapsed), then every known key the layout does + * not mention, in the seeded order the caller passed. + */ +export function resolveGroupOrder( + categoryKeysInSeededOrder: string[], + groupOrder: string[] | undefined, +): string[] { + const known = new Set(categoryKeysInSeededOrder); + const ordered: string[] = []; + const seen = new Set(); + for (const key of groupOrder ?? []) { + if (!known.has(key) || seen.has(key)) continue; + seen.add(key); + ordered.push(key); + } + for (const key of categoryKeysInSeededOrder) { + if (seen.has(key)) continue; + seen.add(key); + ordered.push(key); + } + return ordered; +} + +export interface ResolvedMoodTagPlacement { + /** Category keys in final display order. */ + orderedCategoryKeys: string[]; + /** Tag keys per category key, placed-first then home-category remainder. */ + tagKeysByCategory: Map; +} + +/** + * Apply the full layout to the visible tag set. + * + * `tags` must arrive in global `sortOrder` (the un-placed fallback order) + * and contain only the tags the response will render — placements + * referencing anything else (hidden, archived, unknown, another user's) + * are dropped here by construction. A tag key claimed by two groups keeps + * its first claim (group-order wins). + */ +export function resolveMoodTagPlacement(args: { + categoryKeysInSeededOrder: string[]; + tags: Array<{ key: string; homeCategoryKey: string }>; + layout: MoodTagLayout; +}): ResolvedMoodTagPlacement { + const { categoryKeysInSeededOrder, tags, layout } = args; + const orderedCategoryKeys = resolveGroupOrder( + categoryKeysInSeededOrder, + layout.groupOrder, + ); + + const visibleTagKeys = new Set(tags.map((t) => t.key)); + const tagKeysByCategory = new Map( + orderedCategoryKeys.map((key) => [key, []]), + ); + + // 1. Explicit placements, walked in group display order so a duplicate + // claim resolves deterministically. + const placed = new Set(); + for (const categoryKey of orderedCategoryKeys) { + const placement = layout.placements?.[categoryKey]; + if (!placement) continue; + const bucket = tagKeysByCategory.get(categoryKey); + if (!bucket) continue; + for (const tagKey of placement) { + if (!visibleTagKeys.has(tagKey) || placed.has(tagKey)) continue; + placed.add(tagKey); + bucket.push(tagKey); + } + } + + // 2. Un-placed tags fall back to their home category, after the placed + // ones, keeping global sortOrder. A tag whose home category is not in + // the response (inactive) drops — same posture as the v1.13 read. + for (const tag of tags) { + if (placed.has(tag.key)) continue; + const bucket = tagKeysByCategory.get(tag.homeCategoryKey); + if (!bucket) continue; + bucket.push(tag.key); + } + + return { orderedCategoryKeys, tagKeysByCategory }; +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index dd60a0933..5f945b955 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -31,6 +31,7 @@ import { insightsPaths } from "./insights"; import { measurementPaths } from "./measurements"; import { medicationPaths, medicationResource } from "./medications"; import { metaPaths } from "./meta"; +import { moodPaths } from "./mood"; import { profilePaths } from "./profile"; import { syncPaths } from "./sync"; import { workoutPaths } from "./workouts"; @@ -51,6 +52,7 @@ export const openApiPaths: NonNullable = { ...coachFeedbackPaths, ...adminDiagnosticPaths, ...insightsPaths, + ...moodPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts new file mode 100644 index 000000000..136a7f0c0 --- /dev/null +++ b/src/lib/openapi/routes/mood.ts @@ -0,0 +1,436 @@ +/** + * OpenAPI route table — mood-tag taxonomy management (catalog read, custom + * tags, custom groups, per-user layout, catalogue hide). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Request schemas come from `src/lib/mood/{custom-tags,tag-layout}.ts` + * where they are shared with the runtime request parsing, so the wire + * contract stays single-source. Response DTOs are declared here mirroring + * the route handlers under `src/app/api/mood/tags/`. + * + * v1.13.0 shipped the custom-tag CRUD + hide surface outside the YAML; + * v1.17.0 adds groups + layout and registers the WHOLE surface so the iOS + * client gets a locked contract. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + createCustomTagSchema, + updateCustomTagSchema, + createCustomGroupSchema, + updateCustomGroupSchema, + hideCatalogueTagSchema, +} from "@/lib/mood/custom-tags"; +import { moodTagLayoutSchema } from "@/lib/mood/tag-layout"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +// ── Request schemas — annotated for spec emission ──────────────────── + +createCustomTagSchema.meta({ + id: "CreateMoodTagRequest", + description: + "Create a per-user custom mood tag (BINARY only). `label` is encrypted at rest. `icon` must come from the curated allowlist (unknown name → 422). `categoryKey` picks the home group — any seeded category key or one of the caller's own `customcat:` group keys; omitted → the seeded `custom` category. Capped at 50 active custom tags per user (422).", +}); + +updateCustomTagSchema.meta({ + id: "UpdateMoodTagRequest", + description: + "Partial custom-tag edit; at least one field required. `isActive:false` archives (history intact), `isActive:true` restores. `categoryKey` moves the tag to another group (a real categoryId move).", +}); + +createCustomGroupSchema.meta({ + id: "CreateMoodTagGroupRequest", + description: + "Create a per-user custom mood-tag group. `label` is encrypted at rest; `icon` must come from the curated allowlist. Capped at 12 active custom groups per user (422).", +}); + +updateCustomGroupSchema.meta({ + id: "UpdateMoodTagGroupRequest", + description: + "Partial custom-group edit; at least one field required. `isActive:false` retires the group without touching its tags.", +}); + +hideCatalogueTagSchema.meta({ + id: "HideMoodTagRequest", + description: + "Hide / show a CATALOGUE tag for the calling user. Custom tags are hidden via their own `isActive` flag on the custom PATCH instead (this route 400s a `custom:` key).", +}); + +moodTagLayoutSchema.meta({ + id: "MoodTagLayout", + description: + "Per-user mood-tag presentation blob. `groupOrder`: category keys in display order (unknown dropped, missing appended in seeded order). `placements`: categoryKey → ordered tag keys; a placed tag renders in that group at that index, un-placed tags follow in their home category. Display-only — placements referencing hidden/archived/unknown keys are silently dropped at read time. Both fields optional: PUT merges preserve-when-absent.", +}); + +// ── Response DTOs ───────────────────────────────────────────────────── + +const moodTagDto = z + .object({ + key: z.string(), + labelKey: z.string().nullable(), + label: z.string().nullable(), + icon: z.string().nullable(), + kind: z.enum(["BINARY", "RATED"]), + scaleMin: z.number().int(), + scaleMax: z.number().int(), + inverse: z.boolean(), + custom: z.boolean(), + hidden: z + .boolean() + .optional() + .describe( + "Catalogue tags only, present when `include` contains `hidden`.", + ), + archived: z + .boolean() + .optional() + .describe("Custom tags only, present when `include` contains `archived`."), + usageCount: z + .number() + .int() + .optional() + .describe( + "Live-entry link count for this user, present when `include` contains `usage`.", + ), + }) + .meta({ + id: "MoodTagDTO", + description: + "One effective mood tag. Render `label` when `custom`, else resolve `labelKey` against the locale.", + }); + +const moodTagCategoryDto = z + .object({ + key: z.string(), + labelKey: z.string().nullable(), + label: z.string().nullable(), + icon: z.string().nullable(), + custom: z.boolean(), + tags: z.array(moodTagDto), + }) + .meta({ + id: "MoodTagCategoryDTO", + description: + "One group of the effective tree, already per-user ordered (layout applied server-side). `custom: true` = the caller's own group (render `label`); seeded groups resolve `labelKey`.", + }); + +const moodTagTreeResponse = z.object({ + categories: z.array(moodTagCategoryDto), +}); + +const moodCustomTagResponse = z.object({ + key: z.string(), + labelKey: z.null(), + label: z.string().nullable(), + icon: z.string().nullable(), + kind: z.enum(["BINARY", "RATED"]), + scaleMin: z.number().int(), + scaleMax: z.number().int(), + inverse: z.boolean(), + custom: z.literal(true), +}); + +const moodCustomTagPatchResponse = moodCustomTagResponse.extend({ + isActive: z.boolean(), +}); + +const moodTagGroupResponse = z.object({ + key: z.string(), + labelKey: z.null(), + label: z.string().nullable(), + icon: z.string().nullable(), + custom: z.literal(true), +}); + +const moodTagGroupPatchResponse = moodTagGroupResponse.extend({ + isActive: z.boolean(), +}); + +const moodTagLayoutResolved = z + .object({ + groupOrder: z.array(z.string()), + placements: z.record(z.string(), z.array(z.string())), + }) + .meta({ + id: "MoodTagLayoutResolved", + description: + "The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category set, `placements` as stored.", + }); + +const notFoundResponse = { + "404": { + description: "Not found / not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +export const moodPaths: NonNullable = { + "/api/mood/tags": { + get: { + tags: ["Mood"], + summary: "Effective per-user mood-tag tree (v1.8.5 / v1.13.0 / v1.17.0)", + description: + "The fully-resolved, ordered Category → Tag tree: seeded catalogue + the caller's custom tags and groups, per-user layout (group order + placements) applied server-side, hidden catalogue tags omitted. `include` is a comma list: `hidden` (hidden catalogue tags, `hidden:true`), `archived` (own inactive custom tags, `archived:true`), `usage` (per-tag `usageCount`). With any flag set, the caller's own empty groups are kept; the plain read drops empty categories.", + requestParams: { + query: z.object({ + include: z + .string() + .optional() + .describe("Comma list of `hidden`, `archived`, `usage`."), + }), + }, + responses: { + "200": { + description: "The effective tag tree.", + content: { + "application/json": { + schema: dataEnvelope(moodTagTreeResponse, "MoodTagTreeEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/mood/tags/custom": { + post: { + tags: ["Mood"], + summary: "Create a custom mood tag (v1.13.0 / v1.17.0)", + description: + "Mints a `custom:` key, encrypts the label at rest, stores a BINARY tag owned by the caller under the chosen group (default: the seeded `custom` category). 422 over the 50-tag cap or on an unknown / foreign `categoryKey`.", + requestBody: { + required: true, + content: { "application/json": { schema: createCustomTagSchema } }, + }, + responses: { + "201": { + description: "Custom tag created.", + content: { + "application/json": { + schema: dataEnvelope( + moodCustomTagResponse, + "MoodCustomTagCreatedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/mood/tags/custom/{key}": { + patch: { + tags: ["Mood"], + summary: "Update a custom mood tag (v1.13.0 / v1.17.0)", + description: + "Owner-scoped: another user's key or a catalogue key 404s. Rename (re-encrypts), re-icon, archive/restore via `isActive`, or move groups via `categoryKey`.", + requestParams: { path: z.object({ key: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: updateCustomTagSchema } }, + }, + responses: { + "200": { + description: "Custom tag updated.", + content: { + "application/json": { + schema: dataEnvelope( + moodCustomTagPatchResponse, + "MoodCustomTagPatchEnvelope", + ), + }, + }, + }, + ...notFoundResponse, + ...stdResponses, + }, + }, + delete: { + tags: ["Mood"], + summary: "Archive or purge a custom mood tag (v1.13.0)", + description: + "Default: soft-deactivates (`isActive:false`) — history intact, restorable via PATCH. `?purge=true`: hard-deletes the row; the FK cascade removes its entry links. Owner-scoped 404.", + requestParams: { + path: z.object({ key: z.string() }), + query: z.object({ purge: z.enum(["true", "false"]).optional() }), + }, + responses: { + "200": { + description: "Archived (or purged).", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ key: z.string(), purged: z.boolean() }), + "MoodCustomTagDeleteEnvelope", + ), + }, + }, + }, + ...notFoundResponse, + ...stdResponses, + }, + }, + }, + "/api/mood/tags/{key}/hidden": { + put: { + tags: ["Mood"], + summary: "Hide / show a catalogue mood tag (v1.13.0)", + description: + "Per-user hide of a CATALOGUE tag (upserts / removes a `mood_tag_hidden` row). 400 on a `custom:` key — custom tags archive via their own `isActive`.", + requestParams: { path: z.object({ key: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: hideCatalogueTagSchema } }, + }, + responses: { + "200": { + description: "Visibility updated.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ key: z.string(), hidden: z.boolean() }), + "MoodTagHiddenEnvelope", + ), + }, + }, + }, + "400": { + description: "Custom-tag key — use the custom PATCH instead.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...notFoundResponse, + ...stdResponses, + }, + }, + }, + "/api/mood/tags/groups": { + post: { + tags: ["Mood"], + summary: "Create a custom mood-tag group (v1.17.0)", + description: + "Mints a `customcat:` key, encrypts the label at rest, stores a group owned by the caller. 422 over the 12-group cap.", + requestBody: { + required: true, + content: { "application/json": { schema: createCustomGroupSchema } }, + }, + responses: { + "201": { + description: "Custom group created.", + content: { + "application/json": { + schema: dataEnvelope( + moodTagGroupResponse, + "MoodTagGroupCreatedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/mood/tags/groups/{key}": { + patch: { + tags: ["Mood"], + summary: "Update a custom mood-tag group (v1.17.0)", + description: + "Owner-scoped: another user's key or a seeded category key 404s. Rename (re-encrypts), re-icon, retire/restore via `isActive`.", + requestParams: { path: z.object({ key: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: updateCustomGroupSchema } }, + }, + responses: { + "200": { + description: "Custom group updated.", + content: { + "application/json": { + schema: dataEnvelope( + moodTagGroupPatchResponse, + "MoodTagGroupPatchEnvelope", + ), + }, + }, + }, + ...notFoundResponse, + ...stdResponses, + }, + }, + delete: { + tags: ["Mood"], + summary: "Delete a custom mood-tag group (v1.17.0)", + description: + "Non-destructive: the group's custom tags re-home to the seeded `custom` category, catalogue-tag placements evaporate back to their seeded category, the layout drops the group, then the row soft-deactivates (default) or hard-deletes (`?purge=true`). No tag and no entry link is deleted.", + requestParams: { + path: z.object({ key: z.string() }), + query: z.object({ purge: z.enum(["true", "false"]).optional() }), + }, + responses: { + "200": { + description: "Group deleted; `rehomedCount` custom tags moved.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + key: z.string(), + purged: z.boolean(), + rehomedCount: z.number().int(), + }), + "MoodTagGroupDeleteEnvelope", + ), + }, + }, + }, + ...notFoundResponse, + ...stdResponses, + }, + }, + }, + "/api/mood/tags/layout": { + get: { + tags: ["Mood"], + summary: "Read the per-user mood-tag layout (v1.17.0)", + description: + "Returns the stored blob merged over defaults: `groupOrder` resolved against the caller's effective category set (seeded + own groups), `placements` as stored.", + responses: { + "200": { + description: "Resolved layout.", + content: { + "application/json": { + schema: dataEnvelope( + moodTagLayoutResolved, + "MoodTagLayoutGetEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Mood"], + summary: "Update the per-user mood-tag layout (v1.17.0)", + description: + "Preserve-when-absent merge: a `groupOrder`-only PUT keeps the stored `placements` and vice versa. Bounded: ≤ 50 groups, ≤ 400 placement entries, keys ≤ 80 chars. Returns the resolved layout.", + requestBody: { + required: true, + content: { "application/json": { schema: moodTagLayoutSchema } }, + }, + responses: { + "200": { + description: "Merged + resolved layout.", + content: { + "application/json": { + schema: dataEnvelope( + moodTagLayoutResolved, + "MoodTagLayoutPutEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/query-keys/mood.ts b/src/lib/query-keys/mood.ts index 9fb35b159..8a60c3013 100644 --- a/src/lib/query-keys/mood.ts +++ b/src/lib/query-keys/mood.ts @@ -48,4 +48,21 @@ export const moodKeys = { * `staleTime` is fine. */ moodTagCatalog: () => ["mood-tag-catalog"] as const, + /** + * v1.17 — the tag-MANAGEMENT read (`/api/mood/tags?include= + * hidden,archived,usage`). Rides under the `["mood-tag-catalog"]` + * prefix so one `invalidateQueries({ queryKey: ["mood-tag-catalog"] })` + * after any management mutation refreshes both this read and the + * picker read above (same trick as `moodEntriesList` under + * `["mood-entries"]`). The scope segment keeps the two queryFn shapes + * on different keys — same-key/different-shape silently poisons the + * cache. + */ + moodTagManage: () => ["mood-tag-catalog", "manage"] as const, + /** + * v1.17 — the per-user picker layout blob (`/api/mood/tags/layout`): + * group order + per-group tag placement. Its own root key — layout + * writes invalidate it alongside the catalog prefix. + */ + moodTagLayout: () => ["mood-tag-layout"] as const, }; diff --git a/tests/integration/mood-tag-groups.test.ts b/tests/integration/mood-tag-groups.test.ts new file mode 100644 index 000000000..936dcb65d --- /dev/null +++ b/tests/integration/mood-tag-groups.test.ts @@ -0,0 +1,322 @@ +/** + * v1.17.0 — custom mood-tag groups + per-user layout against real Postgres. + * + * Pins the group lifecycle the unit mocks can't: + * - group create (encrypted label round-trip via the effective read), + * cap 422, cross-user 404 on PATCH/DELETE + * - custom-tag create into an own group via `categoryKey` (and the 422 on + * a foreign group key) + * - group delete: own tags re-home to the seeded `custom` category, the + * layout blob drops the group, default = retire vs `?purge=true` = + * hard delete of the emptied row + * - layout PUT preserve-when-absent + the resolved GET tree honouring + * group order and a catalogue-tag placement + * - `include=archived` surfacing an archived custom tag + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_A = "user-mtg-a"; +const USER_B = "user-mtg-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 createUserSession(userId: string, username: string) { + await getPrismaClient().user.create({ + data: { + id: userId, + username, + email: `${username}@example.test`, + timezone: "Europe/Berlin", + }, + }); + return getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); +} + +async function actAs(userId: string, username: string) { + const session = await createUserSession(userId, username); + cookieJar.set("healthlog_session", session.id); +} + +function jsonReq(url: string, method: string, body: unknown): NextRequest { + return new NextRequest(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const params = (key: string) => ({ params: Promise.resolve({ key }) }); + +async function createGroup(label: string, icon?: string): Promise { + const { POST } = await import("@/app/api/mood/tags/groups/route"); + const res = await POST( + jsonReq("http://localhost/api/mood/tags/groups", "POST", { label, icon }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { key: string } }; + return body.data.key; +} + +async function createTag( + label: string, + categoryKey?: string, +): Promise { + const { POST } = await import("@/app/api/mood/tags/custom/route"); + const res = await POST( + jsonReq("http://localhost/api/mood/tags/custom", "POST", { + label, + ...(categoryKey ? { categoryKey } : {}), + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { key: string } }; + return body.data.key; +} + +interface TreeCategory { + key: string; + label: string | null; + custom: boolean; + tags: Array<{ key: string; label: string | null; archived?: boolean }>; +} + +async function readTree(include?: string): Promise { + const { GET } = await import("@/app/api/mood/tags/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/mood/tags${include ? `?include=${include}` : ""}`, + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { categories: TreeCategory[] } }; + return body.data.categories; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("custom mood-tag groups (real Postgres)", () => { + it("creates a group, stores the label encrypted, and resolves it on the management read", async () => { + await actAs(USER_A, "mtg-a"); + const groupKey = await createGroup("Therapie", "Stethoscope"); + expect(groupKey.startsWith("customcat:")).toBe(true); + + const row = await getPrismaClient().moodTagCategory.findUnique({ + where: { key: groupKey }, + }); + expect(row?.userId).toBe(USER_A); + expect(row?.labelEncrypted).not.toBeNull(); + expect(row?.labelEncrypted).not.toContain("Therapie"); + + // Empty group surfaces only on a management read. + const plain = await readTree(); + expect(plain.find((c) => c.key === groupKey)).toBeUndefined(); + const manage = await readTree("hidden,archived"); + expect(manage.find((c) => c.key === groupKey)).toMatchObject({ + custom: true, + label: "Therapie", + tags: [], + }); + }); + + it("422s over the 12-group cap", async () => { + await actAs(USER_A, "mtg-a"); + for (let i = 0; i < 12; i++) await createGroup(`G${i}`); + const { POST } = await import("@/app/api/mood/tags/groups/route"); + const res = await POST( + jsonReq("http://localhost/api/mood/tags/groups", "POST", { label: "Nope" }), + ); + expect(res.status).toBe(422); + }); + + it("404s a cross-user PATCH / DELETE and a 422 on a foreign categoryKey", async () => { + await actAs(USER_A, "mtg-a"); + const groupKey = await createGroup("Privat"); + + await actAs(USER_B, "mtg-b"); + const { PATCH, DELETE } = await import( + "@/app/api/mood/tags/groups/[key]/route" + ); + const patch = await PATCH( + jsonReq(`http://localhost/api/mood/tags/groups/${groupKey}`, "PATCH", { + label: "Hijack", + }), + params(groupKey), + ); + expect(patch.status).toBe(404); + const del = await DELETE( + new NextRequest(`http://localhost/api/mood/tags/groups/${groupKey}`), + params(groupKey), + ); + expect(del.status).toBe(404); + + // B cannot create a tag inside A's group either. + const { POST } = await import("@/app/api/mood/tags/custom/route"); + const tag = await POST( + jsonReq("http://localhost/api/mood/tags/custom", "POST", { + label: "X", + categoryKey: groupKey, + }), + ); + expect(tag.status).toBe(422); + }); + + it("group delete re-homes own tags, strips the layout, retires by default and purges on demand", async () => { + await actAs(USER_A, "mtg-a"); + const groupKey = await createGroup("Hobbys 2"); + const tagKey = await createTag("Bouldern", groupKey); + + // Seed a layout that references the group. + const { PUT: putLayout } = await import( + "@/app/api/mood/tags/layout/route" + ); + const layoutRes = await putLayout( + jsonReq("http://localhost/api/mood/tags/layout", "PUT", { + groupOrder: [groupKey, "feelings"], + placements: { [groupKey]: ["happy"] }, + }), + ); + expect(layoutRes.status).toBe(200); + + const { DELETE } = await import("@/app/api/mood/tags/groups/[key]/route"); + const res = await DELETE( + new NextRequest(`http://localhost/api/mood/tags/groups/${groupKey}`), + params(groupKey), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { purged: boolean; rehomedCount: number }; + }; + expect(body.data).toMatchObject({ purged: false, rehomedCount: 1 }); + + // The tag survived, re-homed to the seeded custom category. + const tagRow = await getPrismaClient().moodTag.findUnique({ + where: { key: tagKey }, + }); + expect(tagRow?.categoryId).toBe("mtc_custom"); + // The group row is retired, not gone. + const groupRow = await getPrismaClient().moodTagCategory.findUnique({ + where: { key: groupKey }, + }); + expect(groupRow?.isActive).toBe(false); + // The layout no longer references the group. + const userRow = await getPrismaClient().user.findUnique({ + where: { id: USER_A }, + select: { moodTagLayoutJson: true }, + }); + expect(JSON.stringify(userRow?.moodTagLayoutJson)).not.toContain(groupKey); + + // Purge a second (empty) group → the row is gone. + const group2 = await createGroup("Weg damit"); + const purge = await DELETE( + new NextRequest( + `http://localhost/api/mood/tags/groups/${group2}?purge=true`, + ), + params(group2), + ); + expect(purge.status).toBe(200); + expect( + await getPrismaClient().moodTagCategory.findUnique({ + where: { key: group2 }, + }), + ).toBeNull(); + }); + + it("layout PUT merges preserve-when-absent and the GET tree honours order + placement", async () => { + await actAs(USER_A, "mtg-a"); + const groupKey = await createGroup("Sport"); + await createTag("Klettern", groupKey); + + const { PUT: putLayout, GET: getLayout } = await import( + "@/app/api/mood/tags/layout/route" + ); + // 1st PUT: placements only. + await putLayout( + jsonReq("http://localhost/api/mood/tags/layout", "PUT", { + placements: { [groupKey]: ["happy"] }, + }), + ); + // 2nd PUT: groupOrder only — must keep the stored placements. + await putLayout( + jsonReq("http://localhost/api/mood/tags/layout", "PUT", { + groupOrder: [groupKey], + }), + ); + const layoutRes = await getLayout(); + const layout = (await layoutRes.json()) as { + data: { groupOrder: string[]; placements: Record }; + }; + expect(layout.data.groupOrder[0]).toBe(groupKey); + expect(layout.data.placements).toEqual({ [groupKey]: ["happy"] }); + + // The effective tree leads with the group and carries the placed + // catalogue tag inside it (placement, not a categoryId change). + const tree = await readTree(); + expect(tree[0].key).toBe(groupKey); + expect(tree[0].tags.map((t) => t.key)).toContain("happy"); + const happyRow = await getPrismaClient().moodTag.findUnique({ + where: { key: "happy" }, + select: { category: { select: { key: true } } }, + }); + expect(happyRow?.category.key).not.toBe(groupKey); + }); + + it("include=archived surfaces an archived custom tag for restore", async () => { + await actAs(USER_A, "mtg-a"); + const tagKey = await createTag("Alt"); + const { DELETE } = await import("@/app/api/mood/tags/custom/[key]/route"); + const res = await DELETE( + new NextRequest(`http://localhost/api/mood/tags/custom/${tagKey}`), + params(tagKey), + ); + expect(res.status).toBe(200); + + const plain = await readTree(); + expect( + plain.flatMap((c) => c.tags).find((t) => t.key === tagKey), + ).toBeUndefined(); + + const manage = await readTree("archived"); + const archived = manage + .flatMap((c) => c.tags) + .find((t) => t.key === tagKey); + expect(archived).toMatchObject({ archived: true, label: "Alt" }); + }); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index c55e3ae6a..0a9162eb5 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -31,6 +31,16 @@ let catalogueSnapshot: | Awaited> | null = null; +/** + * v1.17.0 — same treatment for the seeded mood-tag categories: + * `mood_tag_categories.user_id` now FKs to `users` (custom groups), so the + * users TRUNCATE cascades into the category table too. The categories must + * restore BEFORE the tags (tags FK into categories). + */ +let categorySnapshot: + | Awaited> + | null = null; + /** * Truncate every user-facing table in dependency-safe order using * `TRUNCATE … RESTART IDENTITY CASCADE`. Call from beforeEach() to make @@ -92,12 +102,24 @@ export async function truncateAllTables(client: PrismaClient): Promise { where: { userId: null }, }); } + if (categorySnapshot === null) { + categorySnapshot = await client.moodTagCategory.findMany({ + where: { userId: null }, + }); + } const quoted = tables.map((t) => `"${t}"`).join(", "); await client.$executeRawUnsafe( `TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`, ); + // Categories first — the tag rows FK into them. + if (categorySnapshot.length > 0) { + await client.moodTagCategory.createMany({ + data: categorySnapshot, + skipDuplicates: true, + }); + } if (catalogueSnapshot.length > 0) { await client.moodTag.createMany({ data: catalogueSnapshot,