mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Restore an account that grouped its own mood factors
An account that ever created its own mood category could not be restored at all. Not partially, not with a gap: the restore violated mood_tags_category_id_fkey and rolled the whole file back, so the operator got a 500 naming a constraint and an empty account. The coverage register described this as custom tags landing in a grouping that no longer exists, which reads like a cosmetic loss. Measured, it is the same shape the retired-catalogue-key work already removed once: one row nobody thought about taking down everything else with it. MoodTag.categoryId is a real foreign key. Seeded categories belong to the instance and are deliberately still not carried, but a category the person made is theirs, cascades away with them, and has to travel. It now does, and it is written before the tags that point at it. The hidden set travels by key rather than id, because what people hide is almost always a seeded tag whose id differs on every instance. A key this instance no longer knows costs one hidden row and is reported: a silent drop would un-hide a factor somebody had deliberately taken out of their mood editor, and they would have no way to know why it came back. Found by experiment rather than by reading: seeding a user-owned category into the round trip turned it red with the constraint name, and the same test is green now.
This commit is contained in:
1 parent
460ed37a2a
commit
d596f8c66d
10 files changed
+223
-5
No files matched your search
@@ -297,6 +297,8 @@ function sourceClient() {
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -794,6 +794,33 @@ const handler = apiHandler(
|
||||
(entry) => entry.structuredTags,
|
||||
);
|
||||
const tagKeys = [...new Set(referencedTagKeys)];
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: category.labelEncrypted ?? null,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
@@ -822,6 +849,53 @@ const handler = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
const factorRows =
|
||||
factorKeys.length === 0
|
||||
? []
|
||||
|
||||
@@ -43,6 +43,8 @@ vi.mock("@/lib/db", () => ({
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
@@ -137,6 +139,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), count: vi.fn() },
|
||||
moodTag: { findMany: vi.fn() },
|
||||
moodTagCategory: { findMany: vi.fn() },
|
||||
moodTagHidden: { findMany: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
user: { findUnique: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
@@ -134,6 +136,9 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
|
||||
@@ -41,6 +41,8 @@ vi.mock("@/lib/db", () => ({
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -345,6 +345,8 @@ function makePrisma() {
|
||||
// future `include` that forgets the relation fails here loudly instead of
|
||||
// exporting an account whose Coach never spoke.
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -354,6 +354,17 @@ export const TWO_ENDED_MODELS = [
|
||||
// that recalculated would disagree with the account's own history.
|
||||
"MedicationInventoryItem",
|
||||
"MedicationInventoryEvent",
|
||||
// The mood taxonomy the account shaped for itself. The category is not a
|
||||
// nicety: `MoodTag.categoryId` is a real foreign key, so a category the
|
||||
// person created and the file did not carry made the restore violate a
|
||||
// constraint and roll the WHOLE account back. The register described this as
|
||||
// custom tags landing in a grouping that no longer exists; measured, it was
|
||||
// a 500 and an empty account.
|
||||
//
|
||||
// The hidden set travels by KEY rather than id, because what people hide is
|
||||
// almost always a seeded tag whose id differs on every instance.
|
||||
"MoodTagCategory",
|
||||
"MoodTagHidden",
|
||||
] as const;
|
||||
|
||||
/** One model claimed to travel both ways. */
|
||||
@@ -394,10 +405,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
"The history of how a schedule changed. Without it a restored medication keeps today's schedule and loses the record of when the dose or timing moved, which is the part a doctor asks about.",
|
||||
MedicationEfficacyTarget:
|
||||
"What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.",
|
||||
MoodTagCategory:
|
||||
"Categories a person created to group their own mood factors. Custom tags restore into a grouping that no longer exists.",
|
||||
MoodTagHidden:
|
||||
"Which seeded mood tags the account chose to hide. A restore un-hides every one, so the mood editor comes back cluttered with tags the person removed on purpose.",
|
||||
MentalHealthAssessment:
|
||||
"Completed WHO-5, PHQ and GAD instruments with their scores and dates. Clinically meaningful history that no integration can re-sync, and the single largest gap on this list.",
|
||||
EcgRecording:
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface FullBackupCounts
|
||||
medicationInventoryItems: number;
|
||||
medicationInventoryEvents: number;
|
||||
moodEntries: number;
|
||||
customMoodTagCategories: number;
|
||||
hiddenMoodTags: number;
|
||||
cycles: number;
|
||||
cycleDayLogs: number;
|
||||
nutrientDays: number;
|
||||
@@ -293,6 +295,8 @@ export async function buildFullBackupPayload(
|
||||
intakeEvents,
|
||||
moodEntries,
|
||||
customMoodTags,
|
||||
customMoodTagCategories,
|
||||
hiddenMoodTags,
|
||||
cycle,
|
||||
records,
|
||||
profile,
|
||||
@@ -409,6 +413,26 @@ export async function buildFullBackupPayload(
|
||||
where: { userId },
|
||||
orderBy: { key: "asc" },
|
||||
}),
|
||||
// The account's OWN categories. Seeded ones (`userId: null`) belong to the
|
||||
// instance and are deliberately not carried — but a custom tag's
|
||||
// `categoryId` is a real foreign key, so a category the account created
|
||||
// itself has to travel or the tag above cannot be written at all. Before
|
||||
// this section existed, that was not a gap in the restore: it was a
|
||||
// constraint violation that took the WHOLE file down with it, so an
|
||||
// account that had ever grouped its own mood factors could not be
|
||||
// restored.
|
||||
prisma.moodTagCategory.findMany({
|
||||
where: { userId },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
}),
|
||||
// Which tags the account chose to hide. Carried by KEY rather than id:
|
||||
// what people hide is almost always a SEEDED tag, whose id differs on
|
||||
// every instance, so an id would resolve to nothing on the way back.
|
||||
prisma.moodTagHidden.findMany({
|
||||
where: { userId },
|
||||
select: { moodTag: { select: { key: true } }, createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
buildCycleBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
@@ -773,6 +797,20 @@ export async function buildFullBackupPayload(
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
customMoodTagCategories: customMoodTagCategories.map((category) => ({
|
||||
id: category.id,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
// Ciphertext verbatim, like the tag label beside it.
|
||||
labelEncrypted: category.labelEncrypted,
|
||||
})),
|
||||
hiddenMoodTags: hiddenMoodTags.map((hidden) => ({
|
||||
key: hidden.moodTag.key,
|
||||
createdAt: hidden.createdAt.toISOString(),
|
||||
})),
|
||||
customMoodTags: customMoodTags.map((tag) => ({
|
||||
...(disasterRecovery ? { id: tag.id } : {}),
|
||||
key: tag.key,
|
||||
@@ -851,6 +889,8 @@ export async function buildFullBackupPayload(
|
||||
0,
|
||||
),
|
||||
moodEntries: moodEntries.length,
|
||||
customMoodTagCategories: customMoodTagCategories.length,
|
||||
hiddenMoodTags: hiddenMoodTags.length,
|
||||
cycles: cycle.cycles.length,
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
nutrientDays: nutrientDays.length,
|
||||
|
||||
@@ -1192,6 +1192,31 @@ const coachReminderBackupSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* A grouping the account created for its own mood factors. `id` is REQUIRED,
|
||||
* unlike everywhere else in this file, because a custom tag addresses its
|
||||
* category by that id and nothing else can resolve the pairing.
|
||||
*/
|
||||
const customMoodTagCategorySchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
key: z.string().min(1),
|
||||
labelKey: z.string().min(1),
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
labelEncrypted: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** A tag the account hid, named by key so a seeded tag resolves anywhere. */
|
||||
const hiddenMoodTagSchema = z
|
||||
.object({
|
||||
key: z.string().min(1),
|
||||
createdAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const allergyBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -1313,6 +1338,8 @@ export const backupPayloadSchema = z
|
||||
// Defaulted rather than required: files written before this field existed
|
||||
// are still valid, and an account with no custom symptoms writes [].
|
||||
customSymptoms: z.array(customCycleSymptomSchema).default([]),
|
||||
customMoodTagCategories: z.array(customMoodTagCategorySchema).default([]),
|
||||
hiddenMoodTags: z.array(hiddenMoodTagSchema).default([]),
|
||||
customMoodTags: z.array(customMoodTagSchema).default([]),
|
||||
// Structured records default to empty arrays so older backups remain
|
||||
// parseable. Canonical DR writers add stable ids and encrypted document
|
||||
|
||||
@@ -188,6 +188,9 @@ const COUNT_BACK: Record<
|
||||
// No own `userId` column — reached through the drug, like the schedules.
|
||||
MedicationInventoryEvent: (p, userId) =>
|
||||
p.medicationInventoryEvent.count({ where: { medication: { userId } } }),
|
||||
MoodTagCategory: (p, userId) =>
|
||||
p.moodTagCategory.count({ where: { userId } }),
|
||||
MoodTagHidden: (p, userId) => p.moodTagHidden.count({ where: { userId } }),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -350,10 +353,22 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
const moodCategory = await prisma.moodTagCategory.findFirstOrThrow({
|
||||
where: { userId: null },
|
||||
});
|
||||
// A category the account created itself, not a seeded one. It cascades away
|
||||
// with the user, and `MoodTag.categoryId` is a real foreign key — so before
|
||||
// the category travelled, this one row made the restore violate
|
||||
// `mood_tags_category_id_fkey` and answer 500 with the account left empty.
|
||||
const ownCategory = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
key: "round_trip_own_category",
|
||||
labelKey: "mood.category.roundTripOwn",
|
||||
sortOrder: 90,
|
||||
},
|
||||
});
|
||||
const moodTag = await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
categoryId: moodCategory.id,
|
||||
categoryId: ownCategory.id,
|
||||
key: "round_trip_factor",
|
||||
labelKey: "mood.tag.roundTripFactor",
|
||||
kind: "RATED",
|
||||
@@ -361,6 +376,15 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
scaleMax: 5,
|
||||
},
|
||||
});
|
||||
// A SEEDED tag the account chose to hide. The id differs per instance, so
|
||||
// this is the row that proves the hidden set travels by key.
|
||||
const seededTagToHide = await prisma.moodTag.findFirstOrThrow({
|
||||
where: { userId: null },
|
||||
});
|
||||
await prisma.moodTagHidden.create({
|
||||
data: { userId: OWNER_ID, moodTagId: seededTagToHide.id },
|
||||
});
|
||||
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
@@ -1068,6 +1092,37 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// The account's own grouping came back, and the hidden tag is hidden
|
||||
// again — resolved by key, so it points at whatever id this instance uses.
|
||||
const ownCategories = await prisma.moodTagCategory.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(
|
||||
ownCategories.map((c) => ({ key: c.key, labelKey: c.labelKey })),
|
||||
).toEqual([
|
||||
{
|
||||
key: "round_trip_own_category",
|
||||
labelKey: "mood.category.roundTripOwn",
|
||||
},
|
||||
]);
|
||||
const restoredTag = await prisma.moodTag.findFirstOrThrow({
|
||||
where: { key: "round_trip_factor" },
|
||||
});
|
||||
expect(
|
||||
restoredTag.categoryId,
|
||||
"the custom tag is grouped under the account's own category",
|
||||
).toBe(ownCategories[0].id);
|
||||
|
||||
const hidden = await prisma.moodTagHidden.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
include: { moodTag: { select: { key: true, userId: true } } },
|
||||
});
|
||||
// Exactly one, and it points at a SEEDED tag — which is only possible if
|
||||
// the key was resolved against this instance's catalogue rather than an id
|
||||
// carried in the file.
|
||||
expect(hidden).toHaveLength(1);
|
||||
expect(hidden[0].moodTag.userId).toBeNull();
|
||||
|
||||
// The shelf, and the count that must not be recomputed.
|
||||
const packs = await prisma.medicationInventoryItem.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
|
||||
Reference in new issue
Block a user