diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..e6e09540c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -60081,6 +60081,19 @@ components: externalId: description: Echoed back when the entry carried one, for client mapping. type: string + droppedTagKeys: + description: "`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's + custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every + submitted key landed. The entry itself was written." + type: array + items: + type: string + droppedFactorKeys: + description: "`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. + Present only when non-empty; absent means every submitted factor landed." + type: array + items: + type: string required: - index - status @@ -60722,6 +60735,19 @@ components: - $ref: "#/components/schemas/MoodContextOutput" - type: "null" description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. + droppedTagKeys: + description: "Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, + another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An + archived tag the edit preserved is on the entry and is not listed." + type: array + items: + type: string + droppedFactorKeys: + description: "Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present + only when non-empty." + type: array + items: + type: string required: - id - userId diff --git a/messages/de.json b/messages/de.json index cecd50085..553d63b6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Damit werden die eingegebene Stimmung, Tags und Notiz gelöscht. Das lässt sich nicht rückgängig machen.", "formResetConfirmAction": "Zurücksetzen", "saveError": "Fehler beim Speichern", + "tagsNotSaved": "Gespeichert. Tags, die es nicht mehr gibt, wurden weggelassen.", "deleteError": "Fehler beim Löschen", "deletedToast": "Eintrag gelöscht", "restoredToast": "Eintrag wiederhergestellt", diff --git a/messages/en.json b/messages/en.json index 322af6ad7..55d0718d6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "This clears the mood, tags and note you have entered. This cannot be undone.", "formResetConfirmAction": "Reset", "saveError": "Error saving", + "tagsNotSaved": "Saved. Tags that are no longer available were left off.", "deleteError": "Error deleting", "deletedToast": "Entry deleted", "restoredToast": "Entry restored", diff --git a/messages/es.json b/messages/es.json index 06010c86e..1718c1acc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Esto borra el estado de ánimo, las etiquetas y la nota que has introducido. No se puede deshacer.", "formResetConfirmAction": "Restablecer", "saveError": "Error al guardar", + "tagsNotSaved": "Guardado. Se omitieron las etiquetas que ya no están disponibles.", "deleteError": "Error al eliminar", "deletedToast": "Entrada eliminada", "restoredToast": "Entrada restaurada", diff --git a/messages/fr.json b/messages/fr.json index 5135074b2..0afad1da7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Cela efface l’humeur, les tags et la note que vous avez saisis. Cette action est irréversible.", "formResetConfirmAction": "Réinitialiser", "saveError": "Erreur lors de l’enregistrement", + "tagsNotSaved": "Enregistré. Les étiquettes qui ne sont plus disponibles ont été omises.", "deleteError": "Erreur lors de la suppression", "deletedToast": "Entrée supprimée", "restoredToast": "Entrée restaurée", diff --git a/messages/it.json b/messages/it.json index 7ffad325e..883d3c4a1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Questo cancella umore, tag e nota inseriti. L’operazione non può essere annullata.", "formResetConfirmAction": "Reimposta", "saveError": "Errore durante il salvataggio", + "tagsNotSaved": "Salvato. I tag non più disponibili sono stati omessi.", "deleteError": "Errore durante l’eliminazione", "deletedToast": "Voce eliminata", "restoredToast": "Voce ripristinata", diff --git a/messages/ko.json b/messages/ko.json index 28038e56f..f814ab916 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "입력한 기분, 태그, 메모가 모두 지워져요. 되돌릴 수 없어요.", "formResetConfirmAction": "초기화", "saveError": "저장하지 못했어요", + "tagsNotSaved": "저장했습니다. 더 이상 사용할 수 없는 태그는 제외했습니다.", "deleteError": "삭제하지 못했어요", "deletedToast": "기록을 삭제했어요", "restoredToast": "기록을 되살렸어요", diff --git a/messages/pl.json b/messages/pl.json index 295afc34f..7e45f4587 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -847,6 +847,7 @@ "formResetConfirmBody": "Spowoduje to usunięcie wprowadzonego nastroju, tagów i notatki. Tej operacji nie można cofnąć.", "formResetConfirmAction": "Resetuj", "saveError": "Błąd zapisu", + "tagsNotSaved": "Zapisano. Pominięto tagi, które nie są już dostępne.", "deleteError": "Błąd usuwania", "deletedToast": "Wpis usunięty", "restoredToast": "Wpis przywrócony", diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index 3570b2b13..fc01e1444 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -126,8 +126,8 @@ beforeEach(() => { txClient.moodEntryTagLink.findMany.mockResolvedValue([]); txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 }); txClient.moodContext.findUnique.mockResolvedValue(null); - vi.mocked(replaceTagLinks).mockResolvedValue(undefined); - vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined); + vi.mocked(replaceTagLinks).mockResolvedValue([]); + vi.mocked(replaceRatedFactorLinks).mockResolvedValue([]); // v1.7.0 sync — PUT now looks the row up via `findFirst` with a // `deletedAt: null` guard (refuses to resurrect-edit a tombstone). vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue( @@ -316,6 +316,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { }; vi.mocked(replaceTagLinks).mockImplementationOnce(async () => { draft.binary = ["calm"]; + return []; }); vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => { draft.rated = [{ key: "factor_work", rating: 5 }]; diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 94cb5f59e..816293996 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -25,6 +25,7 @@ import { RatedFactorOutOfRangeError, replaceRatedFactorLinks, replaceTagLinks, + droppedLinkKeysForWire, } from "@/lib/mood/tag-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -197,11 +198,15 @@ export const PUT = apiHandler( // Omission preserves the corresponding link set. Explicit null/empty // clears it, matching the validated update contract. - if (data.tagKeys !== undefined) { - await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx); - } + // Each replacement returns the submitted keys it could not store, + // reported on the response rather than dropped in silence. + const droppedTagKeys = + data.tagKeys !== undefined + ? await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx) + : []; + let droppedFactorKeys: string[] = []; if (data.ratedFactors !== undefined) { - await replaceRatedFactorLinks( + droppedFactorKeys = await replaceRatedFactorLinks( id, user.id, data.ratedFactors ?? [], @@ -236,6 +241,7 @@ export const PUT = apiHandler( return { entry: updated, contextOutcome, + droppedKeys: { droppedTagKeys, droppedFactorKeys }, persistedContext: storedContext, persistedTagKeys: links .filter((link) => link.moodTag.kind !== "RATED") @@ -282,6 +288,7 @@ export const PUT = apiHandler( persistedRatedFactors, persistedContext, contextOutcome, + droppedKeys, } = transactionOutcome.result; await auditLog("moodEntry.update", { @@ -315,7 +322,12 @@ export const PUT = apiHandler( annotate({ action: { name: "mood-entries.update" }, - meta: { moodEntryId: id, mood_context: contextOutcome }, + meta: { + moodEntryId: id, + mood_context: contextOutcome, + dropped_tag_keys: droppedKeys.droppedTagKeys.length, + dropped_factor_keys: droppedKeys.droppedFactorKeys.length, + }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -360,6 +372,10 @@ export const PUT = apiHandler( ratedFactors: persistedRatedFactors, // v1.38 — the stored context after the edit, or null when there is none. context: persistedContext ? contextForWire(persistedContext) : null, + // Submitted keys that are not on the entry after the edit, each list + // present only when non-empty (an archived link the edit preserved is + // on the entry and is not listed). + ...droppedLinkKeysForWire(droppedKeys), }); }, ); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 3af7aca11..08f0b7de7 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -66,8 +66,10 @@ vi.mock("@/lib/mood/tag-links", async () => { "@/lib/mood/tag-links", ); return { - createTagLinks: vi.fn().mockResolvedValue(undefined), + createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS), RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError, + droppedLinkKeysForWire: actual.droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS, }; }); @@ -137,7 +139,10 @@ beforeEach(() => { (fn as (tx: typeof txClient) => unknown)(txClient), ); txClient.moodEntryTagLink.findMany.mockResolvedValue([]); - vi.mocked(createTagLinks).mockResolvedValue(undefined); + vi.mocked(createTagLinks).mockResolvedValue({ + droppedTagKeys: [], + droppedFactorKeys: [], + }); // `reset` blanks the post-commit best-effort mock; restore the promise // return so `recompute(...)` awaits. vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined); @@ -349,6 +354,37 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () expect(body.data.tagKeys).toEqual(["happy"]); }); + it("names the keys it did not store on the 201, and omits the fields when all landed", async () => { + txClient.moodEntry.create.mockResolvedValue({ + id: "mood-1", + tags: null, + moodLoggedAt: new Date(VALID_BODY.moodLoggedAt), + mood: "GUT", + note: null, + source: "MANUAL", + date: "2026-06-01", + }); + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { moodTag: { key: "happy" } }, + ]); + vi.mocked(createTagLinks).mockResolvedValueOnce({ + droppedTagKeys: ["custom:archived"], + droppedFactorKeys: [], + }); + + const res = await POST( + postReq({ ...VALID_BODY, tagKeys: ["happy", "custom:archived"] }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: Record }; + expect(body.data.droppedTagKeys).toEqual(["custom:archived"]); + expect(body.data).not.toHaveProperty("droppedFactorKeys"); + + const clean = await POST(postReq({ ...VALID_BODY, tagKeys: ["happy"] })); + const cleanBody = (await clean.json()) as { data: Record }; + expect(cleanBody.data).not.toHaveProperty("droppedTagKeys"); + }); + it("rolls the entry back when the tag-link write fails (no commit)", async () => { txClient.moodEntry.create.mockResolvedValue({ id: "mood-2", diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index e897edbdc..35907e376 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -55,8 +55,10 @@ vi.mock("@/lib/mood/tag-links", async () => { "@/lib/mood/tag-links", ); return { - createTagLinks: vi.fn().mockResolvedValue(undefined), + createTagLinks: vi.fn().mockResolvedValue(actual.NO_DROPPED_LINK_KEYS), RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError, + droppedLinkKeysForWire: actual.droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS: actual.NO_DROPPED_LINK_KEYS, }; }); @@ -140,7 +142,10 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); - vi.mocked(createTagLinks).mockResolvedValue(undefined); + vi.mocked(createTagLinks).mockResolvedValue({ + droppedTagKeys: [], + droppedFactorKeys: [], + }); }); describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { @@ -317,6 +322,45 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { ); }); + it("lists the keys that did not land on that entry's result, and only there", async () => { + vi.mocked(createTagLinks) + .mockResolvedValueOnce({ + droppedTagKeys: ["custom:archived"], + droppedFactorKeys: ["factor_retired"], + }) + .mockResolvedValueOnce({ droppedTagKeys: [], droppedFactorKeys: [] }); + const res = await POST( + postReq({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tagKeys: ["movies", "custom:archived"], + ratedFactors: [{ key: "factor_retired", rating: 2 }], + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + tagKeys: ["movies"], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { entries: Array> }; + }; + expect(json.data.entries[0]).toMatchObject({ + status: "inserted", + droppedTagKeys: ["custom:archived"], + droppedFactorKeys: ["factor_retired"], + }); + // Nothing dropped: the fields are absent, so the result reads exactly + // as it did before they existed. + expect(json.data.entries[1]).not.toHaveProperty("droppedTagKeys"); + expect(json.data.entries[1]).not.toHaveProperty("droppedFactorKeys"); + }); + it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => { const res = await POST( postReq({ diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 3a504fa71..d22415e71 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -58,7 +58,11 @@ import { persistMoodContext } from "@/lib/mood/context"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { createTagLinks } from "@/lib/mood/tag-links"; +import { + createTagLinks, + droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS, +} from "@/lib/mood/tag-links"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -73,8 +77,9 @@ const bulkEntrySchema = z.object({ * Without this the bulk path Zod-stripped the field, so iOS-sent * taxonomy links were silently dropped on the adopt-on-pair backfill. * The server resolves each key to a `MoodTag` row and writes the - * `MoodEntryTagLink` join; unknown keys are dropped silently (the - * catalog is the source of truth). Bounds match the single-entry + * `MoodEntryTagLink` join; an unknown or archived key is not stored (the + * catalog is the source of truth) and is listed on that entry's result + * as `droppedTagKeys`. Bounds match the single-entry * `structuredTagKeys` schema so one entry can't fan out an unbounded * link set. */ @@ -178,6 +183,11 @@ interface EntryResult { // result so iOS can map a server row id onto its local SwiftData row // without re-deriving it. Omitted when the entry sent no externalId. externalId?: string; + // Submitted keys the catalog did not resolve (unknown, archived, another + // account's custom tag, or the wrong kind). Present only when non-empty, + // on an "inserted" or "duplicate" result: the entry landed, these did not. + droppedTagKeys?: string[]; + droppedFactorKeys?: string[]; } export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); @@ -251,6 +261,8 @@ async function postBulk(request: NextRequest): Promise { const results: EntryResult[] = []; let inserted = 0; let duplicates = 0; + let droppedTagKeyCount = 0; + let droppedFactorKeyCount = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; @@ -437,7 +449,7 @@ async function postBulk(request: NextRequest): Promise { // the row committed) left a persisted mood entry with no links while the // per-entry catch reported the entry as "skipped" — the client believes // nothing saved and a half-written row is on disk. - const result = await prisma.$transaction(async (tx) => { + const written = await prisma.$transaction(async (tx) => { const upserted = await tx.moodEntry.upsert({ where: probeWhere, create: { @@ -489,11 +501,14 @@ async function postBulk(request: NextRequest): Promise { // an entry reported as skipped really did write nothing. await persistMoodContext(tx, upserted.id, user.id, parsedContext.data); + // The keys the catalog did not resolve are reported on this entry's + // result: "inserted" alone told a client its tags had landed. + let droppedKeys = NO_DROPPED_LINK_KEYS; if ( (entry.tagKeys && entry.tagKeys.length > 0) || (entry.ratedFactors && entry.ratedFactors.length > 0) ) { - await createTagLinks( + droppedKeys = await createTagLinks( upserted.id, user.id, entry.tagKeys ?? [], @@ -502,8 +517,12 @@ async function postBulk(request: NextRequest): Promise { ); } - return upserted; + return { upserted, droppedKeys }; }); + const result = written.upserted; + const droppedKeys = written.droppedKeys; + droppedTagKeyCount += droppedKeys.droppedTagKeys.length; + droppedFactorKeyCount += droppedKeys.droppedFactorKeys.length; if (existing) { duplicates += 1; @@ -512,6 +531,7 @@ async function postBulk(request: NextRequest): Promise { status: "duplicate", id: result.id, ...(entry.externalId ? { externalId: entry.externalId } : {}), + ...droppedLinkKeysForWire(droppedKeys), }); } else { inserted += 1; @@ -520,6 +540,7 @@ async function postBulk(request: NextRequest): Promise { status: "inserted", id: result.id, ...(entry.externalId ? { externalId: entry.externalId } : {}), + ...droppedLinkKeysForWire(droppedKeys), }); } } catch (err: unknown) { @@ -586,6 +607,8 @@ async function postBulk(request: NextRequest): Promise { inserted, duplicates, skipped: skipped.length, + dropped_tag_keys: droppedTagKeyCount, + dropped_factor_keys: droppedFactorKeyCount, ...(unstableShapes.length > 0 ? unstableExternalIdMeta("mood.bulk", unstableShapes) : {}), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 86b14901a..9f23a965f 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -33,6 +33,8 @@ import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { createTagLinks, + droppedLinkKeysForWire, + NO_DROPPED_LINK_KEYS, RatedFactorOutOfRangeError, } from "@/lib/mood/tag-links"; @@ -219,6 +221,7 @@ async function postMoodEntry(request: NextRequest) { persistedRatedFactors, persistedContext, contextOutcome, + droppedKeys, } = await prisma.$transaction(async (tx) => { // v1.12.1 — when the client supplies a source-stable `externalId`, // upsert on the NULL-distinct `(userId, source, externalId)` key so @@ -287,14 +290,16 @@ async function postMoodEntry(request: NextRequest) { }, }); + let droppedKeys = NO_DROPPED_LINK_KEYS; if ( (tagKeys && tagKeys.length > 0) || (ratedFactors && ratedFactors.length > 0) ) { - // Unknown / non-RATED keys are dropped inside the helper (the - // catalog is the source of truth). An out-of-scale rating - // throws `RatedFactorOutOfRangeError`, rolling the tx back. - await createTagLinks( + // Unknown, archived or non-RATED keys are not stored (the catalog + // is the source of truth); the helper returns them so the response + // can say so. An out-of-scale rating throws + // `RatedFactorOutOfRangeError`, rolling the tx back. + droppedKeys = await createTagLinks( created.id, user.id, tagKeys ?? [], @@ -333,6 +338,7 @@ async function postMoodEntry(request: NextRequest) { return { entry: created, contextOutcome, + droppedKeys, persistedContext: storedContext, persistedTagKeys: links .filter((link) => link.moodTag.kind !== "RATED") @@ -360,7 +366,13 @@ async function postMoodEntry(request: NextRequest) { // boolean, because "the request said nothing" and "the request cleared // it" are different acts and a dashboard that folded them together // could not tell an abandoned section from a deliberate one. - meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome }, + meta: { + moodEntryId: entry.id, + mood, + mood_context: contextOutcome, + dropped_tag_keys: droppedKeys.droppedTagKeys.length, + dropped_factor_keys: droppedKeys.droppedFactorKeys.length, + }, }); // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. @@ -398,6 +410,10 @@ async function postMoodEntry(request: NextRequest) { // when the entry carries none, which is a different answer from an // object full of nulls and reads as one. context: persistedContext ? contextForWire(persistedContext) : null, + // The submitted keys that were not stored (unknown, archived, or the + // wrong kind), each list present only when non-empty. Without them + // a 201 read as "everything landed" when part of it had not. + ...droppedLinkKeysForWire(droppedKeys), }, 201, ); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 23b18f965..9b14f4697 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -63,6 +63,10 @@ import { import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context"; import { MoodQuickTags } from "./mood-quick-tags"; import { useRecentTags } from "./recent-tags"; +import { + droppedAnyLinkKeys, + type DroppedLinkKeysWire, +} from "@/lib/mood/dropped-link-keys"; import { moodFaceIcon } from "./mood-tag-icons"; // One catalogue for the chip strip and for every server-side reader of what it // writes. Split apart, the write side gained a locale and the read side did @@ -219,7 +223,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { const trimmedNote = note.trim(); - await apiPost("/api/mood-entries", { + const saved = await apiPost("/api/mood-entries", { mood, tags: tags.length > 0 ? tags : undefined, tagKeys: tagKeys.length > 0 ? tagKeys : undefined, @@ -243,6 +247,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { await invalidateKeys(queryClient, moodDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); + // The entry landed, but a tag picked here may have been archived or + // deleted elsewhere since the picker loaded; the server says so. + if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); onSuccess?.(); } catch (err) { setError(localizedApiError(err, t, "mood.saveError")); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 3f2144d57..6d68cd3a2 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -112,6 +112,10 @@ import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { MOOD_LIST_PAGE_SIZE as PAGE_SIZE } from "@/lib/mood/list-page-size"; import { localizedApiError } from "@/lib/api/localized-error"; +import { + droppedAnyLinkKeys, + type DroppedLinkKeysWire, +} from "@/lib/mood/dropped-link-keys"; // Re-export the score map under the legacy local name to keep the // rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single @@ -471,7 +475,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { note: string | null; moodLoggedAt: string; }) => { - await apiPut(`/api/mood-entries/${id}`, { + return apiPut(`/api/mood-entries/${id}`, { mood, tags, tagKeys, @@ -488,7 +492,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { moodLoggedAt, }); }, - onSuccess: async () => { + onSuccess: async (saved) => { + // A tag picked in the dialog may have been archived or deleted + // elsewhere since it opened; the server names what it did not store. + if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); await invalidateKeys(queryClient, moodDependentKeys); await refetchInactiveDailyReads(queryClient); setEditSeed({ diff --git a/src/lib/mood/__tests__/dropped-link-keys.test.ts b/src/lib/mood/__tests__/dropped-link-keys.test.ts new file mode 100644 index 000000000..862bf5867 --- /dev/null +++ b/src/lib/mood/__tests__/dropped-link-keys.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { droppedAnyLinkKeys } from "@/lib/mood/dropped-link-keys"; + +describe("droppedAnyLinkKeys — the web's read of a mood write response", () => { + it("is false for a response that lists nothing (the fields are absent)", () => { + expect(droppedAnyLinkKeys({})).toBe(false); + expect(droppedAnyLinkKeys(null)).toBe(false); + expect(droppedAnyLinkKeys({ droppedTagKeys: [] })).toBe(false); + }); + + it("is true when either list names a key", () => { + expect(droppedAnyLinkKeys({ droppedTagKeys: ["custom:gone"] })).toBe(true); + expect(droppedAnyLinkKeys({ droppedFactorKeys: ["factor_x"] })).toBe(true); + }); +}); diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index c14e7c7e6..0f2863893 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -310,3 +310,69 @@ describe("entry-ownership guard", () => { }); }); }); + +describe("the write helpers report the keys they did not store", () => { + it("createTagLinks returns the unresolved binary and factor keys", async () => { + // First lookup: rated factors (only `factor_work` is a live factor). + // Second lookup: binary keys (`happy` live; the other two are unknown or + // archived, which the `isActive` filter makes look the same). + moodTagFindMany + .mockResolvedValueOnce([ + { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 }, + ]) + .mockResolvedValueOnce([{ id: "mt_happy", key: "happy" }]); + + const dropped = await createTagLinks( + "entry-1", + "user-1", + ["happy", "custom:gone", "no_such_tag", "happy"], + undefined, + [ + { key: "factor_work", rating: 3 }, + { key: "factor_retired", rating: 2 }, + ], + ); + + expect(dropped).toEqual({ + droppedTagKeys: ["custom:gone", "no_such_tag"], + droppedFactorKeys: ["factor_retired"], + }); + }); + + it("createTagLinks reports nothing when every key landed", async () => { + moodTagFindMany.mockResolvedValue([{ id: "mt_happy", key: "happy" }]); + expect(await createTagLinks("entry-1", "user-1", ["happy"])).toEqual({ + droppedTagKeys: [], + droppedFactorKeys: [], + }); + }); + + it("replaceTagLinks does not report a key whose archived link it preserved", async () => { + // `custom:old` is archived but already on the entry: the edit keeps it, + // so it is not dropped. `custom:never` resolves to nothing anywhere. + moodTagFindMany.mockResolvedValue([]); + linkFindMany.mockResolvedValue([ + { + moodTagId: "mt_old", + moodTag: { isActive: false, kind: "BINARY", key: "custom:old" }, + }, + ]); + expect( + await replaceTagLinks("entry-1", "user-1", [ + "custom:old", + "custom:never", + ]), + ).toEqual(["custom:never"]); + }); + + it("replaceRatedFactorLinks does not report an archived factor that stays on the entry", async () => { + moodTagFindMany.mockResolvedValue([]); + linkFindMany.mockResolvedValue([{ moodTag: { key: "factor_archived" } }]); + expect( + await replaceRatedFactorLinks("entry-1", "user-1", [ + { key: "factor_archived", rating: 2 }, + { key: "factor_unknown", rating: 2 }, + ]), + ).toEqual(["factor_unknown"]); + }); +}); diff --git a/src/lib/mood/dropped-link-keys.ts b/src/lib/mood/dropped-link-keys.ts new file mode 100644 index 000000000..18e7612c7 --- /dev/null +++ b/src/lib/mood/dropped-link-keys.ts @@ -0,0 +1,20 @@ +/** + * Client-side reader for the `droppedTagKeys` / `droppedFactorKeys` fields a + * mood write answers with (see `src/lib/mood/tag-links.ts`). Each list is + * present only when the server did not store some of the submitted keys — + * a tag archived in another tab, a custom tag deleted on another device — + * so its presence alone is the signal; the client re-derives nothing. + */ +export interface DroppedLinkKeysWire { + droppedTagKeys?: string[]; + droppedFactorKeys?: string[]; +} + +export function droppedAnyLinkKeys( + response: DroppedLinkKeysWire | null | undefined, +): boolean { + return ( + (response?.droppedTagKeys?.length ?? 0) > 0 || + (response?.droppedFactorKeys?.length ?? 0) > 0 + ); +} diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 5ad4c07ed..685029f7b 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -6,8 +6,11 @@ import { prisma } from "@/lib/db"; * * Resolve a list of catalog tag keys (`mood_tags.key`) to their ids and * write the `mood_entry_tag_links` join for a mood entry. The catalog is - * the source of truth: unknown keys are dropped silently so a stale - * client can never mint a link to a tag the deployment doesn't carry. + * the source of truth: an unknown or archived key never mints a link, so a + * stale client cannot link a tag the deployment doesn't carry. The write + * helpers return the keys they did not store, and the routes report them + * per entry (`droppedTagKeys` / `droppedFactorKeys`), so a client is never + * told an entry landed whole when part of it did not. * * Every helper accepts an optional `db` client so the caller can thread * the same `$transaction` client that wrote the entry — the entry row and @@ -76,7 +79,8 @@ interface ResolvedRatedFactor { * resolved `MoodTag`'s own `scaleMin..scaleMax`. The route maps this to a * 422 (the per-tag scale is the real gate; the Zod schema only enforces * the outer 1..5 envelope). Unknown / non-RATED keys are NOT an error — - * they are dropped silently, matching the binary `tagKeys` posture. + * the entry still lands without them, matching the binary `tagKeys` + * posture, and the write helpers report them back as dropped. */ export class RatedFactorOutOfRangeError extends Error { constructor( @@ -92,10 +96,20 @@ export class RatedFactorOutOfRangeError extends Error { } } +/** + * The submitted keys a write did not store: unknown to the catalog, archived, + * another account's custom tag, or the wrong kind for the field they came in. + * Empty arrays when everything landed. + */ +export interface DroppedLinkKeys { + droppedTagKeys: string[]; + droppedFactorKeys: string[]; +} + /** * Resolve rated-factor inputs against the catalog. Unknown keys, inactive - * tags, and `kind = 'BINARY'` keys are dropped silently (the catalog is - * the source of truth, same posture as `resolveTagKeysToIds`). A rating + * tags, and `kind = 'BINARY'` keys are not resolved (the catalog is the + * source of truth, same posture as `resolveTagKeysToIds`). A rating * outside the resolved tag's `scaleMin..scaleMax` throws * `RatedFactorOutOfRangeError` so the route returns 422. The last value * wins on a duplicate key in the same payload. @@ -104,7 +118,15 @@ export async function resolveRatedFactors( factors: RatedFactorInput[], db: TagLinkDb = prisma, ): Promise { - if (factors.length === 0) return []; + return (await resolveRatedFactorsReporting(factors, db)).resolved; +} + +/** `resolveRatedFactors`, plus the submitted keys that did not resolve. */ +async function resolveRatedFactorsReporting( + factors: RatedFactorInput[], + db: TagLinkDb, +): Promise<{ resolved: ResolvedRatedFactor[]; dropped: string[] }> { + if (factors.length === 0) return { resolved: [], dropped: [] }; // Last-writer-wins on a duplicate key in the same submission. const byKey = new Map(); for (const f of factors) byKey.set(f.key, f.rating); @@ -135,7 +157,11 @@ export async function resolveRatedFactors( } resolved.push({ moodTagId: row.id, rating }); } - return resolved; + const found = new Set(rows.map((row) => row.key)); + return { + resolved, + dropped: [...byKey.keys()].filter((key) => !found.has(key)), + }; } /** @@ -161,8 +187,18 @@ export async function resolveTagKeysToIds( ownerUserId?: string, kind?: "BINARY" | "RATED", ): Promise { + return (await resolveTagKeysReporting(keys, db, ownerUserId, kind)).ids; +} + +/** `resolveTagKeysToIds`, plus the submitted keys that did not resolve. */ +async function resolveTagKeysReporting( + keys: string[], + db: TagLinkDb, + ownerUserId?: string, + kind?: "BINARY" | "RATED", +): Promise<{ ids: string[]; dropped: string[] }> { const unique = Array.from(new Set(keys)); - if (unique.length === 0) return []; + if (unique.length === 0) return { ids: [], dropped: [] }; const ownerClause = ownerUserId ? { OR: [{ userId: null }, { userId: ownerUserId }] } : { userId: null }; @@ -173,9 +209,13 @@ export async function resolveTagKeysToIds( ...(kind ? { kind } : {}), ...ownerClause, }, - select: { id: true }, + select: { id: true, key: true }, }); - return rows.map((row) => row.id); + const found = new Set(rows.map((row) => row.key)); + return { + ids: rows.map((row) => row.id), + dropped: unique.filter((key) => !found.has(key)), + }; } /** @@ -191,6 +231,8 @@ export async function resolveTagKeysToIds( * route 422) when a rating is out of the factor's scale. Asserts the * entry belongs to `userId` before any write — a defensive guard against * linking into an entry the acting session does not own. + * + * Returns the submitted keys that were not stored, for the route to report. */ export async function createTagLinks( moodEntryId: string, @@ -198,12 +240,13 @@ export async function createTagLinks( keys: string[], db: TagLinkDb = prisma, ratedFactors: RatedFactorInput[] = [], -): Promise { +): Promise { await assertEntryOwnership(moodEntryId, userId, db); // Resolve rated factors FIRST so an out-of-range rating aborts before // any write, and so the rated rows (which carry a value) take priority // over a bare binary row for the same tag id under `skipDuplicates`. - const resolvedFactors = await resolveRatedFactors(ratedFactors, db); + const { resolved: resolvedFactors, dropped: droppedFactorKeys } = + await resolveRatedFactorsReporting(ratedFactors, db); const ratedTagIds = new Set(resolvedFactors.map((f) => f.moodTagId)); if (resolvedFactors.length > 0) { @@ -218,16 +261,19 @@ export async function createTagLinks( }); } - const tagIds = (await resolveTagKeysToIds(keys, db, userId)).filter( + const binary = await resolveTagKeysReporting(keys, db, userId); + const tagIds = binary.ids.filter( // A key already written as a rated link is not re-inserted as a // bare binary row (that would lose the rating to `skipDuplicates`). (id) => !ratedTagIds.has(id), ); - if (tagIds.length === 0) return; - await db.moodEntryTagLink.createMany({ - data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })), - skipDuplicates: true, - }); + if (tagIds.length > 0) { + await db.moodEntryTagLink.createMany({ + data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })), + skipDuplicates: true, + }); + } + return { droppedTagKeys: binary.dropped, droppedFactorKeys }; } /** @@ -248,24 +294,31 @@ export async function createTagLinks( * (`isActive: false`) are therefore preserved untouched no matter what * the body carries; a purge (`DELETE /api/mood/tags/custom/[key]?purge=true`) * remains the only path that removes them. + * + * Returns the submitted keys that are not on the entry afterwards. A key + * whose archived link was preserved is on the entry, so it is not reported. */ export async function replaceTagLinks( moodEntryId: string, userId: string, keys: string[], db: TagLinkDb = prisma, -): Promise { +): Promise { await assertEntryOwnership(moodEntryId, userId, db); - const desiredIds = new Set( - await resolveTagKeysToIds(keys, db, userId, "BINARY"), - ); + const desired = await resolveTagKeysReporting(keys, db, userId, "BINARY"); + const desiredIds = new Set(desired.ids); const existing = await db.moodEntryTagLink.findMany({ where: { moodEntryId }, select: { moodTagId: true, - moodTag: { select: { isActive: true, kind: true } }, + moodTag: { select: { isActive: true, kind: true, key: true } }, }, }); + const preservedKeys = new Set( + existing + .filter((row) => !row.moodTag.isActive && row.moodTag.kind === "BINARY") + .map((row) => row.moodTag.key), + ); const existingIds = new Set(existing.map((row) => row.moodTagId)); const toDelete = existing @@ -289,6 +342,7 @@ export async function replaceTagLinks( skipDuplicates: true, }); } + return desired.dropped.filter((key) => !preservedKeys.has(key)); } /** @@ -300,17 +354,40 @@ export async function replaceTagLinks( * Throws `RatedFactorOutOfRangeError` (→ route 422) before any write when a * rating is out of scale. Asserts the entry belongs to `userId` before any * write. + * + * Returns the submitted factor keys that are not on the entry afterwards; an + * archived factor whose link survives is on the entry and is not reported. */ export async function replaceRatedFactorLinks( moodEntryId: string, userId: string, factors: RatedFactorInput[], db: TagLinkDb = prisma, -): Promise { +): Promise { await assertEntryOwnership(moodEntryId, userId, db); // Resolve (and range-check) before mutating so an out-of-range rating // aborts the replace cleanly. - const resolved = await resolveRatedFactors(factors, db); + const { resolved, dropped } = await resolveRatedFactorsReporting(factors, db); + // Looked up only when something did not resolve: which of those keys still + // sit on the entry as an archived factor the replacement leaves alone. + const preservedKeys = + dropped.length === 0 + ? new Set() + : new Set( + ( + await db.moodEntryTagLink.findMany({ + where: { + moodEntryId, + moodTag: { + isActive: false, + kind: "RATED", + key: { in: dropped }, + }, + }, + select: { moodTag: { select: { key: true } } }, + }) + ).map((row) => row.moodTag.key), + ); // Drop every active RATED link, including a legacy row whose rating is // null. Same archive contract as `replaceTagLinks`: a link whose tag has @@ -333,4 +410,30 @@ export async function replaceRatedFactorLinks( skipDuplicates: true, }); } + return dropped.filter((key) => !preservedKeys.has(key)); } + +/** + * The wire form of `DroppedLinkKeys`: each list only when it is non-empty. + * Absent means every submitted key of that kind landed, which keeps the + * common response byte-identical to what clients read before the fields + * existed. + */ +export function droppedLinkKeysForWire( + dropped: DroppedLinkKeys, +): Partial { + return { + ...(dropped.droppedTagKeys.length > 0 + ? { droppedTagKeys: dropped.droppedTagKeys } + : {}), + ...(dropped.droppedFactorKeys.length > 0 + ? { droppedFactorKeys: dropped.droppedFactorKeys } + : {}), + }; +} + +/** No key dropped: the value a write that carried no keys reports. */ +export const NO_DROPPED_LINK_KEYS: DroppedLinkKeys = Object.freeze({ + droppedTagKeys: [], + droppedFactorKeys: [], +}) as DroppedLinkKeys; diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 5e80702f8..64f88bbde 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -401,6 +401,21 @@ const bulkMoodPayload = z "iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs are idempotent.", }); +// The submitted keys a write did not store. Shared by the bulk result and the +// single-entry write responses so the two surfaces name the same thing. +const droppedTagKeysField = z + .array(z.string()) + .optional() + .describe( + "`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every submitted key landed. The entry itself was written.", + ); +const droppedFactorKeysField = z + .array(z.string()) + .optional() + .describe( + "`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. Present only when non-empty; absent means every submitted factor landed.", + ); + const bulkMoodEntryResult = z .object({ index: z.number().int().nonnegative(), @@ -418,6 +433,8 @@ const bulkMoodEntryResult = z .string() .optional() .describe("Echoed back when the entry carried one, for client mapping."), + droppedTagKeys: droppedTagKeysField, + droppedFactorKeys: droppedFactorKeysField, }) .meta({ id: "BulkMoodEntryResult" }); @@ -526,6 +543,12 @@ const moodEntryDto = z .describe( "The day's context, or null when the entry carries none. Null and an object of nulls are different answers.", ), + droppedTagKeys: droppedTagKeysField.describe( + "Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An archived tag the edit preserved is on the entry and is not listed.", + ), + droppedFactorKeys: droppedFactorKeysField.describe( + "Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present only when non-empty.", + ), }) .meta({ id: "MoodEntryDTO", diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts index 388e1531f..50818240e 100644 --- a/tests/integration/mood-entries-bulk.test.ts +++ b/tests/integration/mood-entries-bulk.test.ts @@ -277,6 +277,70 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => { expect(linkCount).toBe(0); }); + it("names the unknown and archived keys on the entry's result instead of reporting it whole", async () => { + const db = getPrismaClient(); + const category = await db.moodTagCategory.findFirstOrThrow({ + select: { id: true }, + }); + const archived = await db.moodTag.create({ + data: { + categoryId: category.id, + key: "test_archived_tag", + labelKey: "mood.tags.test_archived_tag", + isActive: false, + }, + }); + try { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tagKeys: ["movies", "test_archived_tag", "not_a_real_tag"], + ratedFactors: [ + { key: "factor_work", rating: 4 }, + { key: "factor_not_real", rating: 2 }, + ], + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + tagKeys: ["movies"], + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { inserted: number; entries: Array> }; + }; + expect(json.data.inserted).toBe(2); + expect(json.data.entries[0]).toMatchObject({ + status: "inserted", + droppedTagKeys: ["test_archived_tag", "not_a_real_tag"], + droppedFactorKeys: ["factor_not_real"], + }); + expect(json.data.entries[1]).not.toHaveProperty("droppedTagKeys"); + expect(json.data.entries[1]).not.toHaveProperty("droppedFactorKeys"); + + // What the result says is what is on disk. + const first = await db.moodEntry.findUniqueOrThrow({ + where: { id: json.data.entries[0].id as string }, + select: { + tagLinks: { select: { moodTag: { select: { key: true } } } }, + }, + }); + expect(first.tagLinks.map((l) => l.moodTag.key).sort()).toEqual([ + "factor_work", + "movies", + ]); + } finally { + await db.moodTag.delete({ where: { id: archived.id } }); + } + }); + it("rejects an over-cap batch with 422 and the documented error code", async () => { const { POST } = await import("@/app/api/mood-entries/bulk/route"); const entries = Array.from({ length: 501 }, (_, i) => ({