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:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 11:49:23 +02:00
1 parent 3e398295a3
commit ac13dd28ed
22 files changed
+770 -174

No files matched your search

+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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).
+61
View File
@@ -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 });
});
+108 -39
View File
@@ -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,
};
}
+21 -1
View File
@@ -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,
+24 -1
View File
@@ -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 },
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+39 -11
View File
@@ -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;
+3
View File
@@ -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");
+11 -4
View File
@@ -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) {
+6 -1
View File
@@ -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) {
+8
View File
@@ -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;
}
+1
View File
@@ -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(),