mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(cycle): honour the sensitive-data encryption toggle + harden the write path
- When the sensitive-category encryption preference is on (the default), encrypt the intent-revealing day-log fields (sexual activity, protection, pregnancy/progesterone tests, contraception) into an encrypted envelope and leave the queryable columns null, decrypting on read. The toggle now does what it says rather than storing those fields in plaintext. - Make the day-log write a partial merge so a re-log only overwrites the fields it carries, never nulling untouched ones (no more data loss when the same day is logged again). - Make the upsert collision-safe: a manual entry and an imported entry on the same date adopt one canonical row instead of throwing, and the single POST returns a clean conflict rather than a 500; one bad date no longer aborts a whole import. - Compare the decrypted note, not the ciphertext, so an unchanged re-log is a true no-op and stops needless re-sync. - Hoist cycle attribution out of the per-entry loop; map bulk failures to stable reason codes; audit the import goal change; mask the event name in discreet reminder pushes; add a one-tap hard-delete purge for all cycle data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3e398295a3
commit
ac13dd28ed
22 files changed
+770
-174
No files matched your search
+10
-1
@@ -5097,7 +5097,16 @@
|
||||
"deleteData": "Meine Zyklusdaten löschen",
|
||||
"saved": "Einstellungen gespeichert",
|
||||
"saveError": "Konnte nicht gespeichert werden. Versuch es erneut.",
|
||||
"save": "Speichern"
|
||||
"save": "Speichern",
|
||||
"purgeTitle": "Alle Zyklusdaten löschen",
|
||||
"purgeDescription": "Entfernt dauerhaft jede Periode, jeden Tageseintrag und jede Vorhersage von diesem Konto. Dies kann nicht rückgängig gemacht werden.",
|
||||
"purgeButton": "Alle Zyklusdaten löschen",
|
||||
"purgeConfirmTitle": "Alle Zyklusdaten löschen?",
|
||||
"purgeConfirmBody": "Dies löscht dauerhaft jede Periode, jeden Tageseintrag, jedes Symptom und jede Vorhersage. Dies kann nicht rückgängig gemacht werden.",
|
||||
"purgeConfirm": "Alles löschen",
|
||||
"purgeCancel": "Abbrechen",
|
||||
"purgeSuccess": "Alle Zyklusdaten gelöscht.",
|
||||
"purgeError": "Zyklusdaten konnten nicht gelöscht werden. Bitte erneut versuchen."
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
|
||||
+10
-1
@@ -5097,7 +5097,16 @@
|
||||
"deleteData": "Delete my cycle data",
|
||||
"saved": "Settings saved",
|
||||
"saveError": "Could not save. Try again.",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"purgeTitle": "Delete all cycle data",
|
||||
"purgeDescription": "Permanently remove every period, day-log, and prediction from this account. This cannot be undone.",
|
||||
"purgeButton": "Delete all cycle data",
|
||||
"purgeConfirmTitle": "Delete all cycle data?",
|
||||
"purgeConfirmBody": "This permanently deletes every period, day-log, symptom, and prediction. This cannot be undone.",
|
||||
"purgeConfirm": "Delete everything",
|
||||
"purgeCancel": "Cancel",
|
||||
"purgeSuccess": "All cycle data deleted.",
|
||||
"purgeError": "Could not delete cycle data. Try again."
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
|
||||
+10
-1
@@ -5097,7 +5097,16 @@
|
||||
"deleteData": "Eliminar mis datos del ciclo",
|
||||
"saved": "Ajustes guardados",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"save": "Guardar"
|
||||
"save": "Guardar",
|
||||
"purgeTitle": "Eliminar todos los datos del ciclo",
|
||||
"purgeDescription": "Elimina de forma permanente todos los periodos, registros diarios y predicciones de esta cuenta. No se puede deshacer.",
|
||||
"purgeButton": "Eliminar todos los datos del ciclo",
|
||||
"purgeConfirmTitle": "¿Eliminar todos los datos del ciclo?",
|
||||
"purgeConfirmBody": "Esto elimina de forma permanente todos los periodos, registros diarios, síntomas y predicciones. No se puede deshacer.",
|
||||
"purgeConfirm": "Eliminar todo",
|
||||
"purgeCancel": "Cancelar",
|
||||
"purgeSuccess": "Todos los datos del ciclo eliminados.",
|
||||
"purgeError": "No se pudieron eliminar los datos del ciclo. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
|
||||
+10
-1
@@ -5097,7 +5097,16 @@
|
||||
"deleteData": "Supprimer mes données de cycle",
|
||||
"saved": "Réglages enregistrés",
|
||||
"saveError": "Enregistrement impossible. Réessaie.",
|
||||
"save": "Enregistrer"
|
||||
"save": "Enregistrer",
|
||||
"purgeTitle": "Supprimer toutes les données de cycle",
|
||||
"purgeDescription": "Supprime définitivement chaque règle, journal quotidien et prédiction de ce compte. Cette action est irréversible.",
|
||||
"purgeButton": "Supprimer toutes les données de cycle",
|
||||
"purgeConfirmTitle": "Supprimer toutes les données de cycle ?",
|
||||
"purgeConfirmBody": "Cela supprime définitivement chaque règle, journal quotidien, symptôme et prédiction. Cette action est irréversible.",
|
||||
"purgeConfirm": "Tout supprimer",
|
||||
"purgeCancel": "Annuler",
|
||||
"purgeSuccess": "Toutes les données de cycle ont été supprimées.",
|
||||
"purgeError": "Impossible de supprimer les données de cycle. Réessayez."
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
|
||||
+10
-1
@@ -5097,7 +5097,16 @@
|
||||
"deleteData": "Elimina i miei dati del ciclo",
|
||||
"saved": "Impostazioni salvate",
|
||||
"saveError": "Impossibile salvare. Riprova.",
|
||||
"save": "Salva"
|
||||
"save": "Salva",
|
||||
"purgeTitle": "Elimina tutti i dati del ciclo",
|
||||
"purgeDescription": "Rimuove definitivamente ogni mestruazione, registro giornaliero e previsione da questo account. L'operazione è irreversibile.",
|
||||
"purgeButton": "Elimina tutti i dati del ciclo",
|
||||
"purgeConfirmTitle": "Eliminare tutti i dati del ciclo?",
|
||||
"purgeConfirmBody": "Questo elimina definitivamente ogni mestruazione, registro giornaliero, sintomo e previsione. L'operazione è irreversibile.",
|
||||
"purgeConfirm": "Elimina tutto",
|
||||
"purgeCancel": "Annulla",
|
||||
"purgeSuccess": "Tutti i dati del ciclo eliminati.",
|
||||
"purgeError": "Impossibile eliminare i dati del ciclo. Riprova."
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
|
||||
+10
-1
@@ -5097,7 +5097,16 @@
|
||||
"deleteData": "Usuń moje dane cyklu",
|
||||
"saved": "Ustawienia zapisane",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"save": "Zapisz"
|
||||
"save": "Zapisz",
|
||||
"purgeTitle": "Usuń wszystkie dane cyklu",
|
||||
"purgeDescription": "Trwale usuwa każdą miesiączkę, wpis dzienny i prognozę z tego konta. Tej operacji nie można cofnąć.",
|
||||
"purgeButton": "Usuń wszystkie dane cyklu",
|
||||
"purgeConfirmTitle": "Usunąć wszystkie dane cyklu?",
|
||||
"purgeConfirmBody": "Spowoduje to trwałe usunięcie każdej miesiączki, wpisu dziennego, objawu i prognozy. Tej operacji nie można cofnąć.",
|
||||
"purgeConfirm": "Usuń wszystko",
|
||||
"purgeCancel": "Anuluj",
|
||||
"purgeSuccess": "Usunięto wszystkie dane cyklu.",
|
||||
"purgeError": "Nie udało się usunąć danych cyklu. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"cycleReminders": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Additive: at-rest envelope column for the intent-revealing cycle day-log
|
||||
-- fields. Populated INSTEAD of the plaintext columns when
|
||||
-- CycleProfile.sensitive_category_encryption is ON; the plaintext columns
|
||||
-- are then NULL. AES-256-GCM ciphertext, decrypted fail-soft on read.
|
||||
ALTER TABLE "cycle_day_logs" ADD COLUMN "sensitive_encrypted" TEXT;
|
||||
@@ -3432,6 +3432,16 @@ model CycleDayLog {
|
||||
progesteroneTest HomeTestResult? @map("progesterone_test")
|
||||
contraceptive ContraceptiveKind? @map("contraceptive")
|
||||
|
||||
/// AES-256-GCM ciphertext envelope of the intent-revealing fields
|
||||
/// (sexualActivity / protectedSex / pregnancyTest / progesteroneTest /
|
||||
/// contraceptive). Populated INSTEAD of the plaintext columns above when
|
||||
/// CycleProfile.sensitiveCategoryEncryption is ON: the plaintext columns
|
||||
/// are NULLed and this carries the JSON envelope, so the most sensitive
|
||||
/// reproductive data is encrypted at rest. Those five fields then drop
|
||||
/// out of the rollup / correlation tier (queryable plaintext is the
|
||||
/// trade for at-rest secrecy). Decrypted fail-soft on read.
|
||||
sensitiveEncrypted String? @map("sensitive_encrypted")
|
||||
|
||||
/// AES-256-GCM ciphertext envelope of the day's free-text note (always
|
||||
/// encrypted — the `*Encrypted` String? convention, matching
|
||||
/// MoodTag.labelEncrypted + InsightNarrative).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* `DELETE /api/cycle/all` — one-click cycle hard-delete purge.
|
||||
*
|
||||
* Distinct from the per-row soft-delete (`DELETE /api/cycle/day-logs/{id}`,
|
||||
* which leaves a tombstone for the sync feed): this HARD-deletes every
|
||||
* cycle row the user owns — day-logs (and their symptom links, by cascade),
|
||||
* menstrual cycles, predictions, and the reminder ledger — so nothing
|
||||
* reproductive persists in Postgres. The `CycleProfile` row is reset to its
|
||||
* defaults (tracking left enabled) rather than dropped, so the gate and
|
||||
* settings page keep working.
|
||||
*
|
||||
* Gated (`cycle.disabled` 403) and owner-scoped. Audited.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Hard delete in a transaction. Symptom links cascade off the day-log
|
||||
// delete (the schema's onDelete: Cascade). Order child-before-parent for
|
||||
// the rows that don't cascade.
|
||||
const cleared = await prisma.$transaction(async (tx) => {
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const predictions = await tx.cyclePrediction.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const cycles = await tx.menstrualCycle.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
return {
|
||||
dayLogs: dayLogs.count,
|
||||
predictions: predictions.count,
|
||||
cycles: cycles.count,
|
||||
};
|
||||
});
|
||||
|
||||
await auditLog("cycle.purge", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: cleared,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.purge" },
|
||||
meta: cleared,
|
||||
});
|
||||
|
||||
return apiSuccess({ purged: true, ...cleared });
|
||||
});
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
|
||||
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
|
||||
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
|
||||
|
||||
@@ -39,7 +41,16 @@ export const PATCH = apiHandler(
|
||||
|
||||
const existing = await prisma.cycleDayLog.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: { id: true, userId: true },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Day-log not found", 404);
|
||||
@@ -61,9 +72,38 @@ export const PATCH = apiHandler(
|
||||
|
||||
const body = parsed.data;
|
||||
|
||||
// Field-by-field update (no mass assignment). Every field is
|
||||
// optional; an omitted field is left untouched. `note` re-encrypts;
|
||||
// an explicit null clears it.
|
||||
// Resolve the stored plaintext sensitive fields (from the envelope when
|
||||
// the row was encrypted, else the columns) so a partial patch merges
|
||||
// against the true current value.
|
||||
const encryptSensitive = (await getOrCreateCycleProfile(user.id))
|
||||
.sensitiveCategoryEncryption;
|
||||
const stored = readStoredSensitive(existing);
|
||||
const merged = {
|
||||
sexualActivity:
|
||||
body.sexualActivity !== undefined
|
||||
? body.sexualActivity
|
||||
: stored.sexualActivity,
|
||||
protectedSex:
|
||||
body.protectedSex !== undefined
|
||||
? (body.protectedSex ?? null)
|
||||
: stored.protectedSex,
|
||||
pregnancyTest:
|
||||
body.pregnancyTest !== undefined
|
||||
? (body.pregnancyTest ?? null)
|
||||
: stored.pregnancyTest,
|
||||
progesteroneTest:
|
||||
body.progesteroneTest !== undefined
|
||||
? (body.progesteroneTest ?? null)
|
||||
: stored.progesteroneTest,
|
||||
contraceptive:
|
||||
body.contraceptive !== undefined
|
||||
? (body.contraceptive ?? null)
|
||||
: stored.contraceptive,
|
||||
};
|
||||
|
||||
// Field-by-field update (no mass assignment). Non-sensitive fields are
|
||||
// written only when present; the sensitive set is re-resolved and split
|
||||
// between plaintext columns and the envelope per the flag.
|
||||
await prisma.cycleDayLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -80,21 +120,16 @@ export const PATCH = apiHandler(
|
||||
...(body.cervicalMucus !== undefined && {
|
||||
cervicalMucus: body.cervicalMucus,
|
||||
}),
|
||||
...(body.sexualActivity !== undefined && {
|
||||
sexualActivity: body.sexualActivity,
|
||||
}),
|
||||
...(body.protectedSex !== undefined && {
|
||||
protectedSex: body.protectedSex,
|
||||
}),
|
||||
...(body.pregnancyTest !== undefined && {
|
||||
pregnancyTest: body.pregnancyTest,
|
||||
}),
|
||||
...(body.progesteroneTest !== undefined && {
|
||||
progesteroneTest: body.progesteroneTest,
|
||||
}),
|
||||
...(body.contraceptive !== undefined && {
|
||||
contraceptive: body.contraceptive,
|
||||
}),
|
||||
sexualActivity: encryptSensitive ? false : merged.sexualActivity,
|
||||
protectedSex: encryptSensitive ? null : merged.protectedSex,
|
||||
pregnancyTest: (encryptSensitive ? null : merged.pregnancyTest) as never,
|
||||
progesteroneTest: (encryptSensitive
|
||||
? null
|
||||
: merged.progesteroneTest) as never,
|
||||
contraceptive: (encryptSensitive ? null : merged.contraceptive) as never,
|
||||
sensitiveEncrypted: encryptSensitive
|
||||
? encrypt(JSON.stringify(merged))
|
||||
: null,
|
||||
...(body.note !== undefined && {
|
||||
notesEncrypted: body.note ? encrypt(body.note) : null,
|
||||
}),
|
||||
@@ -104,25 +139,11 @@ export const PATCH = apiHandler(
|
||||
|
||||
// Replace symptom links only when `symptoms` was supplied.
|
||||
if (body.symptoms !== undefined) {
|
||||
const keys = Array.from(new Set(body.symptoms.map((s) => s.key)));
|
||||
const symptoms =
|
||||
keys.length > 0
|
||||
? await prisma.cycleSymptom.findMany({
|
||||
where: {
|
||||
key: { in: keys },
|
||||
isActive: true,
|
||||
OR: [{ userId: null }, { userId: user.id }],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: [];
|
||||
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: id } });
|
||||
if (symptoms.length > 0) {
|
||||
await prisma.cycleSymptomLink.createMany({
|
||||
data: symptoms.map((s) => ({ dayLogId: id, symptomId: s.id })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
await replaceSymptomLinks(
|
||||
user.id,
|
||||
id,
|
||||
body.symptoms.map((s) => s.key),
|
||||
);
|
||||
}
|
||||
|
||||
const row = await prisma.cycleDayLog.findUniqueOrThrow({
|
||||
@@ -190,3 +211,51 @@ export const DELETE = apiHandler(
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
);
|
||||
|
||||
/** The stored plaintext sensitive fields (envelope when encrypted, else columns). */
|
||||
function readStoredSensitive(row: {
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
}): {
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
} {
|
||||
if (row.sensitiveEncrypted) {
|
||||
try {
|
||||
const dec = JSON.parse(decrypt(row.sensitiveEncrypted)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
sexualActivity: (dec.sexualActivity as boolean) ?? false,
|
||||
protectedSex: (dec.protectedSex as boolean | null) ?? null,
|
||||
pregnancyTest: (dec.pregnancyTest as string | null) ?? null,
|
||||
progesteroneTest: (dec.progesteroneTest as string | null) ?? null,
|
||||
contraceptive: (dec.contraceptive as string | null) ?? null,
|
||||
};
|
||||
} catch {
|
||||
// Fail-soft: an undecryptable envelope reads as cleared.
|
||||
return {
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
sexualActivity: row.sexualActivity,
|
||||
protectedSex: row.protectedSex,
|
||||
pregnancyTest: row.pregnancyTest,
|
||||
progesteroneTest: row.progesteroneTest,
|
||||
contraceptive: row.contraceptive,
|
||||
};
|
||||
}
|
||||
@@ -123,8 +123,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Map to a stable closed set of reason codes — never echo the raw
|
||||
// driver message (it can carry column / constraint / value fragments
|
||||
// and bypasses the central redaction boundary). The full message is
|
||||
// logged through the WideEvent path where redaction applies.
|
||||
const code =
|
||||
typeof err === "object" && err !== null && "code" in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined;
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
|
||||
code === "P2002"
|
||||
? "constraint"
|
||||
: code === "P2003"
|
||||
? "constraint"
|
||||
: "upsert_failed";
|
||||
annotate({
|
||||
action: { name: "cycle.bulk.entry-failed" },
|
||||
meta: {
|
||||
index: i,
|
||||
reason,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
skipped += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
@@ -57,7 +58,29 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const cycleId = await findOwningCycleId(user.id, entry.date);
|
||||
const result = await upsertCycleDayLog(user.id, entry, tz, cycleId);
|
||||
let result;
|
||||
try {
|
||||
result = await upsertCycleDayLog(user.id, entry, tz, cycleId);
|
||||
} catch (err: unknown) {
|
||||
// A residual unique-constraint collision (the helper adopts the
|
||||
// canonical row on the common case) surfaces as a clean 409, never a
|
||||
// 500 (the MoodEntry conflict precedent).
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "cycle.day-log.conflict" },
|
||||
meta: { date: entry.date },
|
||||
});
|
||||
return apiError("Day-log already exists for this date", 409, {
|
||||
errorCode: "cycle.day-log.conflict",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const row = await prisma.cycleDayLog.findUniqueOrThrow({
|
||||
where: { id: result.id },
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface CycleBackupSection {
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
notesEncrypted: string | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
@@ -115,7 +116,8 @@ export async function buildCycleBackupSection(
|
||||
pregnancyTest: d.pregnancyTest,
|
||||
progesteroneTest: d.progesteroneTest,
|
||||
contraceptive: d.contraceptive,
|
||||
// Ciphertext envelope carried verbatim — never decrypted here.
|
||||
// Both ciphertext envelopes carried verbatim — never decrypted here.
|
||||
sensitiveEncrypted: d.sensitiveEncrypted,
|
||||
notesEncrypted: d.notesEncrypted,
|
||||
source: d.source,
|
||||
externalId: d.externalId,
|
||||
@@ -286,7 +288,8 @@ export async function restoreCycleData(
|
||||
pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never,
|
||||
progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS) as never,
|
||||
contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES) as never,
|
||||
// Ciphertext envelope written back verbatim — never re-encrypted.
|
||||
// Both ciphertext envelopes written back verbatim — never re-encrypted.
|
||||
sensitiveEncrypted: d.sensitiveEncrypted ?? null,
|
||||
notesEncrypted: d.notesEncrypted ?? null,
|
||||
source: (enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL") as never,
|
||||
externalId: d.externalId ?? null,
|
||||
|
||||
+360
-95
@@ -4,10 +4,24 @@
|
||||
*
|
||||
* UPSERT key: `(userId, source, externalId)` when `externalId` is present
|
||||
* (the NULL-distinct cross-device dedup key, like MoodEntry), else the
|
||||
* canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM);
|
||||
* every other field is queryable plaintext. Symptom keys resolve against
|
||||
* the seeded catalog (unknown keys dropped silently) and write the
|
||||
* `CycleSymptomLink` join.
|
||||
* canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM).
|
||||
*
|
||||
* Sensitive-category encryption: when the resolved profile flag is ON the
|
||||
* five intent-revealing fields (sexualActivity, protectedSex,
|
||||
* pregnancyTest, progesteroneTest, contraceptive) are encrypted into the
|
||||
* single `sensitiveEncrypted` JSON envelope and the plaintext columns are
|
||||
* NULLed — they then drop out of the rollup / correlation tier (the trade
|
||||
* for at-rest secrecy). When OFF the plaintext columns are written as
|
||||
* before and the envelope is NULL.
|
||||
*
|
||||
* Partial merge: a re-post that omits a field never nulls a previously
|
||||
* stored value — the UPDATE branch only overwrites the fields actually
|
||||
* present in the input.
|
||||
*
|
||||
* Collision-safe: on a `(userId, source, externalId)`-keyed create that
|
||||
* collides with an existing canonical `(userId, date)` row (Prisma P2002),
|
||||
* the existing row is adopted and UPDATEd in place — one canonical row per
|
||||
* date regardless of provenance.
|
||||
*
|
||||
* Returns the upserted row id + whether a row already existed (so the
|
||||
* caller can map `inserted`/`updated`/`duplicate`) plus whether any field
|
||||
@@ -15,8 +29,10 @@
|
||||
* distinction).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import type { CycleDayLogInput } from "@/lib/validations/cycle";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export interface DayLogWriteResult {
|
||||
id: string;
|
||||
@@ -25,8 +41,17 @@ export interface DayLogWriteResult {
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** The five intent-revealing fields the encryption envelope carries. */
|
||||
interface SensitiveFields {
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
}
|
||||
|
||||
/** Resolve catalog symptom keys → ids, dropping unknown keys silently. */
|
||||
async function resolveSymptomIds(
|
||||
export async function resolveSymptomIds(
|
||||
userId: string,
|
||||
keys: readonly string[],
|
||||
): Promise<string[]> {
|
||||
@@ -44,6 +69,81 @@ async function resolveSymptomIds(
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the symptom-link set for a day-log. Shared by the upsert helper
|
||||
* and the PATCH route so both resolve + write the join identically.
|
||||
*/
|
||||
export async function replaceSymptomLinks(
|
||||
userId: string,
|
||||
dayLogId: string,
|
||||
keys: readonly string[],
|
||||
): Promise<void> {
|
||||
const ids = await resolveSymptomIds(userId, keys);
|
||||
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId } });
|
||||
if (ids.length > 0) {
|
||||
await prisma.cycleSymptomLink.createMany({
|
||||
data: ids.map((symptomId) => ({ dayLogId, symptomId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt the sensitive envelope fail-soft (null on missing / undecryptable). */
|
||||
function decryptSensitive(envelope: string | null): Partial<SensitiveFields> {
|
||||
if (!envelope) return {};
|
||||
try {
|
||||
return JSON.parse(decrypt(envelope)) as Partial<SensitiveFields>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt a note fail-soft (null on missing / undecryptable). */
|
||||
function decryptNoteSoft(notesEncrypted: string | null): string | null {
|
||||
if (!notesEncrypted) return null;
|
||||
try {
|
||||
return decrypt(notesEncrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ExistingRow = {
|
||||
id: string;
|
||||
date: string;
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: string | null;
|
||||
cervicalMucus: string | null;
|
||||
sexualActivity: boolean;
|
||||
protectedSex: boolean | null;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
sensitiveEncrypted: string | null;
|
||||
notesEncrypted: string | null;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
|
||||
const EXISTING_SELECT = {
|
||||
id: true,
|
||||
date: true,
|
||||
flow: true,
|
||||
intermenstrualBleeding: true,
|
||||
basalBodyTempC: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
notesEncrypted: true,
|
||||
deletedAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Upsert one cycle day-log. `tz` anchors the date string. When
|
||||
* `cycleId` is supplied the row is attributed to that cycle span.
|
||||
@@ -55,9 +155,12 @@ export async function upsertCycleDayLog(
|
||||
cycleId: string | null = null,
|
||||
): Promise<DayLogWriteResult> {
|
||||
const source = entry.source;
|
||||
const notesEncrypted = entry.note ? encrypt(entry.note) : null;
|
||||
|
||||
const where = entry.externalId
|
||||
// Resolve the sensitive-category encryption flag once per write.
|
||||
const profile = await getOrCreateCycleProfile(userId);
|
||||
const encryptSensitive = profile.sensitiveCategoryEncryption;
|
||||
|
||||
const where: Prisma.CycleDayLogWhereUniqueInput = entry.externalId
|
||||
? {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
@@ -69,103 +172,265 @@ export async function upsertCycleDayLog(
|
||||
|
||||
// Probe so the caller can distinguish inserted / updated / duplicate
|
||||
// and (for an externalId re-post) detect whether a field changed.
|
||||
const existing = await prisma.cycleDayLog.findUnique({
|
||||
const existing = (await prisma.cycleDayLog.findUnique({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
flow: true,
|
||||
intermenstrualBleeding: true,
|
||||
basalBodyTempC: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
notesEncrypted: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
select: EXISTING_SELECT,
|
||||
})) as ExistingRow | null;
|
||||
|
||||
const result = await writeDayLog({
|
||||
userId,
|
||||
entry,
|
||||
tz,
|
||||
cycleId,
|
||||
source,
|
||||
where,
|
||||
existing,
|
||||
encryptSensitive,
|
||||
});
|
||||
|
||||
// Field-by-field create data (no mass assignment).
|
||||
const baseData = {
|
||||
flow: entry.flow ?? null,
|
||||
intermenstrualBleeding: entry.intermenstrualBleeding ?? false,
|
||||
basalBodyTempC: entry.basalBodyTempC ?? null,
|
||||
ovulationTest: entry.ovulationTest ?? null,
|
||||
cervicalMucus: entry.cervicalMucus ?? null,
|
||||
sexualActivity: entry.sexualActivity ?? false,
|
||||
protectedSex: entry.protectedSex ?? null,
|
||||
pregnancyTest: entry.pregnancyTest ?? null,
|
||||
progesteroneTest: entry.progesteroneTest ?? null,
|
||||
contraceptive: entry.contraceptive ?? null,
|
||||
return result;
|
||||
}
|
||||
|
||||
interface WriteArgs {
|
||||
userId: string;
|
||||
entry: CycleDayLogInput;
|
||||
tz: string | null;
|
||||
cycleId: string | null;
|
||||
source: CycleDayLogInput["source"];
|
||||
where: Prisma.CycleDayLogWhereUniqueInput;
|
||||
existing: ExistingRow | null;
|
||||
encryptSensitive: boolean;
|
||||
}
|
||||
|
||||
async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
|
||||
const { userId, entry, tz, cycleId, source, where, existing, encryptSensitive } =
|
||||
args;
|
||||
|
||||
// Decrypt the existing note (fail-soft) for the change/duplicate split —
|
||||
// re-encrypting the same plaintext yields a different ciphertext (random
|
||||
// GCM IV), so a ciphertext compare would always read "changed".
|
||||
const existingNote = decryptNoteSoft(existing?.notesEncrypted ?? null);
|
||||
|
||||
// The resolved plaintext sensitive values for THIS write, after partial
|
||||
// merge against what's already stored (decrypted from the envelope when
|
||||
// the stored row was encrypted).
|
||||
const storedSensitive: SensitiveFields = existing
|
||||
? mergeStoredSensitive(existing)
|
||||
: {
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
};
|
||||
|
||||
const resolvedSensitive: SensitiveFields = {
|
||||
sexualActivity:
|
||||
entry.sexualActivity !== undefined
|
||||
? entry.sexualActivity
|
||||
: storedSensitive.sexualActivity,
|
||||
protectedSex:
|
||||
entry.protectedSex !== undefined
|
||||
? (entry.protectedSex ?? null)
|
||||
: storedSensitive.protectedSex,
|
||||
pregnancyTest:
|
||||
entry.pregnancyTest !== undefined
|
||||
? (entry.pregnancyTest ?? null)
|
||||
: storedSensitive.pregnancyTest,
|
||||
progesteroneTest:
|
||||
entry.progesteroneTest !== undefined
|
||||
? (entry.progesteroneTest ?? null)
|
||||
: storedSensitive.progesteroneTest,
|
||||
contraceptive:
|
||||
entry.contraceptive !== undefined
|
||||
? (entry.contraceptive ?? null)
|
||||
: storedSensitive.contraceptive,
|
||||
};
|
||||
|
||||
// Note resolution with partial merge: an omitted `note` keeps the stored
|
||||
// value; an explicit null clears it.
|
||||
const incomingNote =
|
||||
entry.note !== undefined ? (entry.note ?? null) : decryptNoteSoft(existing?.notesEncrypted ?? null);
|
||||
const notesEncrypted = incomingNote ? encrypt(incomingNote) : null;
|
||||
|
||||
// Non-sensitive fields, partial-merged against the stored row.
|
||||
const flow = entry.flow !== undefined ? (entry.flow ?? null) : (existing?.flow ?? null);
|
||||
const intermenstrualBleeding =
|
||||
entry.intermenstrualBleeding !== undefined
|
||||
? entry.intermenstrualBleeding
|
||||
: (existing?.intermenstrualBleeding ?? false);
|
||||
const basalBodyTempC =
|
||||
entry.basalBodyTempC !== undefined
|
||||
? (entry.basalBodyTempC ?? null)
|
||||
: (existing?.basalBodyTempC ?? null);
|
||||
const ovulationTest =
|
||||
entry.ovulationTest !== undefined
|
||||
? (entry.ovulationTest ?? null)
|
||||
: (existing?.ovulationTest ?? null);
|
||||
const cervicalMucus =
|
||||
entry.cervicalMucus !== undefined
|
||||
? (entry.cervicalMucus ?? null)
|
||||
: (existing?.cervicalMucus ?? null);
|
||||
|
||||
// Split the sensitive fields between plaintext columns and the envelope
|
||||
// depending on the flag. When encrypting, the plaintext columns are NULL
|
||||
// (drop out of rollup/correlation) and the envelope carries the JSON.
|
||||
const sensitiveEncrypted = encryptSensitive
|
||||
? encrypt(JSON.stringify(resolvedSensitive))
|
||||
: null;
|
||||
const sensitivePlaintext = encryptSensitive
|
||||
? {
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
}
|
||||
: { ...resolvedSensitive };
|
||||
|
||||
const baseData: Prisma.CycleDayLogUncheckedUpdateInput = {
|
||||
flow: flow as never,
|
||||
intermenstrualBleeding,
|
||||
basalBodyTempC,
|
||||
ovulationTest: ovulationTest as never,
|
||||
cervicalMucus: cervicalMucus as never,
|
||||
sexualActivity: sensitivePlaintext.sexualActivity,
|
||||
protectedSex: sensitivePlaintext.protectedSex,
|
||||
pregnancyTest: sensitivePlaintext.pregnancyTest as never,
|
||||
progesteroneTest: sensitivePlaintext.progesteroneTest as never,
|
||||
contraceptive: sensitivePlaintext.contraceptive as never,
|
||||
sensitiveEncrypted,
|
||||
notesEncrypted,
|
||||
};
|
||||
|
||||
// Last-writer-wins on the field set. An externalId re-post also refreshes
|
||||
// `date` (a re-zoned re-import lands the corrected wall-clock). A
|
||||
// soft-deleted row is resurrected on a fresh write (deletedAt cleared).
|
||||
const updateData = {
|
||||
...baseData,
|
||||
...(entry.externalId ? { date: entry.date } : {}),
|
||||
...(cycleId !== null ? { cycleId } : {}),
|
||||
deletedAt: null,
|
||||
syncVersion: { increment: 1 },
|
||||
};
|
||||
|
||||
const row = await prisma.cycleDayLog.upsert({
|
||||
where,
|
||||
create: {
|
||||
userId,
|
||||
date: entry.date,
|
||||
tz,
|
||||
source,
|
||||
externalId: entry.externalId ?? null,
|
||||
cycleId,
|
||||
...baseData,
|
||||
},
|
||||
update: updateData,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Replace the symptom-link set when symptoms were supplied. Absent
|
||||
// `symptoms` leaves existing links untouched (a partial day-log edit
|
||||
// that doesn't mention symptoms must not wipe them).
|
||||
if (entry.symptoms !== undefined) {
|
||||
const ids = await resolveSymptomIds(
|
||||
userId,
|
||||
entry.symptoms.map((s) => s.key),
|
||||
);
|
||||
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: row.id } });
|
||||
if (ids.length > 0) {
|
||||
await prisma.cycleSymptomLink.createMany({
|
||||
data: ids.map((symptomId) => ({ dayLogId: row.id, symptomId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// `changed` only matters for the bulk `updated` vs `duplicate` split on
|
||||
// an existing row: did any persisted field differ from the re-post?
|
||||
let changed = false;
|
||||
// Did anything actually change vs the stored row? Compare the resolved
|
||||
// PLAINTEXT values (note + sensitive), never ciphertext.
|
||||
let changed = !existing;
|
||||
if (existing) {
|
||||
changed =
|
||||
existing.flow !== baseData.flow ||
|
||||
existing.intermenstrualBleeding !== baseData.intermenstrualBleeding ||
|
||||
existing.basalBodyTempC !== baseData.basalBodyTempC ||
|
||||
existing.ovulationTest !== baseData.ovulationTest ||
|
||||
existing.cervicalMucus !== baseData.cervicalMucus ||
|
||||
existing.sexualActivity !== baseData.sexualActivity ||
|
||||
existing.protectedSex !== baseData.protectedSex ||
|
||||
existing.pregnancyTest !== baseData.pregnancyTest ||
|
||||
existing.progesteroneTest !== baseData.progesteroneTest ||
|
||||
existing.contraceptive !== baseData.contraceptive ||
|
||||
existing.notesEncrypted !== baseData.notesEncrypted ||
|
||||
(existing.flow ?? null) !== flow ||
|
||||
existing.intermenstrualBleeding !== intermenstrualBleeding ||
|
||||
(existing.basalBodyTempC ?? null) !== basalBodyTempC ||
|
||||
(existing.ovulationTest ?? null) !== ovulationTest ||
|
||||
(existing.cervicalMucus ?? null) !== cervicalMucus ||
|
||||
storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity ||
|
||||
storedSensitive.protectedSex !== resolvedSensitive.protectedSex ||
|
||||
storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest ||
|
||||
storedSensitive.progesteroneTest !== resolvedSensitive.progesteroneTest ||
|
||||
storedSensitive.contraceptive !== resolvedSensitive.contraceptive ||
|
||||
existingNote !== incomingNote ||
|
||||
existing.deletedAt !== null ||
|
||||
(Boolean(entry.externalId) && existing.date !== entry.date);
|
||||
}
|
||||
|
||||
return { id: row.id, existed: existing !== null, changed };
|
||||
const updateData: Prisma.CycleDayLogUncheckedUpdateInput = {
|
||||
...baseData,
|
||||
...(entry.externalId ? { date: entry.date } : {}),
|
||||
...(cycleId !== null ? { cycleId } : {}),
|
||||
deletedAt: null,
|
||||
// Only bump syncVersion when a field actually changed — an unchanged
|
||||
// re-post must be a true no-op on the /api/sync/changes feed so other
|
||||
// devices don't re-pull an identical row.
|
||||
...(changed ? { syncVersion: { increment: 1 } } : {}),
|
||||
};
|
||||
|
||||
let rowId: string;
|
||||
let existed = existing !== null;
|
||||
try {
|
||||
const row = await prisma.cycleDayLog.upsert({
|
||||
where,
|
||||
create: {
|
||||
userId,
|
||||
date: entry.date,
|
||||
tz,
|
||||
source,
|
||||
externalId: entry.externalId ?? null,
|
||||
cycleId,
|
||||
flow: baseData.flow as never,
|
||||
intermenstrualBleeding: baseData.intermenstrualBleeding as never,
|
||||
basalBodyTempC: baseData.basalBodyTempC as never,
|
||||
ovulationTest: baseData.ovulationTest as never,
|
||||
cervicalMucus: baseData.cervicalMucus as never,
|
||||
sexualActivity: sensitivePlaintext.sexualActivity,
|
||||
protectedSex: sensitivePlaintext.protectedSex,
|
||||
pregnancyTest: sensitivePlaintext.pregnancyTest as never,
|
||||
progesteroneTest: sensitivePlaintext.progesteroneTest as never,
|
||||
contraceptive: sensitivePlaintext.contraceptive as never,
|
||||
sensitiveEncrypted,
|
||||
notesEncrypted,
|
||||
},
|
||||
update: updateData,
|
||||
select: { id: true },
|
||||
});
|
||||
rowId = row.id;
|
||||
} catch (err: unknown) {
|
||||
// Dual-unique collision: an externalId-keyed create whose date already
|
||||
// has a canonical (userId, date) row. Adopt the existing row and UPDATE
|
||||
// it in place — one canonical row per date regardless of provenance.
|
||||
if (isUniqueViolation(err) && entry.externalId) {
|
||||
const canonical = await prisma.cycleDayLog.findUnique({
|
||||
where: { userId_date: { userId, date: entry.date } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!canonical) throw err;
|
||||
const adopted = await prisma.cycleDayLog.update({
|
||||
where: { id: canonical.id },
|
||||
data: {
|
||||
...updateData,
|
||||
source,
|
||||
externalId: entry.externalId,
|
||||
...(changed ? {} : { syncVersion: { increment: 1 } }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
rowId = adopted.id;
|
||||
existed = true;
|
||||
changed = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the symptom-link set when symptoms were supplied. Absent
|
||||
// `symptoms` leaves existing links untouched.
|
||||
if (entry.symptoms !== undefined) {
|
||||
await replaceSymptomLinks(
|
||||
userId,
|
||||
rowId,
|
||||
entry.symptoms.map((s) => s.key),
|
||||
);
|
||||
}
|
||||
|
||||
return { id: rowId, existed, changed };
|
||||
}
|
||||
|
||||
/** Prisma P2002 (unique constraint) detector without importing the runtime. */
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the stored plaintext sensitive fields (envelope OR columns). */
|
||||
function mergeStoredSensitive(existing: ExistingRow): SensitiveFields {
|
||||
if (existing.sensitiveEncrypted) {
|
||||
const dec = decryptSensitive(existing.sensitiveEncrypted);
|
||||
return {
|
||||
sexualActivity: dec.sexualActivity ?? false,
|
||||
protectedSex: dec.protectedSex ?? null,
|
||||
pregnancyTest: dec.pregnancyTest ?? null,
|
||||
progesteroneTest: dec.progesteroneTest ?? null,
|
||||
contraceptive: dec.contraceptive ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sexualActivity: existing.sexualActivity,
|
||||
protectedSex: existing.protectedSex,
|
||||
pregnancyTest: existing.pregnancyTest,
|
||||
progesteroneTest: existing.progesteroneTest,
|
||||
contraceptive: existing.contraceptive,
|
||||
};
|
||||
}
|
||||
+34
-5
@@ -73,7 +73,34 @@ function decryptNote(notesEncrypted: string | null): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
interface SensitiveEnvelope {
|
||||
sexualActivity?: boolean;
|
||||
protectedSex?: boolean | null;
|
||||
pregnancyTest?: string | null;
|
||||
progesteroneTest?: string | null;
|
||||
contraceptive?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the sensitive-category envelope fail-soft. When
|
||||
* `sensitiveCategoryEncryption` was ON at write the five intent fields live
|
||||
* here instead of the plaintext columns; an undecryptable envelope reads as
|
||||
* an empty object so the page never 500s.
|
||||
*/
|
||||
function decryptSensitive(envelope: string | null): SensitiveEnvelope {
|
||||
if (!envelope) return {};
|
||||
try {
|
||||
return JSON.parse(decrypt(envelope)) as SensitiveEnvelope;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
|
||||
// Prefer the encryption envelope when present; else read the plaintext
|
||||
// columns (the flag-OFF path).
|
||||
const enc = decryptSensitive(row.sensitiveEncrypted);
|
||||
const hasEnvelope = row.sensitiveEncrypted != null;
|
||||
return {
|
||||
id: row.id,
|
||||
date: row.date,
|
||||
@@ -83,11 +110,13 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
|
||||
basalBodyTempC: row.basalBodyTempC,
|
||||
ovulationTest: row.ovulationTest,
|
||||
cervicalMucus: row.cervicalMucus,
|
||||
sexualActivity: row.sexualActivity,
|
||||
protectedSex: row.protectedSex,
|
||||
pregnancyTest: row.pregnancyTest,
|
||||
progesteroneTest: row.progesteroneTest,
|
||||
contraceptive: row.contraceptive,
|
||||
sexualActivity: hasEnvelope ? (enc.sexualActivity ?? false) : row.sexualActivity,
|
||||
protectedSex: hasEnvelope ? (enc.protectedSex ?? null) : row.protectedSex,
|
||||
pregnancyTest: hasEnvelope ? (enc.pregnancyTest ?? null) : row.pregnancyTest,
|
||||
progesteroneTest: hasEnvelope
|
||||
? (enc.progesteroneTest ?? null)
|
||||
: row.progesteroneTest,
|
||||
contraceptive: hasEnvelope ? (enc.contraceptive ?? null) : row.contraceptive,
|
||||
symptoms: (row.symptomLinks ?? []).map((l) => ({
|
||||
key: l.symptom.key,
|
||||
severity: null,
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
|
||||
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
mapHkCycleSample,
|
||||
isCycleHkIdentifier,
|
||||
@@ -33,6 +34,14 @@ import type { ContraceptiveKind } from "@/generated/prisma/client";
|
||||
/** Synthetic per-day externalId prefix for HealthKit-imported cycle rows. */
|
||||
export const HK_CYCLE_DAY_EXTERNAL_PREFIX = "hkcycle:";
|
||||
|
||||
/** A zeroed cycle-import result — the no-cycle / aborted-flush fallback. */
|
||||
export const EMPTY_CYCLE_IMPORT_STATS: CycleImportStats = {
|
||||
samplesConsumed: 0,
|
||||
daysUpserted: 0,
|
||||
daysInserted: 0,
|
||||
goalNudged: false,
|
||||
};
|
||||
|
||||
/** Per-day merge state held during the parse. */
|
||||
interface DayBucket {
|
||||
fields: CycleDayLogFields;
|
||||
@@ -162,14 +171,8 @@ export class CycleImportAccumulator {
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
]);
|
||||
// Inline the gate logic (no profile auto-create on the import path).
|
||||
const toggle = profile?.cycleTrackingEnabled;
|
||||
this.gated =
|
||||
toggle === true
|
||||
? true
|
||||
: toggle === false
|
||||
? false
|
||||
: user?.gender === "FEMALE";
|
||||
// The single source-of-truth gate (no profile auto-create on import).
|
||||
this.gated = isCycleEnabled(user?.gender, profile);
|
||||
return this.gated;
|
||||
}
|
||||
|
||||
@@ -188,6 +191,23 @@ export class CycleImportAccumulator {
|
||||
|
||||
let contraceptiveNudge: ContraceptiveKind | null = null;
|
||||
|
||||
// Hoist cycle attribution out of the per-day loop: load the user's
|
||||
// (non-deleted) cycle start dates once and resolve the owning span in
|
||||
// memory (latest start <= date) rather than a query per day.
|
||||
const cycleRows = await prisma.menstrualCycle.findMany({
|
||||
where: { userId: this.userId, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { id: true, startDate: true },
|
||||
});
|
||||
const owningCycleId = (date: string): string | null => {
|
||||
let id: string | null = null;
|
||||
for (const c of cycleRows) {
|
||||
if (c.startDate <= date) id = c.id;
|
||||
else break;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
for (const [dayKey, bucket] of this.byDay.entries()) {
|
||||
const symptoms =
|
||||
bucket.symptomKeys.size > 0
|
||||
@@ -206,7 +226,7 @@ export class CycleImportAccumulator {
|
||||
loggedAt: `${dayKey}T12:00:00.000Z`,
|
||||
};
|
||||
|
||||
const cycleId = await findOwningCycleId(this.userId, dayKey);
|
||||
const cycleId = owningCycleId(dayKey);
|
||||
const result = await upsertCycleDayLog(
|
||||
this.userId,
|
||||
entry,
|
||||
@@ -235,7 +255,15 @@ export class CycleImportAccumulator {
|
||||
where: { userId: this.userId, goal: "GENERAL_HEALTH" },
|
||||
data: { goal: "AVOID_PREGNANCY" },
|
||||
});
|
||||
if (updated.count > 0) stats.goalNudged = true;
|
||||
if (updated.count > 0) {
|
||||
stats.goalNudged = true;
|
||||
// A server-side mutation of an intent-revealing setting must not be
|
||||
// invisible — record it on the audit trail.
|
||||
await auditLog("cycle.profile.goal-nudged", {
|
||||
userId: this.userId,
|
||||
details: { from: "GENERAL_HEALTH", to: "AVOID_PREGNANCY", via: "apple-health-import" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
|
||||
@@ -368,6 +368,9 @@ export async function runCycleReminderTick(
|
||||
userId: user.id,
|
||||
title,
|
||||
message: body,
|
||||
// In discreet mode the senders mask the lock-screen routing
|
||||
// metadata so no cycle event is named on the lock screen.
|
||||
discreet,
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
localDate: today,
|
||||
|
||||
@@ -46,6 +46,7 @@ import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activit
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import {
|
||||
CycleImportAccumulator,
|
||||
EMPTY_CYCLE_IMPORT_STATS,
|
||||
type CycleImportStats,
|
||||
} from "@/lib/cycle/import-accumulator";
|
||||
import { HK_SEXUAL_ACTIVITY_PROTECTION_META } from "@/lib/cycle/healthkit-mapping";
|
||||
@@ -815,19 +816,23 @@ export async function streamParseExportXml(
|
||||
// v1.15.0 — fold the accumulated reproductive samples into CYCLE
|
||||
// day-logs. Gated on cycle-tracking being enabled for the account so a
|
||||
// non-cycle Apple Health export never silently provisions cycle rows.
|
||||
let cycle: CycleImportStats = {
|
||||
samplesConsumed: 0,
|
||||
daysUpserted: 0,
|
||||
daysInserted: 0,
|
||||
goalNudged: false,
|
||||
};
|
||||
let cycle: CycleImportStats = { ...EMPTY_CYCLE_IMPORT_STATS };
|
||||
// Only touch the cycle tables when the export actually carried
|
||||
// reproductive samples AND the account has cycle tracking enabled. The
|
||||
// empty-accumulator short-circuit also keeps the no-cycle import path
|
||||
// (and its unit tests) free of any cycle DB round-trip.
|
||||
// (and its unit tests) free of any cycle DB round-trip. A flush failure
|
||||
// (e.g. a single colliding day) must never abort the whole import — fold
|
||||
// the error into zeroed cycle stats and continue.
|
||||
if (cycleAccumulator.hasSamples() && (await cycleAccumulator.isEnabled())) {
|
||||
cycle = await cycleAccumulator.flush();
|
||||
rowsUpserted += cycle.daysUpserted;
|
||||
try {
|
||||
cycle = await cycleAccumulator.flush();
|
||||
rowsUpserted += cycle.daysUpserted;
|
||||
} catch (err: unknown) {
|
||||
cycle = { ...EMPTY_CYCLE_IMPORT_STATS };
|
||||
console.warn(
|
||||
`cycle import flush failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await emitProgress("upserting");
|
||||
|
||||
@@ -523,6 +523,7 @@ export async function sendViaApns(
|
||||
message: string;
|
||||
eventType: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
discreet?: boolean;
|
||||
},
|
||||
): Promise<SendOutcome> {
|
||||
const config = loadApnsConfig();
|
||||
@@ -602,6 +603,12 @@ export async function sendViaApns(
|
||||
const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";
|
||||
|
||||
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
|
||||
// Discreet mode (cycle privacy): the lock-screen-visible routing
|
||||
// metadata must not carry the event name. Collapse the category /
|
||||
// threadId / collapseId and the data eventType to a generic value so
|
||||
// CYCLE_PERIOD_SOON never surfaces on the lock screen.
|
||||
const GENERIC_EVENT = "REMINDER";
|
||||
const routingEvent = payload.discreet ? GENERIC_EVENT : payload.eventType;
|
||||
let workingEnv: "production" | "sandbox" | null = null;
|
||||
for (const attemptEnv of envSequence) {
|
||||
result = await sendApnsPush({
|
||||
@@ -610,16 +617,16 @@ export async function sendViaApns(
|
||||
payload: {
|
||||
alert: { title: payload.title, body: stripHtml(payload.message) },
|
||||
data: {
|
||||
eventType: payload.eventType,
|
||||
eventType: routingEvent,
|
||||
...pickIosMetadata(payload.metadata),
|
||||
},
|
||||
threadId: payload.eventType,
|
||||
threadId: routingEvent,
|
||||
// The iOS app registers a `UNNotificationCategory` per event-type
|
||||
// so the same string doubles as the category identifier. iOS
|
||||
// ignores categories it doesn't know about, so adding the key
|
||||
// here is safe for event-types that don't have an actionable
|
||||
// category registered yet — they fall back to a plain alert.
|
||||
category: payload.eventType,
|
||||
category: routingEvent,
|
||||
// Future-proof for an iOS Notification Service Extension. iOS
|
||||
// ignores the flag when no extension is registered, so it's a
|
||||
// no-op on today's binary but unblocks NSE work without a
|
||||
@@ -632,7 +639,7 @@ export async function sendViaApns(
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
collapseId: payload.eventType,
|
||||
collapseId: routingEvent,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
|
||||
@@ -29,7 +29,12 @@ export async function sendViaNtfy(
|
||||
Title: payload.title,
|
||||
Priority:
|
||||
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
|
||||
Tags: payload.eventType.toLowerCase().replace(/_/g, "-"),
|
||||
// Discreet mode (cycle privacy): the X-Tags header is visible on the
|
||||
// lock screen, so collapse it to a generic tag instead of leaking the
|
||||
// cycle event name.
|
||||
Tags: payload.discreet
|
||||
? "reminder"
|
||||
: payload.eventType.toLowerCase().replace(/_/g, "-"),
|
||||
};
|
||||
|
||||
if (config.authToken) {
|
||||
|
||||
@@ -96,4 +96,12 @@ export interface NotificationPayload {
|
||||
message: string;
|
||||
/** Channel-specific extras (e.g. medicationId for Telegram inline buttons) */
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Discreet mode (cycle privacy). When true, the lock-screen-visible
|
||||
* routing metadata the senders would otherwise derive from `eventType`
|
||||
* (APNs category / threadId / collapseId, ntfy Tags) is replaced with a
|
||||
* generic value so the cycle event name never appears on the lock screen
|
||||
* — the title/body are already masked upstream.
|
||||
*/
|
||||
discreet?: boolean;
|
||||
}
|
||||
@@ -148,6 +148,7 @@ const cycleDayLogSchema = z
|
||||
pregnancyTest: z.string().nullable().optional(),
|
||||
progesteroneTest: z.string().nullable().optional(),
|
||||
contraceptive: z.string().nullable().optional(),
|
||||
sensitiveEncrypted: z.string().nullable().optional(),
|
||||
notesEncrypted: z.string().nullable().optional(),
|
||||
source: z.string().min(1).optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
|
||||
Reference in new issue
Block a user