mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'fix/coldstart-perf' into release/v1.27.32
This commit is contained in:
commit
57c7972755
27 files changed
+1040
-179
No files matched your search
+17
-8
@@ -18,15 +18,24 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
# When using docker-compose.yml: the app container reaches Postgres via the
|
||||
# service name "db", so the hostname is "db" — NOT "localhost".
|
||||
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
|
||||
# change the hostname to "localhost" and adjust the credentials.
|
||||
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
|
||||
# Bundled docker-compose.yml path (the common case): you do NOT set DATABASE_URL
|
||||
# here. Compose CONSTRUCTS it inside the `app` service from POSTGRES_PASSWORD
|
||||
# (see docker-compose.yml — the `environment:` block wins over any value in this
|
||||
# file), so a DATABASE_URL line here is ignored for that stack. Set only
|
||||
# POSTGRES_PASSWORD below and compose wires the connection string for you.
|
||||
#
|
||||
# Bare-Node / custom-compose path (`pnpm dev` against a host Postgres, or your
|
||||
# own compose that does NOT derive DATABASE_URL): uncomment DATABASE_URL and set
|
||||
# it explicitly. Hostname is "db" on the bundled compose network, or
|
||||
# "localhost" for a host-installed Postgres.
|
||||
# DATABASE_URL="postgresql://healthlog:YOUR_PASSWORD@localhost:5432/healthlog?schema=public"
|
||||
|
||||
# Postgres password — used both by the db service AND by DATABASE_URL above.
|
||||
# Pick a strong random password, e.g. `openssl rand -hex 32`.
|
||||
POSTGRES_PASSWORD="CHANGE-ME"
|
||||
# Postgres password for the bundled `db` service — and the value compose splices
|
||||
# into DATABASE_URL. The quick-start appends a generated one:
|
||||
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
|
||||
# Left commented here so that append does not create a duplicate key. Uncomment
|
||||
# and set it directly instead if you prefer to edit the file by hand.
|
||||
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -53,11 +53,14 @@ echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
|
||||
Then open `.env` and confirm `DATABASE_URL` includes the password you
|
||||
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
|
||||
references the literal string `CHANGE-ME`; replace it with the value
|
||||
you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute
|
||||
explicitly.
|
||||
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
|
||||
stack: compose constructs the connection string inside the `app`
|
||||
service from `${POSTGRES_PASSWORD}` (its `environment:` block overrides
|
||||
any `DATABASE_URL` in `.env`), so the `openssl` line above is the only
|
||||
password step. `DATABASE_URL` only matters if you run a **bare Node**
|
||||
process (`pnpm dev` against a host Postgres) or a **custom compose** that
|
||||
does not derive it — in that case uncomment and set `DATABASE_URL`
|
||||
explicitly in `.env` (see the commented example there).
|
||||
|
||||
## 3. Bring the stack up
|
||||
|
||||
@@ -92,6 +95,22 @@ After the first user lands, you can:
|
||||
- Open `/measurements` and add a weight or blood pressure to confirm
|
||||
the data path is wired end-to-end.
|
||||
|
||||
> **Close public registration if you expose the instance to the
|
||||
> internet.** Registration stays **open** after the first admin so you
|
||||
> can invite the rest of your household — but on a publicly reachable
|
||||
> host that means strangers can still sign up. Once everyone who needs
|
||||
> an account has one, turn it off in **`/admin` → registration toggle**
|
||||
> (or keep it open and hand out invite links instead). The admin
|
||||
> settings show a reminder underneath the toggle while registration is
|
||||
> open.
|
||||
|
||||
> **No email is configured out of the box** — a deliberate first-boot
|
||||
> simplicity win, but it also means password reset has no self-service
|
||||
> path until you set SMTP (see `docs/self-hosting/notifications.md`). If
|
||||
> you are the sole admin, **save your password in a manager now**: a
|
||||
> locked-out solo admin with no SMTP recovers only via the CLI script in
|
||||
> `docs/ops/password-reset.md`.
|
||||
|
||||
## 5. Point the instance at a public hostname
|
||||
|
||||
Local-only is fine for a single-user trial. For anything more — and
|
||||
|
||||
+2
-1
@@ -2082,7 +2082,7 @@
|
||||
"emptyAction": "Briefing erstellen",
|
||||
"loadingLabel": "Briefing wird zusammengestellt",
|
||||
"noProviderTitle": "Kein KI-Anbieter verbunden",
|
||||
"noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.",
|
||||
"noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio – nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen – zum Beispiel: „Gewicht in zwei Wochen um 1,2 kg gesunken, Ruhepuls stabil bei 58, Blutdruck an 6 von 7 Tagen im Zielbereich.“ Die Einrichtung dauert etwa eine Minute.",
|
||||
"noProviderAction": "Zu den KI-Einstellungen",
|
||||
"staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.",
|
||||
"refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.",
|
||||
@@ -5496,6 +5496,7 @@
|
||||
"appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.",
|
||||
"registrationEnabled": "Registrierung erlauben",
|
||||
"registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Nutzer registrieren.",
|
||||
"registrationOpenHint": "Die Registrierung ist offen — jeder, der diese Instanz erreichen kann, kann ein Konto anlegen. Schließe sie hier, sobald alle ein Konto haben.",
|
||||
"defaultLanguage": "Standard-Sprache",
|
||||
"defaultLanguageDescription": "Wird für neue Nutzer und als Fallback verwendet.",
|
||||
"defaultUserTimezone": "Standard-Zeitzone für neue Konten",
|
||||
|
||||
+2
-1
@@ -2082,7 +2082,7 @@
|
||||
"emptyAction": "Generate briefing",
|
||||
"loadingLabel": "Synthesising today's briefing",
|
||||
"noProviderTitle": "No AI provider connected",
|
||||
"noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.",
|
||||
"noProviderDescription": "Point HealthLog at a local model (Ollama, LM Studio — nothing leaves your network) or bring your own OpenAI or Anthropic key, and the daily briefing turns your own readings into a plain-language summary — for example: “weight down 1.2 kg over two weeks, resting pulse steady at 58, blood pressure in range on 6 of 7 days.” Setup takes about a minute.",
|
||||
"noProviderAction": "Open AI settings",
|
||||
"staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.",
|
||||
"refreshFailedHint": "Couldn't refresh the briefing — showing the last one.",
|
||||
@@ -5496,6 +5496,7 @@
|
||||
"appSettingsDescription": "Global defaults for registration, language, and the timezone of new accounts.",
|
||||
"registrationEnabled": "Allow registration",
|
||||
"registrationEnabledDescription": "When disabled, new users cannot register.",
|
||||
"registrationOpenHint": "Registration is open — anyone who can reach this instance can create an account. Close it here once everyone who needs an account has one.",
|
||||
"defaultLanguage": "Default language",
|
||||
"defaultLanguageDescription": "Used for new users and as fallback.",
|
||||
"defaultUserTimezone": "Default timezone for new accounts",
|
||||
|
||||
+2
-1
@@ -2082,7 +2082,7 @@
|
||||
"emptyAction": "Generar resumen",
|
||||
"loadingLabel": "Preparando el resumen",
|
||||
"noProviderTitle": "Ningún proveedor de IA conectado",
|
||||
"noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.",
|
||||
"noProviderDescription": "Apunta HealthLog a un modelo local (Ollama, LM Studio: nada sale de tu red) o usa tu propia clave de OpenAI o Anthropic, y el resumen diario convierte tus propias mediciones en un resumen en lenguaje claro; por ejemplo: «peso 1,2 kg menos en dos semanas, pulso en reposo estable en 58, tensión en rango 6 de 7 días». La configuración lleva alrededor de un minuto.",
|
||||
"noProviderAction": "Abrir ajustes de IA",
|
||||
"staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.",
|
||||
"refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.",
|
||||
@@ -5496,6 +5496,7 @@
|
||||
"appSettingsDescription": "Valores globales por defecto para registro, idioma y zona horaria de cuentas nuevas.",
|
||||
"registrationEnabled": "Permitir el registro",
|
||||
"registrationEnabledDescription": "Cuando está desactivado, los nuevos usuarios no pueden registrarse.",
|
||||
"registrationOpenHint": "El registro está abierto: cualquiera que pueda acceder a esta instancia puede crear una cuenta. Ciérralo aquí cuando todos tengan una cuenta.",
|
||||
"defaultLanguage": "Idioma predeterminado",
|
||||
"defaultLanguageDescription": "Se usa para los nuevos usuarios y como reserva.",
|
||||
"defaultUserTimezone": "Zona horaria predeterminada para cuentas nuevas",
|
||||
|
||||
+2
-1
@@ -2082,7 +2082,7 @@
|
||||
"emptyAction": "Générer le briefing",
|
||||
"loadingLabel": "Préparation du briefing",
|
||||
"noProviderTitle": "Aucun fournisseur d'IA connecté",
|
||||
"noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.",
|
||||
"noProviderDescription": "Faites pointer HealthLog vers un modèle local (Ollama, LM Studio — rien ne quitte votre réseau) ou utilisez votre propre clé OpenAI ou Anthropic, et le résumé quotidien transforme vos propres relevés en un résumé en langage clair — par exemple : « poids en baisse de 1,2 kg en deux semaines, pouls au repos stable à 58, tension dans la cible 6 jours sur 7 ». La configuration prend environ une minute.",
|
||||
"noProviderAction": "Ouvrir les réglages IA",
|
||||
"staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.",
|
||||
"refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.",
|
||||
@@ -5496,6 +5496,7 @@
|
||||
"appSettingsDescription": "Valeurs globales par défaut pour l’inscription, la langue et le fuseau horaire des nouveaux comptes.",
|
||||
"registrationEnabled": "Autoriser l'inscription",
|
||||
"registrationEnabledDescription": "Lorsque cette option est désactivée, les nouveaux utilisateurs ne peuvent pas s'inscrire.",
|
||||
"registrationOpenHint": "L'inscription est ouverte : toute personne pouvant accéder à cette instance peut créer un compte. Fermez-la ici une fois que tout le monde a un compte.",
|
||||
"defaultLanguage": "Langue par défaut",
|
||||
"defaultLanguageDescription": "Utilisé pour les nouveaux utilisateurs et comme solution de repli.",
|
||||
"defaultUserTimezone": "Fuseau horaire par défaut pour les nouveaux comptes",
|
||||
|
||||
+2
-1
@@ -2082,7 +2082,7 @@
|
||||
"emptyAction": "Genera briefing",
|
||||
"loadingLabel": "Preparazione del briefing",
|
||||
"noProviderTitle": "Nessun provider IA collegato",
|
||||
"noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.",
|
||||
"noProviderDescription": "Punta HealthLog verso un modello locale (Ollama, LM Studio: nulla lascia la tua rete) o usa la tua chiave OpenAI o Anthropic, e il riepilogo quotidiano trasforma le tue misurazioni in un riassunto in linguaggio chiaro, ad esempio: «peso in calo di 1,2 kg in due settimane, battito a riposo stabile a 58, pressione nel range 6 giorni su 7». La configurazione richiede circa un minuto.",
|
||||
"noProviderAction": "Apri impostazioni IA",
|
||||
"staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.",
|
||||
"refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.",
|
||||
@@ -5496,6 +5496,7 @@
|
||||
"appSettingsDescription": "Impostazioni globali predefinite per registrazione, lingua e fuso orario dei nuovi account.",
|
||||
"registrationEnabled": "Consentire la registrazione",
|
||||
"registrationEnabledDescription": "Se disattivato, i nuovi utenti non possono registrarsi.",
|
||||
"registrationOpenHint": "La registrazione è aperta: chiunque possa raggiungere questa istanza può creare un account. Chiudila qui quando tutti hanno un account.",
|
||||
"defaultLanguage": "Lingua predefinita",
|
||||
"defaultLanguageDescription": "Usata per i nuovi utenti e come ripiego.",
|
||||
"defaultUserTimezone": "Fuso orario predefinito per i nuovi account",
|
||||
|
||||
+2
-1
@@ -2082,7 +2082,7 @@
|
||||
"emptyAction": "Wygeneruj briefing",
|
||||
"loadingLabel": "Przygotowanie briefingu",
|
||||
"noProviderTitle": "Brak połączonego dostawcy AI",
|
||||
"noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.",
|
||||
"noProviderDescription": "Skieruj HealthLog na model lokalny (Ollama, LM Studio — nic nie opuszcza Twojej sieci) lub podaj własny klucz OpenAI albo Anthropic, a codzienne podsumowanie zamieni Twoje pomiary w zrozumiały opis — na przykład: „waga niższa o 1,2 kg w dwa tygodnie, tętno spoczynkowe stabilne na 58, ciśnienie w normie przez 6 z 7 dni”. Konfiguracja zajmuje około minuty.",
|
||||
"noProviderAction": "Otwórz ustawienia AI",
|
||||
"staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.",
|
||||
"refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.",
|
||||
@@ -5496,6 +5496,7 @@
|
||||
"appSettingsDescription": "Globalne ustawienia domyślne rejestracji, języka i strefy czasowej nowych kont.",
|
||||
"registrationEnabled": "Zezwól na rejestrację",
|
||||
"registrationEnabledDescription": "Gdy wyłączone, nowi użytkownicy nie mogą się zarejestrować.",
|
||||
"registrationOpenHint": "Rejestracja jest otwarta — każdy, kto ma dostęp do tej instancji, może założyć konto. Zamknij ją tutaj, gdy wszyscy będą mieć konto.",
|
||||
"defaultLanguage": "Domyślny język",
|
||||
"defaultLanguageDescription": "Używany dla nowych użytkowników i jako rozwiązanie zapasowe.",
|
||||
"defaultUserTimezone": "Domyślna strefa czasowa dla nowych kont",
|
||||
|
||||
@@ -8,11 +8,23 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { create: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const measurement = {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
measurement,
|
||||
// Run the batched write callback against the same measurement mock so
|
||||
// createMany / updateMany calls are observable.
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
|
||||
fn({ measurement }),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
@@ -83,6 +95,23 @@ beforeEach(() => {
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
// Default: nothing pre-exists; the transaction runner is re-stubbed by the
|
||||
// db mock factory (resetAllMocks clears its implementation), so restore it.
|
||||
const measurement = prisma.measurement as unknown as {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
measurement.findMany.mockResolvedValue([]);
|
||||
measurement.createMany.mockResolvedValue({ count: 0 });
|
||||
measurement.updateMany.mockResolvedValue({ count: 0 });
|
||||
(
|
||||
prisma.$transaction as unknown as {
|
||||
mockImplementation: (f: unknown) => void;
|
||||
}
|
||||
).mockImplementation(async (fn: (tx: unknown) => unknown) =>
|
||||
fn({ measurement }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /api/import/csv — rate limit", () => {
|
||||
@@ -103,18 +132,23 @@ describe("POST /api/import/csv — rate limit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const mMeasurement = () =>
|
||||
prisma.measurement as unknown as {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe("POST /api/import/csv — fatal header error", () => {
|
||||
it("returns 422 when a required column is missing", async () => {
|
||||
const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
it("inserts valid rows, skips invalid ones, returns per-row status", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[
|
||||
@@ -135,12 +169,29 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
const reasons = body.data?.rows.map((r) => r.reason);
|
||||
expect(reasons).toContain("unknown_type");
|
||||
expect(reasons).toContain("missing_timezone_offset");
|
||||
// One create call for the single valid row.
|
||||
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
|
||||
// One bulk createMany carrying the single valid survivor.
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
const arg = mMeasurement().createMany.mock.calls[0][0];
|
||||
expect(arg.data).toHaveLength(1);
|
||||
// Rollup re-fold fired for the touched (type, day).
|
||||
expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("batches many rows into a single createMany (no per-row round-trip)", async () => {
|
||||
const rows = [HEADER];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mm = String(i).padStart(2, "0"); // unique minute → unique measuredAt
|
||||
rows.push(`WEIGHT,${80 + i * 0.1},kg,2026-05-01T08:${mm}:00Z,,,`);
|
||||
}
|
||||
const res = await POST(csvRequest(rows.join("\n")));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(50);
|
||||
// 50 rows, one createMany call — the whole point of the batch.
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("dryRun previews without writing and reports projected inserts", async () => {
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
@@ -153,17 +204,14 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
expect(body.data?.dryRun).toBe(true);
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.rows[0].status).toBe("inserted");
|
||||
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("upserts an externalId row and surfaces it as updated when it already existed", async () => {
|
||||
const created = new Date("2026-05-01T08:00:00Z");
|
||||
const bumped = new Date("2026-05-02T08:00:00Z");
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
|
||||
createdAt: created,
|
||||
updatedAt: bumped,
|
||||
} as never);
|
||||
it("surfaces an externalId row as updated when it already existed (updateMany)", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", externalId: "ext-1" },
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
@@ -174,13 +222,28 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
expect(body.data?.updated).toBe(1);
|
||||
expect(body.data?.inserted).toBe(0);
|
||||
expect(body.data?.rows[0].status).toBe("updated");
|
||||
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts a unique-constraint duplicate as skipped/duplicate", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockRejectedValue(
|
||||
new Error("Unique constraint failed"),
|
||||
it("inserts an externalId row when it does not exist yet", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([]);
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,ext-2"].join("\n"),
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.updated).toBe(0);
|
||||
expect(body.data?.rows[0].status).toBe("inserted");
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("counts a pre-existing natural-key row as skipped/duplicate", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-05-01T08:00:00Z") },
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest([HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n")),
|
||||
@@ -192,5 +255,23 @@ describe("POST /api/import/csv — write loop + per-row envelope", () => {
|
||||
status: "skipped",
|
||||
reason: "duplicate",
|
||||
});
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collapses an in-file duplicate (same type+measuredAt) to one insert + one duplicate", async () => {
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[
|
||||
HEADER,
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
|
||||
"WEIGHT,80.6,kg,2026-05-01T08:00:00Z,,,",
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.skipped).toBe(1);
|
||||
// Only the first survivor reaches the bulk insert.
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+147
-64
@@ -19,6 +19,8 @@ import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
|
||||
|
||||
/**
|
||||
* CSV measurement import (v1.17.1).
|
||||
@@ -106,84 +108,165 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const writeOutcome = new Map<number, "inserted" | "updated" | "duplicate">();
|
||||
|
||||
if (!dryRun) {
|
||||
for (const result of okRows) {
|
||||
const m = result.row;
|
||||
try {
|
||||
if (m.externalId) {
|
||||
// Idempotent re-import keyed on the source-stable id — re-uploading
|
||||
// the same file updates in place rather than minting duplicates.
|
||||
const res = await prisma.measurement.upsert({
|
||||
// Batched write. The previous shape ran one serial `upsert` (or `create`)
|
||||
// per accepted row — N round-trips for an N-row file, which on a
|
||||
// few-thousand-row import approaches the request timeout. This adopts the
|
||||
// fast shape `/measurements/batch` uses: pre-fetch the existing rows under
|
||||
// the IMPORT source in one query per dedup-key shape, partition into bulk
|
||||
// insert / update sets, then `createMany` + grouped `updateMany`. The
|
||||
// per-line inserted/updated/duplicate envelope is reconstructed from the
|
||||
// partition, so the response contract is unchanged.
|
||||
|
||||
const buildCreateData = (
|
||||
m: NormalisedMeasurementRow,
|
||||
): Prisma.MeasurementCreateManyInput => ({
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
...(m.externalId ? { externalId: m.externalId } : {}),
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
});
|
||||
|
||||
const extRows = okRows.filter((r) => r.row.externalId);
|
||||
const plainRows = okRows.filter((r) => !r.row.externalId);
|
||||
|
||||
// --- externalId rows: idempotent upsert on
|
||||
// (userId, type, source=IMPORT, externalId). One probe fetches every
|
||||
// pre-existing key; a hit means the row updates in place.
|
||||
const extExisting =
|
||||
extRows.length > 0
|
||||
? await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
OR: extRows.map((r) => ({
|
||||
type: r.row.type as MeasurementType,
|
||||
externalId: r.row.externalId as string,
|
||||
})),
|
||||
},
|
||||
select: { createdAt: true, updatedAt: true },
|
||||
});
|
||||
// A fresh create has createdAt === updatedAt; an in-place update
|
||||
// bumps updatedAt past createdAt.
|
||||
if (res.updatedAt.getTime() > res.createdAt.getTime()) {
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else {
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
}
|
||||
} else {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
select: { type: true, externalId: true },
|
||||
})
|
||||
: [];
|
||||
const extExistingSet = new Set(
|
||||
extExisting.map((e) => `${e.type}::${e.externalId}`),
|
||||
);
|
||||
|
||||
// Partition in file order, honouring in-file repeats: the first sighting
|
||||
// of a brand-new key inserts, every later sighting (or any sighting of a
|
||||
// key already in the DB) updates in place. The last value for a key wins,
|
||||
// matching the old sequential-upsert overwrite. Insert/update maps are
|
||||
// keyed by (type, externalId) so a repeated key collapses to one write.
|
||||
const seenExt = new Set<string>();
|
||||
const extInserts = new Map<string, NormalisedMeasurementRow>();
|
||||
const extUpdates = new Map<string, NormalisedMeasurementRow>();
|
||||
for (const result of extRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.externalId}`;
|
||||
if (extExistingSet.has(key)) {
|
||||
extUpdates.set(key, m);
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else if (seenExt.has(key)) {
|
||||
// New-to-DB key seen again in this file: still a single create with
|
||||
// the latest value, but this line reads as an update just as the
|
||||
// sequential upsert did.
|
||||
extInserts.set(key, m);
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else {
|
||||
seenExt.add(key);
|
||||
extInserts.set(key, m);
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
}
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
}
|
||||
|
||||
// --- externalId-less rows: create-only, deduped on the natural unique key
|
||||
// (userId, type, measuredAt, source=IMPORT, sleepStage=null). A collision
|
||||
// — against the DB or an earlier row in this same file — is a duplicate,
|
||||
// never an overwrite (each sample is a canonical reading).
|
||||
const plainExisting =
|
||||
plainRows.length > 0
|
||||
? await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
OR: plainRows.map((r) => ({
|
||||
type: r.row.type as MeasurementType,
|
||||
measuredAt: r.row.measuredAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
}
|
||||
select: { type: true, measuredAt: true },
|
||||
})
|
||||
: [];
|
||||
const plainExistingSet = new Set(
|
||||
plainExisting.map((e) => `${e.type}::${e.measuredAt.getTime()}`),
|
||||
);
|
||||
const seenPlain = new Set<string>();
|
||||
const plainInserts: NormalisedMeasurementRow[] = [];
|
||||
for (const result of plainRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.measuredAt.getTime()}`;
|
||||
if (plainExistingSet.has(key) || seenPlain.has(key)) {
|
||||
skipped++;
|
||||
writeOutcome.set(result.line, "duplicate");
|
||||
} else {
|
||||
seenPlain.add(key);
|
||||
plainInserts.push(m);
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
} catch {
|
||||
// Unique-constraint violation (a NULL-externalId duplicate against an
|
||||
// existing row) — count as skipped rather than failing the file.
|
||||
skipped++;
|
||||
writeOutcome.set(result.line, "duplicate");
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk write: createMany the survivors (chunked under the PG parameter
|
||||
// cap, skipDuplicates to absorb a concurrent double-submit race), then
|
||||
// grouped updateMany for the pre-existing externalId rows.
|
||||
const toCreate: Prisma.MeasurementCreateManyInput[] = [
|
||||
...[...extInserts.values()].map(buildCreateData),
|
||||
...plainInserts.map(buildCreateData),
|
||||
];
|
||||
const CREATE_CHUNK = 200;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
|
||||
await tx.measurement.createMany({
|
||||
data: toCreate.slice(i, i + CREATE_CHUNK),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
for (const m of extUpdates.values()) {
|
||||
await tx.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
|
||||
// Best-effort: a populator hiccup never fails the importer.
|
||||
|
||||
@@ -13,18 +13,16 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
/**
|
||||
* v1.4.25 W14b — onboarding wizard step page (foundation scaffold).
|
||||
* Onboarding wizard step page.
|
||||
*
|
||||
* Routes:
|
||||
* /onboarding/0 welcome → carousel / value-prop intro
|
||||
* /onboarding/1 goals → "what do you want to track?"
|
||||
* /onboarding/2 source → Withings / Apple Health / manual cards
|
||||
* /onboarding/2 source → device/import source cards (see
|
||||
* source-card-grid.tsx for the shipped set)
|
||||
* /onboarding/3 baseline → first measurement or sync confirmation
|
||||
* /onboarding/4 done → success screen + return to dashboard
|
||||
*
|
||||
* This page ships the scaffold only — the step body is a placeholder
|
||||
* `<div>` that the W14b-Content agent replaces with real step UI.
|
||||
*
|
||||
* Step gating:
|
||||
* - Unauthenticated → `/auth/login` (the proxy also enforces this,
|
||||
* but the server-side check keeps the contract explicit).
|
||||
@@ -139,10 +137,12 @@ export default async function OnboardingStepPage({ params }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Source (step 2) — four-card grid. Manual is the implicit default;
|
||||
// Withings opens OAuth in a new tab; Apple Health is rendered as a
|
||||
// "coming with v1.5" announce card; Garmin is omitted per the
|
||||
// W14b-Content brief (not on the roadmap yet).
|
||||
// Source (step 2) — card grid. Manual is the implicit default; the
|
||||
// connectable sources (Apple Health, WHOOP, Oura, Polar, Nightscout,
|
||||
// Fitbit/Pixel via Google Health) live in `source-card-grid.tsx`'s
|
||||
// SOURCE_CARDS / MORE_SOURCES lists — refer to that component for the
|
||||
// current shipped set rather than duplicating it here. The selection
|
||||
// is non-binding: nothing here auto-configures a sync.
|
||||
if (requested === 2) {
|
||||
return (
|
||||
<OnboardingShell step={2} userLocale={user.locale ?? null}>
|
||||
|
||||
@@ -96,6 +96,19 @@ export function GeneralSettingsSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
|
||||
{/* Honest nudge: registration stays open after the first admin so a
|
||||
household can be invited, but on an internet-exposed instance that
|
||||
means anyone who can reach the URL can sign up. Surface a quiet
|
||||
reminder while it is open — no behaviour change, just a signal. */}
|
||||
{(settings?.registrationEnabled ?? true) && (
|
||||
<p
|
||||
data-slot="registration-open-hint"
|
||||
className="text-muted-foreground -mt-2 text-xs"
|
||||
>
|
||||
{t("admin.registrationOpenHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* v1.23 — instance-wide "require a second factor" policy. Every
|
||||
account without an active factor is sent to forced enrollment after
|
||||
sign-in. This is a web-session nudge, not an API wall: direct API /
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<RecentWorkoutsTile>`.
|
||||
@@ -70,7 +71,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDayLabel(iso: string, locale: string): string {
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
return getDateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
@@ -73,7 +74,7 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
@@ -84,7 +85,7 @@ function formatNumber(
|
||||
locale: string,
|
||||
fractionDigits = 0,
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
@@ -111,13 +112,13 @@ function formatDateRange(
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = new Intl.DateTimeFormat(locale, {
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = new Intl.DateTimeFormat(locale, {
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
@@ -68,14 +69,14 @@ function formatDuration(seconds: number): string {
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
function formatEnergy(kcal: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Math.round(kcal));
|
||||
}
|
||||
@@ -86,7 +87,7 @@ function formatDate(
|
||||
timeFormat: TimeFormatPreference,
|
||||
): string {
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
return getDateTimeFormat(locale, {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati
|
||||
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import {
|
||||
@@ -335,7 +336,7 @@ export function MedicationCard({
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
// The actual user-facing display goes through formatTime / formatDateTime.
|
||||
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
const dayFormatter = getDateTimeFormat("en-CA", {
|
||||
timeZone: userTz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -63,59 +64,91 @@ function applyTheme(resolved: "dark" | "light") {
|
||||
document.documentElement.classList.add(resolved);
|
||||
}
|
||||
|
||||
// ── Theme as an external store ───────────────────────
|
||||
//
|
||||
// The stored preference lives in localStorage, and the OS preference in
|
||||
// matchMedia — both browser-only values that must NOT be read in a `useState`
|
||||
// initializer (server renders "dark", a client with a stored "light"/"system"
|
||||
// initializes differently → the React #418 hydration seam this repo already
|
||||
// hit with the sidebar). `useSyncExternalStore` is the sanctioned fix: it reads
|
||||
// the value with an SSR-stable server snapshot ("dark") and reconciles to the
|
||||
// real value after hydration without a setState-in-effect. The nonce-bound
|
||||
// inline script in layout.tsx stamps the correct class pre-paint, so there is
|
||||
// no visual FOUC while React reconciles.
|
||||
|
||||
const THEME_STORAGE_KEY = "healthlog-theme";
|
||||
const themeListeners = new Set<() => void>();
|
||||
|
||||
function notifyThemeListeners() {
|
||||
for (const cb of themeListeners) cb();
|
||||
}
|
||||
|
||||
function subscribeStoredTheme(onChange: () => void): () => void {
|
||||
themeListeners.add(onChange);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === THEME_STORAGE_KEY) onChange();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
themeListeners.delete(onChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
function getStoredThemeSnapshot(): Theme {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null;
|
||||
// A stored "light"/"dark"/"system" choice wins. With no stored preference the
|
||||
// app defaults to dark rather than tracking the OS.
|
||||
if (saved === "light" || saved === "dark" || saved === "system") return saved;
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function getServerThemeSnapshot(): Theme {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function subscribeSystemTheme(onChange: () => void): () => void {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}
|
||||
|
||||
function getSystemThemeSnapshot(): "dark" | "light" {
|
||||
return getSystemTheme();
|
||||
}
|
||||
|
||||
function getServerSystemSnapshot(): "dark" | "light" {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
|
||||
// A stored "light"/"dark"/"system" choice wins. With no stored
|
||||
// preference the app defaults to dark rather than tracking the OS.
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
if (saved === "system") return "system";
|
||||
return "dark";
|
||||
});
|
||||
const theme = useSyncExternalStore(
|
||||
subscribeStoredTheme,
|
||||
getStoredThemeSnapshot,
|
||||
getServerThemeSnapshot,
|
||||
);
|
||||
const systemTheme = useSyncExternalStore(
|
||||
subscribeSystemTheme,
|
||||
getSystemThemeSnapshot,
|
||||
getServerSystemSnapshot,
|
||||
);
|
||||
const resolvedTheme = theme === "system" ? systemTheme : theme;
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
if (saved === "system") return getSystemTheme();
|
||||
return "dark";
|
||||
});
|
||||
|
||||
// Apply theme on mount (inline script handles FOUC, this ensures classes are in sync)
|
||||
// Keep the <html> class in sync with the resolved theme. This is a DOM write,
|
||||
// not a setState — it runs on mount (reconciling the class the inline script
|
||||
// set to any stored preference) and whenever the resolved theme changes.
|
||||
useEffect(() => {
|
||||
applyTheme(resolvedTheme);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Listen for OS preference changes when in system mode
|
||||
useEffect(() => {
|
||||
if (theme !== "system") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
const resolved = e.matches ? "dark" : "light";
|
||||
setResolvedTheme(resolved);
|
||||
applyTheme(resolved);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next);
|
||||
// Persist every explicit choice, including "system" — the absence of a
|
||||
// stored value is reserved for a fresh visitor and now defaults to dark.
|
||||
// stored value is reserved for a fresh visitor and defaults to dark.
|
||||
// Storing "system" verbatim keeps an explicit OS-tracking choice
|
||||
// distinguishable from "never chose", so a reload honours it.
|
||||
localStorage.setItem("healthlog-theme", next);
|
||||
if (next === "system") {
|
||||
const resolved = getSystemTheme();
|
||||
setResolvedTheme(resolved);
|
||||
applyTheme(resolved);
|
||||
} else {
|
||||
setResolvedTheme(next);
|
||||
applyTheme(next);
|
||||
}
|
||||
// distinguishable from "never chose", so a reload honours it. The write +
|
||||
// notify re-reads the external store; the effect above applies the class.
|
||||
localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||
notifyThemeListeners();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,18 @@ import type { Instrumentation } from "next";
|
||||
export async function register() {
|
||||
// Only start the worker on the Node.js server runtime (not Edge, not build)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// First-boot readiness summary: a human-readable block on the core secrets
|
||||
// (with the exact fix on a bad one) before any fail-closed loader throws,
|
||||
// plus a one-glance status of the optional channels. Runs for every process
|
||||
// type (web + worker). Never throws — the real gate stays the loaders.
|
||||
try {
|
||||
const { logReadinessSummary } =
|
||||
await import("@/lib/boot/readiness-summary");
|
||||
logReadinessSummary();
|
||||
} catch {
|
||||
// A readiness summary must never break boot.
|
||||
}
|
||||
|
||||
// Every Node process watches its own event loop — web-only
|
||||
// containers included; a stall report from the process serving
|
||||
// requests is the whole point.
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
|
||||
import { shouldEmitSecureCookie } from "../secure-cookie";
|
||||
import {
|
||||
shouldEmitSecureCookie,
|
||||
detectInsecureCookieTransport,
|
||||
} from "../secure-cookie";
|
||||
|
||||
function req(headers: Record<string, string>, url = "http://10.0.0.5:3000/x") {
|
||||
return { headers: new Headers(headers), url };
|
||||
}
|
||||
|
||||
describe("shouldEmitSecureCookie", () => {
|
||||
afterEach(() => {
|
||||
@@ -47,3 +54,56 @@ describe("shouldEmitSecureCookie", () => {
|
||||
expect(shouldEmitSecureCookie()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInsecureCookieTransport", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns null when the Secure flag is off (SESSION_COOKIE_SECURE=false)", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
expect(detectInsecureCookieTransport(req({}))).toBeNull();
|
||||
});
|
||||
|
||||
it("warns when a Secure cookie will be set on a plain-HTTP request (no proxy)", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
const msg = detectInsecureCookieTransport(req({}));
|
||||
expect(msg).toContain("SESSION_COOKIE_SECURE=false");
|
||||
});
|
||||
|
||||
it("returns null behind a TLS proxy that forwards X-Forwarded-Proto: https", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(req({ "x-forwarded-proto": "https" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the left-most entry of a comma-separated proto chain", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(
|
||||
req({ "x-forwarded-proto": "https, http" }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("warns when the proxy forwards X-Forwarded-Proto: http explicitly", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(req({ "x-forwarded-proto": "http" })),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the request URL is already https", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
expect(
|
||||
detectInsecureCookieTransport(req({}, "https://healthlog.example.com/x")),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
|
||||
export interface FinishLoginParams {
|
||||
@@ -116,8 +118,20 @@ export async function finishLogin(
|
||||
});
|
||||
}
|
||||
|
||||
// Browser path — session cookie. `createSession` anchors the onboarding
|
||||
// cookie itself; thread the user's onboarding state through.
|
||||
// Browser path — session cookie. Before minting it, surface the single
|
||||
// most common self-host snag: a Secure cookie about to be set on a plain-HTTP
|
||||
// request drops silently and loops the login. Warn-log + wide-event so an
|
||||
// operator sees the cause; the login still completes.
|
||||
const transportWarning = detectInsecureCookieTransport(request);
|
||||
if (transportWarning) {
|
||||
console.warn(
|
||||
`[auth] session-cookie transport mismatch: ${transportWarning}`,
|
||||
);
|
||||
annotate({ meta: { session_cookie_secure_transport_mismatch: true } });
|
||||
}
|
||||
|
||||
// `createSession` anchors the onboarding cookie itself; thread the user's
|
||||
// onboarding state through.
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt == null,
|
||||
|
||||
@@ -28,3 +28,57 @@ export function shouldEmitSecureCookie(): boolean {
|
||||
if (override === "true") return true;
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line diagnostic for the single most common self-host login-loop cause:
|
||||
* the app is about to set a `Secure` session cookie
|
||||
* (`shouldEmitSecureCookie() === true`) while the request itself arrived over
|
||||
* plain HTTP. The browser silently drops a `Secure` cookie on an `http://`
|
||||
* origin, so the next request is unauthenticated and the user bounces back to
|
||||
* the login page with no error at all — it reads like a wrong password forever.
|
||||
*
|
||||
* Returns a human-readable message when the mismatch is detected, else `null`.
|
||||
* The caller logs it at login time (`console.warn` survives the prod
|
||||
* `console.*` strip) so an operator sees WHY login loops without having to find
|
||||
* the note buried in three docs. Non-fatal: the login still completes; the
|
||||
* cookie just won't stick until `SESSION_COOKIE_SECURE=false` is set (or TLS is
|
||||
* fronted).
|
||||
*
|
||||
* A request counts as HTTPS when `x-forwarded-proto` names https (the shape
|
||||
* every documented TLS reverse proxy sets) OR the request URL is already
|
||||
* https. A proxy that terminates TLS but omits `x-forwarded-proto` can produce
|
||||
* a benign false positive — the message names that case so it stays actionable
|
||||
* either way.
|
||||
*/
|
||||
export function detectInsecureCookieTransport(req: {
|
||||
headers: Headers;
|
||||
url: string;
|
||||
}): string | null {
|
||||
if (!shouldEmitSecureCookie()) return null;
|
||||
|
||||
// `x-forwarded-proto` can be a comma-separated proto chain; the left-most
|
||||
// entry is the client-facing scheme the browser actually spoke.
|
||||
const clientProto = req.headers
|
||||
.get("x-forwarded-proto")
|
||||
?.split(",")[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (clientProto === "https") return null;
|
||||
|
||||
let urlIsHttps = false;
|
||||
try {
|
||||
urlIsHttps = new URL(req.url).protocol === "https:";
|
||||
} catch {
|
||||
urlIsHttps = false;
|
||||
}
|
||||
if (urlIsHttps) return null;
|
||||
|
||||
return (
|
||||
"This request arrived over plain HTTP but the session cookie is being " +
|
||||
"issued with the `Secure` flag — the browser will drop it and login will " +
|
||||
"loop back to the sign-in page with no error message. If you serve HTTP " +
|
||||
"directly (LAN / NAS / Tailscale, no TLS reverse proxy), set " +
|
||||
"SESSION_COOKIE_SECURE=false. If a reverse proxy terminates TLS in front " +
|
||||
"of this container, make sure it forwards `X-Forwarded-Proto: https`."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
collectReadiness,
|
||||
formatReadiness,
|
||||
logReadinessSummary,
|
||||
} from "../readiness-summary";
|
||||
|
||||
const HEX32 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
function baseEnv(): Record<string, string | undefined> {
|
||||
return {
|
||||
ENCRYPTION_KEY: HEX32,
|
||||
API_TOKEN_HMAC_KEY: HEX32,
|
||||
DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog",
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
}
|
||||
|
||||
function lineFor(env: Record<string, string | undefined>, label: string) {
|
||||
return collectReadiness(env).lines.find((l) => l.label === label);
|
||||
}
|
||||
|
||||
describe("collectReadiness — core secrets", () => {
|
||||
it("reports no blocker when the three core secrets are valid", () => {
|
||||
const report = collectReadiness(baseEnv());
|
||||
expect(report.hasBlocker).toBe(false);
|
||||
expect(lineFor(baseEnv(), "ENCRYPTION_KEY")?.status).toBe("ok");
|
||||
expect(lineFor(baseEnv(), "API_TOKEN_HMAC_KEY")?.status).toBe("ok");
|
||||
expect(lineFor(baseEnv(), "DATABASE_URL")?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("flags a missing ENCRYPTION_KEY as a blocker with the openssl fix", () => {
|
||||
const env = baseEnv();
|
||||
delete env.ENCRYPTION_KEY;
|
||||
const report = collectReadiness(env);
|
||||
expect(report.hasBlocker).toBe(true);
|
||||
const line = report.lines.find((l) => l.label === "ENCRYPTION_KEY");
|
||||
expect(line?.status).toBe("error");
|
||||
expect(line?.detail).toContain("openssl rand -hex 32");
|
||||
});
|
||||
|
||||
it("flags a too-short ENCRYPTION_KEY as a blocker", () => {
|
||||
const env = baseEnv();
|
||||
env.ENCRYPTION_KEY = "deadbeef";
|
||||
expect(collectReadiness(env).hasBlocker).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the ENCRYPTION_KEYS rotation map instead of the single key", () => {
|
||||
const env = baseEnv();
|
||||
delete env.ENCRYPTION_KEY;
|
||||
env.ENCRYPTION_KEYS = JSON.stringify({ v1: HEX32, v2: HEX32 });
|
||||
const report = collectReadiness(env);
|
||||
expect(report.hasBlocker).toBe(false);
|
||||
expect(
|
||||
report.lines.find((l) => l.label === "ENCRYPTION_KEYS")?.status,
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("flags a malformed ENCRYPTION_KEYS map", () => {
|
||||
const env = baseEnv();
|
||||
delete env.ENCRYPTION_KEY;
|
||||
env.ENCRYPTION_KEYS = '{"v1":"nothex"}';
|
||||
expect(collectReadiness(env).hasBlocker).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a missing DATABASE_URL", () => {
|
||||
const env = baseEnv();
|
||||
delete env.DATABASE_URL;
|
||||
expect(collectReadiness(env).hasBlocker).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectReadiness — transport + optional", () => {
|
||||
it("warns Secure-on when NODE_ENV=production and no override", () => {
|
||||
const line = lineFor(baseEnv(), "SESSION_COOKIE_SECURE");
|
||||
expect(line?.detail).toContain("Secure cookie flag ON");
|
||||
});
|
||||
|
||||
it("notes Secure-off when SESSION_COOKIE_SECURE=false", () => {
|
||||
const env = { ...baseEnv(), SESSION_COOKIE_SECURE: "false" };
|
||||
expect(lineFor(env, "SESSION_COOKIE_SECURE")?.detail).toContain(
|
||||
"Secure cookie flag OFF",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks optional subsystems as warn when unconfigured", () => {
|
||||
const smtp = lineFor(baseEnv(), "SMTP (email)");
|
||||
expect(smtp?.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("marks off-host backup ok only when every required var is set", () => {
|
||||
const env = {
|
||||
...baseEnv(),
|
||||
BACKUP_S3_ENDPOINT: "https://r2",
|
||||
BACKUP_S3_BUCKET: "b",
|
||||
BACKUP_S3_ACCESS_KEY: "a",
|
||||
BACKUP_S3_SECRET_KEY: "s",
|
||||
BACKUP_ENCRYPTION_KEY: HEX32,
|
||||
};
|
||||
expect(lineFor(env, "Off-host backup")?.status).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatReadiness + logReadinessSummary", () => {
|
||||
it("renders a header that flags a blocker", () => {
|
||||
const env = baseEnv();
|
||||
delete env.API_TOKEN_HMAC_KEY;
|
||||
const text = formatReadiness(collectReadiness(env));
|
||||
expect(text).toContain("CORE SECRET MISSING/INVALID");
|
||||
expect(text).toContain("✗ API_TOKEN_HMAC_KEY");
|
||||
});
|
||||
|
||||
it("logs blockers to error and healthy summaries to log; never throws", () => {
|
||||
const errors: string[] = [];
|
||||
const logs: string[] = [];
|
||||
const sink = {
|
||||
error: (m: string) => errors.push(m),
|
||||
log: (m: string) => logs.push(m),
|
||||
};
|
||||
logReadinessSummary(baseEnv(), sink);
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(errors).toHaveLength(0);
|
||||
|
||||
const bad = baseEnv();
|
||||
delete bad.ENCRYPTION_KEY;
|
||||
logReadinessSummary(bad, sink);
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* First-boot readiness summary.
|
||||
*
|
||||
* The bundled image promises "single `docker compose up`", but the reality is
|
||||
* three hand-generated secrets plus a handful of optional subsystems — and the
|
||||
* crypto loader is fail-closed, so a missing/short `ENCRYPTION_KEY` throws a
|
||||
* stack trace on first use rather than a sentence an operator can act on. This
|
||||
* module inspects the environment ONCE at boot and prints a compact,
|
||||
* human-readable readiness block: a clear line per core secret (with the exact
|
||||
* `openssl` fix on a bad one) before anything throws, plus a one-glance status
|
||||
* of the optional channels.
|
||||
*
|
||||
* It NEVER logs a secret value — only presence + shape validity. It never
|
||||
* throws: the fail-closed loaders remain the real gate; this is a friendlier
|
||||
* signpost in front of them.
|
||||
*/
|
||||
|
||||
export interface ReadinessLine {
|
||||
/** Short label, e.g. "ENCRYPTION_KEY". */
|
||||
label: string;
|
||||
/** ok = green, warn = optional-not-configured, error = missing/invalid core. */
|
||||
status: "ok" | "warn" | "error";
|
||||
/** One-line human detail (no secret material). */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ReadinessReport {
|
||||
lines: ReadinessLine[];
|
||||
/** True when at least one core secret is missing or malformed. */
|
||||
hasBlocker: boolean;
|
||||
}
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
/** A 64-char hex string (= 32 bytes) or a base64 string that decodes to 32 bytes. */
|
||||
function isValid32ByteKey(raw: string | undefined): boolean {
|
||||
if (!raw) return false;
|
||||
const v = raw.trim();
|
||||
if (/^[0-9a-fA-F]{64}$/.test(v)) return true;
|
||||
if (/^[A-Za-z0-9+/=]+$/.test(v)) {
|
||||
try {
|
||||
return Buffer.from(v, "base64").length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function allSet(env: Env, keys: string[]): boolean {
|
||||
return keys.every((k) => (env[k] ?? "").trim().length > 0);
|
||||
}
|
||||
|
||||
function anySet(env: Env, keys: string[]): boolean {
|
||||
return keys.some((k) => (env[k] ?? "").trim().length > 0);
|
||||
}
|
||||
|
||||
/** Inspect the environment and build the readiness report. Pure + side-effect free. */
|
||||
export function collectReadiness(env: Env = process.env): ReadinessReport {
|
||||
const lines: ReadinessLine[] = [];
|
||||
|
||||
// --- Core secrets (boot blockers) ---------------------------------------
|
||||
// Encryption: the map form (ENCRYPTION_KEYS) supersedes the single key.
|
||||
const keysMap = (env.ENCRYPTION_KEYS ?? "").trim();
|
||||
let encryptionOk = false;
|
||||
if (keysMap) {
|
||||
try {
|
||||
const parsed = JSON.parse(keysMap) as Record<string, string>;
|
||||
const entries = Object.values(parsed ?? {});
|
||||
encryptionOk =
|
||||
entries.length > 0 && entries.every((v) => isValid32ByteKey(v));
|
||||
} catch {
|
||||
encryptionOk = false;
|
||||
}
|
||||
lines.push({
|
||||
label: "ENCRYPTION_KEYS",
|
||||
status: encryptionOk ? "ok" : "error",
|
||||
detail: encryptionOk
|
||||
? "key-rotation map present and valid"
|
||||
: "ENCRYPTION_KEYS is not a JSON map of valid 32-byte keys",
|
||||
});
|
||||
} else {
|
||||
encryptionOk = isValid32ByteKey(env.ENCRYPTION_KEY);
|
||||
lines.push({
|
||||
label: "ENCRYPTION_KEY",
|
||||
status: encryptionOk ? "ok" : "error",
|
||||
detail: encryptionOk
|
||||
? "present (64 hex chars)"
|
||||
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
|
||||
});
|
||||
}
|
||||
|
||||
const hmacOk = isValid32ByteKey(env.API_TOKEN_HMAC_KEY);
|
||||
lines.push({
|
||||
label: "API_TOKEN_HMAC_KEY",
|
||||
status: hmacOk ? "ok" : "error",
|
||||
detail: hmacOk
|
||||
? "present (64 hex chars)"
|
||||
: "missing or not 64 hex chars — generate with: openssl rand -hex 32",
|
||||
});
|
||||
|
||||
const dbOk = (env.DATABASE_URL ?? "").trim().length > 0;
|
||||
lines.push({
|
||||
label: "DATABASE_URL",
|
||||
status: dbOk ? "ok" : "error",
|
||||
detail: dbOk
|
||||
? "present"
|
||||
: "missing — the bundled compose derives it from POSTGRES_PASSWORD",
|
||||
});
|
||||
|
||||
// --- Transport hint (a warning, never a blocker) ------------------------
|
||||
const secureOverride = (env.SESSION_COOKIE_SECURE ?? "").trim().toLowerCase();
|
||||
const secureFlagOn =
|
||||
secureOverride === "true" ||
|
||||
(secureOverride !== "false" && env.NODE_ENV === "production");
|
||||
lines.push({
|
||||
label: "SESSION_COOKIE_SECURE",
|
||||
status: "ok",
|
||||
detail: secureFlagOn
|
||||
? "Secure cookie flag ON — serve HTTPS, or set SESSION_COOKIE_SECURE=false for a plain-HTTP LAN host"
|
||||
: "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet",
|
||||
});
|
||||
|
||||
// --- Optional subsystems (informational) --------------------------------
|
||||
const optional: Array<[string, boolean]> = [
|
||||
["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])],
|
||||
[
|
||||
"Off-host backup",
|
||||
allSet(env, [
|
||||
"BACKUP_S3_ENDPOINT",
|
||||
"BACKUP_S3_BUCKET",
|
||||
"BACKUP_S3_ACCESS_KEY",
|
||||
"BACKUP_S3_SECRET_KEY",
|
||||
"BACKUP_ENCRYPTION_KEY",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"APNs (native iOS push)",
|
||||
allSet(env, ["APNS_KEY_ID", "APNS_TEAM_ID", "APNS_BUNDLE_ID"]) &&
|
||||
anySet(env, ["APNS_KEY_B64", "APNS_KEY", "APNS_KEY_FILE"]),
|
||||
],
|
||||
[
|
||||
"Web Push (VAPID env)",
|
||||
allSet(env, ["VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", "VAPID_SUBJECT"]),
|
||||
],
|
||||
];
|
||||
for (const [label, configured] of optional) {
|
||||
lines.push({
|
||||
label,
|
||||
status: configured ? "ok" : "warn",
|
||||
detail: configured ? "configured" : "not configured (optional)",
|
||||
});
|
||||
}
|
||||
|
||||
const hasBlocker = lines.some((l) => l.status === "error");
|
||||
return { lines, hasBlocker };
|
||||
}
|
||||
|
||||
const GLYPH: Record<ReadinessLine["status"], string> = {
|
||||
ok: "✓",
|
||||
warn: "·",
|
||||
error: "✗",
|
||||
};
|
||||
|
||||
/** Render the report as a compact multi-line block. */
|
||||
export function formatReadiness(report: ReadinessReport): string {
|
||||
const body = report.lines
|
||||
.map((l) => ` ${GLYPH[l.status]} ${l.label}: ${l.detail}`)
|
||||
.join("\n");
|
||||
const header = report.hasBlocker
|
||||
? "HealthLog boot readiness — CORE SECRET MISSING/INVALID (see ✗ below):"
|
||||
: "HealthLog boot readiness:";
|
||||
return `${header}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect + print the readiness summary. Blockers go to stderr (they precede a
|
||||
* fail-closed throw), the healthy summary to stdout. Never throws.
|
||||
*/
|
||||
export function logReadinessSummary(
|
||||
env: Env = process.env,
|
||||
log: Pick<Console, "log" | "error"> = console,
|
||||
): ReadinessReport {
|
||||
try {
|
||||
const report = collectReadiness(env);
|
||||
const text = formatReadiness(report);
|
||||
if (report.hasBlocker) {
|
||||
log.error(text);
|
||||
} else {
|
||||
log.log(text);
|
||||
}
|
||||
return report;
|
||||
} catch {
|
||||
// A readiness summary must never be the thing that breaks boot.
|
||||
return { lines: [], hasBlocker: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getNumberFormat, getDateTimeFormat } from "../formatter-cache";
|
||||
|
||||
describe("formatter-cache", () => {
|
||||
it("returns the same NumberFormat instance for identical (locale, options)", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
const b = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("returns distinct NumberFormat instances for different options", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 2 });
|
||||
const b = getNumberFormat("en", { maximumFractionDigits: 0 });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("returns distinct instances per locale", () => {
|
||||
const a = getNumberFormat("en", { maximumFractionDigits: 1 });
|
||||
const b = getNumberFormat("de", { maximumFractionDigits: 1 });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("caches DateTimeFormat and still formats correctly", () => {
|
||||
const a = getDateTimeFormat("en-CA", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const b = getDateTimeFormat("en-CA", {
|
||||
timeZone: "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
expect(a.format(new Date("2026-05-08T12:00:00Z"))).toBe("2026-05-08");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -6,6 +6,7 @@ import {
|
||||
runOffhostBackup,
|
||||
runOffhostRoundtripTest,
|
||||
} from "../offhost-backup";
|
||||
import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page";
|
||||
|
||||
const ENC_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -138,6 +139,56 @@ describe("runOffhostBackup", () => {
|
||||
expect(parsed.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
// A full first page (=== page size) followed by a short page proves the
|
||||
// reader walks the id cursor instead of one unbounded findMany.
|
||||
const fullPage = Array.from(
|
||||
{ length: MEASUREMENT_BACKUP_PAGE_SIZE },
|
||||
(_, i) => ({
|
||||
id: `e${String(i).padStart(5, "0")}`,
|
||||
userId: "u1",
|
||||
takenAt: new Date("2026-05-01T00:00:00Z"),
|
||||
}),
|
||||
);
|
||||
const tailPage = [
|
||||
{ id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") },
|
||||
];
|
||||
const intakeFindMany = vi.fn(
|
||||
async (args: { where?: { id?: { gt?: string } } }) =>
|
||||
args?.where?.id?.gt ? tailPage : fullPage,
|
||||
);
|
||||
|
||||
const prisma = {
|
||||
user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) },
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: { findMany: intakeFindMany },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const report = await runOffhostBackup(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma as any,
|
||||
s3,
|
||||
new Date("2026-05-08T00:00:00Z"),
|
||||
);
|
||||
expect(report.uploaded).toBe(1);
|
||||
// Two round-trips: the full page, then the short tail that ends the walk.
|
||||
expect(intakeFindMany).toHaveBeenCalledTimes(2);
|
||||
expect(intakeFindMany.mock.calls[0][0]).toMatchObject({
|
||||
orderBy: { id: "asc" },
|
||||
take: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
});
|
||||
// Every paged row lands in the decrypted payload — no rows dropped.
|
||||
const ct = s3.store.get("2026-05-08/user-u1.json.enc")!;
|
||||
const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex")));
|
||||
expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1);
|
||||
});
|
||||
|
||||
it("counts per-user failures without aborting the whole run", async () => {
|
||||
const s3 = makeS3Mock();
|
||||
const prisma = {
|
||||
|
||||
@@ -279,16 +279,70 @@ export async function runOffhostBackup(
|
||||
where: { userId: user.id },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }),
|
||||
prisma.moodEntry.findMany({ where: { userId: user.id } }),
|
||||
// The intake / mood / cycle tables scale with history depth the same
|
||||
// way measurements do (multiple intakes + a mood + a cycle-day-log per
|
||||
// day over years), so they get the same keyset-paged read: at most one
|
||||
// page is resident per round-trip instead of the entire table landing
|
||||
// in one array alongside the giant JSON.stringify below. The projection
|
||||
// keeps the full row (no select), so payload bytes are identical to the
|
||||
// prior default findMany — only peak heap during the read is bounded.
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
|
||||
// disaster-recovery dump, not the canonical user-facing backup);
|
||||
// `notesEncrypted` stays ciphertext.
|
||||
// `notesEncrypted` stays ciphertext. The profile is a single row, so it
|
||||
// stays a direct read; the cycle + day-log tables are paged.
|
||||
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
|
||||
prisma.menstrualCycle.findMany({ where: { userId: user.id } }),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { symptomLinks: true },
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
collectPagedMeasurements({
|
||||
fetchPage: (afterId, take) =>
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(afterId ? { id: { gt: afterId } } : {}),
|
||||
},
|
||||
include: { symptomLinks: true },
|
||||
orderBy: { id: "asc" },
|
||||
take,
|
||||
}),
|
||||
project: (row) => row,
|
||||
pageSize: MEASUREMENT_BACKUP_PAGE_SIZE,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user