mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(mood): report the tag and factor keys a mood write did not store
An unknown or archived tag key, or a factor the catalog no longer carries, was dropped on the way in while the entry still reported "inserted" (bulk) or 201 (create, edit). A client had no way to tell that part of what it sent never landed. Each write now names those keys: the bulk result per entry, the create and edit responses on the entry, as droppedTagKeys and droppedFactorKeys. Both are present only when non-empty, so a response where everything landed is unchanged. An archived tag an edit preserved is on the entry and is not listed. The web shows a short notice after saving when a picked tag was left off.
This commit is contained in:
1 parent
fdbbd3a84a
commit
a66976874e
22 files changed
+525
-50
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -847,6 +847,7 @@
|
||||
"formResetConfirmBody": "입력한 기분, 태그, 메모가 모두 지워져요. 되돌릴 수 없어요.",
|
||||
"formResetConfirmAction": "초기화",
|
||||
"saveError": "저장하지 못했어요",
|
||||
"tagsNotSaved": "저장했습니다. 더 이상 사용할 수 없는 태그는 제외했습니다.",
|
||||
"deleteError": "삭제하지 못했어요",
|
||||
"deletedToast": "기록을 삭제했어요",
|
||||
"restoredToast": "기록을 되살렸어요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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",
|
||||
|
||||
@@ -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<Record<string, unknown>> };
|
||||
};
|
||||
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({
|
||||
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
// 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<Response> {
|
||||
// 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<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
dropped_tag_keys: droppedTagKeyCount,
|
||||
dropped_factor_keys: droppedFactorKeyCount,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("mood.bulk", unstableShapes)
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<DroppedLinkKeysWire>("/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"));
|
||||
|
||||
@@ -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<DroppedLinkKeysWire>(`/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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+128
-25
@@ -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<ResolvedRatedFactor[]> {
|
||||
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<string, number>();
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
): Promise<DroppedLinkKeys> {
|
||||
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<void> {
|
||||
): Promise<string[]> {
|
||||
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<void> {
|
||||
): Promise<string[]> {
|
||||
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<string>()
|
||||
: 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<DroppedLinkKeys> {
|
||||
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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<string, unknown>> };
|
||||
};
|
||||
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) => ({
|
||||
|
||||
Reference in new issue
Block a user