mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #816 from MBombeck/feat/backup-mood-taxonomy
Restore an account that grouped its own mood factors
This commit is contained in:
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