diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b1e6eeb4b..77fa87cf8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36065,18 +36065,6 @@ components: type: string description: Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default. - geoLookup: - type: string - enum: - - offline - - online - - off - description: "How login IPs are placed (since v1.42): `offline` from the GeoLite2 databases, `online` by the provider - above, `off` not at all (`IP_GEO_LOOKUP_DISABLED`)." - geoProviderChosen: - type: boolean - description: The operator named the online provider in `IP_GEO_LOOKUP_URL` (since v1.42). False while the default is in - use. required: - version - buildSha @@ -36087,8 +36075,6 @@ components: - docs - offlineGeoEnabled - geoProviderHost - - geoLookup - - geoProviderChosen additionalProperties: false description: The running build's identity plus the offline-geolocation state. Public — no authentication, no account data. AssistantFlagsEnvelope: diff --git a/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql new file mode 100644 index 000000000..4a9b66e3c --- /dev/null +++ b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql @@ -0,0 +1,4 @@ +-- When the instance first stored a discovery finding under the seasonally +-- adjusted correlation engine. Stamped once by the engine itself; null until +-- its first run, which counts every existing pattern as an earlier finding. +ALTER TABLE "app_settings" ADD COLUMN "correlation_seasonal_engine_since" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 911083311..49e96f28f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5171,6 +5171,14 @@ model AppSettings { /// step names who confirmed when the account still exists. encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id") + /// When this instance first stored a discovery finding under the seasonally + /// adjusted correlation engine (v1.42). Stamped once, by the first + /// `syncAcceptedPatterns` run of a discovery family, before it writes; null + /// until then. Consumer: `hadFindingsBeforeSeasonalAdjustment`, which counts + /// a discovery pattern created before it (or any, while it is null) as a + /// finding of the earlier engine and so earns the one-time note. + correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since") + @@map("app_settings") } diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index bffaa5be3..deef1897b 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,6 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); +const geoMock = vi.hoisted(() => ({ + lookup: "online" as "offline" | "online" | "off", + chosen: false, + host: "ipwho.is", +})); +vi.mock("@/lib/geo", () => ({ + geoLookupMode: () => geoMock.lookup, + geoProviderChosen: () => geoMock.chosen, + resolveGeoProviderHost: () => geoMock.host, +})); + vi.mock("@/lib/jobs/job-failures", () => ({ JOB_FAILURE_WINDOW_HOURS: 72, readFailingQueues: vi.fn(), @@ -208,4 +219,17 @@ describe("GET /api/admin/status (integration health summary)", () => { ], }); }); + + it("carries the login-IP geo state the public version route no longer shows", async () => { + geoMock.lookup = "online"; + geoMock.chosen = true; + geoMock.host = "ip-api.com"; + const res = await GET(); + const body = (await res.json()) as { data: { geo: unknown } }; + expect(body.data.geo).toEqual({ + lookup: "online", + providerChosen: true, + providerHost: "ip-api.com", + }); + }); }); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index d94de0dd4..5e7f4b9ab 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -9,6 +9,11 @@ import { readFailingQueues, } from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; +import { + geoLookupMode, + geoProviderChosen, + resolveGeoProviderHost, +} from "@/lib/geo"; // Build timestamp (set at build time via next.config) const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString(); @@ -109,5 +114,12 @@ export const GET = apiHandler(async () => { : null, webPush: webPushConfigured ? { configured: true } : null, }, + // How login IPs are placed on this host. Admin-only: the public + // `/api/version` keeps just `offlineGeoEnabled` and `geoProviderHost`. + geo: { + lookup: geoLookupMode(), + providerChosen: geoProviderChosen(), + providerHost: resolveGeoProviderHost(), + }, }); }); diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index cba8c7961..4803e2ab5 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -9,8 +9,6 @@ const resolveGeoProviderHostMock = vi.fn(() => "ipwho.is"); vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => offlineGeoReadyMock(), resolveGeoProviderHost: () => resolveGeoProviderHostMock(), - geoLookupMode: () => (offlineGeoReadyMock() ? "offline" : "online"), - geoProviderChosen: () => false, })); import { GET } from "../route"; @@ -108,4 +106,23 @@ describe("GET /api/version", () => { const body = (await response.json()) as VersionEnvelope; expect(body.data.geoProviderHost).toBe("ip-api.com"); }); + + it("keeps the finer geo state off the public answer", async () => { + // Lookup mode and whether the operator chose the provider are admin-only + // (`/api/admin/status`); the public route would only add a way to tell + // instances apart. + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([ + "buildSha", + "builtAt", + "changelog", + "docs", + "geoProviderHost", + "license", + "offlineGeoEnabled", + "repository", + "version", + ]); + }); }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index f737480be..9c0435b8e 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -1,11 +1,6 @@ import { apiHandler } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { - geoLookupMode, - geoProviderChosen, - offlineGeoReady, - resolveGeoProviderHost, -} from "@/lib/geo"; +import { offlineGeoReady, resolveGeoProviderHost } from "@/lib/geo"; import packageJson from "../../../../package.json"; // v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo @@ -41,7 +36,10 @@ export const dynamic = "force-dynamic"; * `geoProviderHost` (the host of `IP_GEO_LOOKUP_URL`, default * `ipwho.is`) and the resolver emits a one-shot admin notification on * first use. `geoProviderHost` lets the admin status surface name the - * real provider instead of assuming the default. + * real provider instead of assuming the default. Both were public before + * v1.42 and stay so for existing readers; the finer geo state (lookup mode, + * whether the operator chose the provider) is admin-only and lives on + * `/api/admin/status`, so it adds nothing to tell instances apart by. */ export const GET = apiHandler(async () => { // v1.4.43 B11 — prefer the build-arg-injected env var so the runtime @@ -69,7 +67,5 @@ export const GET = apiHandler(async () => { docs: "https://docs.healthlog.dev", offlineGeoEnabled, geoProviderHost, - geoLookup: geoLookupMode(), - geoProviderChosen: geoProviderChosen(), }); }); diff --git a/src/components/admin/__tests__/geo-lookup-state.test.ts b/src/components/admin/__tests__/geo-lookup-state.test.ts index 17da78f9c..f7b062cb1 100644 --- a/src/components/admin/__tests__/geo-lookup-state.test.ts +++ b/src/components/admin/__tests__/geo-lookup-state.test.ts @@ -1,41 +1,24 @@ import { describe, expect, it } from "vitest"; -import { geoLookupState, type PublicVersion } from "../_shared"; +import { geoLookupState, type AdminGeoStatus } from "../_shared"; -const base: PublicVersion = { - version: "1.42.0", - buildSha: null, - builtAt: null, +const base: AdminGeoStatus = { + lookup: "online", + providerChosen: false, + providerHost: "ipwho.is", }; describe("geoLookupState", () => { it("warns only about the unconfigured default provider", () => { - expect( - geoLookupState({ - ...base, - offlineGeoEnabled: false, - geoLookup: "online", - }), - ).toBe("default"); + expect(geoLookupState(base)).toBe("default"); }); it("treats a provider the operator named as a decision", () => { - expect( - geoLookupState({ - ...base, - offlineGeoEnabled: false, - geoLookup: "online", - geoProviderChosen: true, - }), - ).toBe("chosen"); + expect(geoLookupState({ ...base, providerChosen: true })).toBe("chosen"); }); it("reads a switched-off lookup and the offline databases as settled", () => { - expect( - geoLookupState({ ...base, offlineGeoEnabled: false, geoLookup: "off" }), - ).toBe("off"); - expect(geoLookupState({ ...base, offlineGeoEnabled: true })).toBe( - "offline", - ); + expect(geoLookupState({ ...base, lookup: "off" })).toBe("off"); + expect(geoLookupState({ ...base, lookup: "offline" })).toBe("offline"); }); }); diff --git a/src/components/admin/__tests__/system-status-worker-contrast.test.tsx b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx index a6c49734f..9ee36ebd4 100644 --- a/src/components/admin/__tests__/system-status-worker-contrast.test.tsx +++ b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx @@ -50,6 +50,11 @@ vi.mock("@tanstack/react-query", () => ({ activeSessions: 1, }, integrations: { umami: null, glitchtip: null, webPush: null }, + geo: { + lookup: "offline", + providerChosen: false, + providerHost: "ipwho.is", + }, }, isLoading: false, isError: false, diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 4c3855915..023211450 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -81,6 +81,8 @@ export interface SystemStatus { glitchtip: { configured: boolean; enabled: boolean } | null; webPush: { configured: boolean } | null; }; + /** Login-IP geolocation state (admin-only since v1.42). */ + geo: AdminGeoStatus; } export interface AdminSettings { @@ -590,10 +592,16 @@ export interface PublicVersion { // offline tier is absent (host of IP_GEO_LOOKUP_URL, default ipwho.is). // Optional so legacy /api/version responses still satisfy the shape. geoProviderHost?: string; - /** v1.42 — `offline`, `online` or `off` (IP_GEO_LOOKUP_DISABLED). */ - geoLookup?: "offline" | "online" | "off"; - /** v1.42 — the operator named the provider in IP_GEO_LOOKUP_URL. */ - geoProviderChosen?: boolean; +} + +/** How login IPs are placed on this host, as `/api/admin/status` reports it. */ +export interface AdminGeoStatus { + /** `offline`, `online` or `off` (IP_GEO_LOOKUP_DISABLED). */ + lookup: "offline" | "online" | "off"; + /** The operator named the provider in IP_GEO_LOOKUP_URL. */ + providerChosen: boolean; + /** Host of the online provider (IP_GEO_LOOKUP_URL, default ipwho.is). */ + providerHost: string; } /** @@ -602,15 +610,15 @@ export interface PublicVersion { * warns, because it sends every login IP to a third party nobody chose. */ export function geoLookupState( - version: PublicVersion, + geo: AdminGeoStatus, ): "offline" | "off" | "chosen" | "default" { - if (version.offlineGeoEnabled) return "offline"; - if (version.geoLookup === "off") return "off"; - return version.geoProviderChosen ? "chosen" : "default"; + if (geo.lookup === "offline") return "offline"; + if (geo.lookup === "off") return "off"; + return geo.providerChosen ? "chosen" : "default"; } -/** Shared `/api/version` reader — the system-status section + overview - * summary both surface app version / build SHA / offline-geo state. */ +/** Shared `/api/version` reader for the version tile (version, build SHA, + * update check). The geo state comes from `/api/admin/status`. */ export function usePublicVersion() { return useQuery({ queryKey: queryKeys.publicVersion(), diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 334a83e2d..a3cf1f4c4 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -25,7 +25,6 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { geoLookupState, StatusItem, - usePublicVersion, useSystemStatus, type FailingQueue, } from "./_shared"; @@ -129,7 +128,6 @@ export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); const { data: status, isError, refetch } = useSystemStatus(); - const { data: version } = usePublicVersion(); return (
@@ -238,30 +236,27 @@ export function SystemStatusSection() { tone="success" /> )} - {/* v1.4.27 R5 — offline GeoLite2 availability. Renders only - when /api/version returns the new field so legacy - responses do not produce a placeholder row. */} - {version?.offlineGeoEnabled !== undefined && ( - - )} + {/* v1.4.27 R5 — offline GeoLite2 availability, read from the + admin status rather than the public /api/version. */} +
) : isError ? ( // P19: surface load failures inline instead of leaving the diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 19ae088dd..c2088e662 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -26,18 +26,12 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; -import { - geoLookupState, - StatusItem, - usePublicVersion, - useSystemStatus, -} from "./_shared"; +import { geoLookupState, StatusItem, useSystemStatus } from "./_shared"; import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { const { t } = useTranslations(); const { data: status, isError, refetch } = useSystemStatus(); - const { data: version } = usePublicVersion(); const { data: keyBackup } = useKeyBackupStatus(); return ( @@ -92,30 +86,27 @@ export function SystemStatusSummary() { /> )} {/* v1.4.27 R5 — surface the offline-geo state so the maintainer - spots the missing MAXMIND_LICENSE_KEY without crawling logs. - The field is undefined on legacy responses; the row only - renders when /api/version answers the new shape. */} - {version?.offlineGeoEnabled !== undefined && ( - - )} + spots the missing GeoLite2 databases without crawling logs. + Read from the admin status, not the public /api/version. */} + ) : isError ? ( { respond(404); const client = seeded(); const onFailed = vi.fn(); - commitCoachConversationDelete(client, "c1", onFailed); + commitCoachConversationDelete(client, "c1", null, onFailed); await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); await new Promise((resolve) => setTimeout(resolve, 0)); expect(onFailed).not.toHaveBeenCalled(); expect(headIds(client)).toEqual(["c2"]); }); + it("settles a 404 against the journal entry of its own scope only", async () => { + const map = new Map(); + vi.stubGlobal("window", { + sessionStorage: { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + removeItem: (key: string) => void map.delete(key), + }, + }); + try { + // The same id journaled in another record must survive this settle. + writeCoachDeleteJournal("user-a:managed-1", "c1", true); + respond(404); + const client = seeded(); + const onFailed = vi.fn(); + commitCoachConversationDelete(client, "c1", "user-a:own", onFailed); + expect(readCoachDeleteJournal("user-a:own")).toEqual(["c1"]); + await vi.waitFor(() => + expect(readCoachDeleteJournal("user-a:own")).toEqual([]), + ); + expect(onFailed).not.toHaveBeenCalled(); + expect(readCoachDeleteJournal("user-a:managed-1")).toEqual(["c1"]); + } finally { + vi.unstubAllGlobals(); + } + }); + it("restores the lists and reports a failed delete", async () => { respond(500); const client = seeded(); const onFailed = vi.fn(); - commitCoachConversationDelete(client, "c1", onFailed); + commitCoachConversationDelete(client, "c1", null, onFailed); expect(headIds(client)).toEqual(["c2"]); await vi.waitFor(() => expect(onFailed).toHaveBeenCalledOnce()); expect(headIds(client)).toEqual(["c1", "c2"]); diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 1572f2880..3aa90e76a 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -46,6 +46,13 @@ import { apiPost, } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; +import { getRecordScope } from "@/lib/query-keys/record-scope"; +import { + coachDeleteJournalOwner, + resendCoachDeleteJournal, + writeCoachDeleteJournal, +} from "@/lib/ai/coach/delete-journal"; +import { useAuth } from "@/hooks/use-auth"; /** * v1.4.20 phase B2b — TanStack Query + SSE client for the AI Coach @@ -430,46 +437,6 @@ function subscribeHiddenConversations(listener: () => void): () => void { return () => hiddenConversationListeners.delete(listener); } -/** - * Deletes sent but not yet confirmed, kept in `sessionStorage` so they - * outlive a reload. A reload inside the undo window sends the DELETE with - * `keepalive` from the old page, and the new page's list read can reach the - * server before it: without the journal that read brings the row back. The - * new page hides every journaled id and sends its DELETE again; a 404 then - * means it is already gone. - */ -const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes"; - -function readDeleteJournal(): string[] { - try { - const raw = window.sessionStorage.getItem(DELETE_JOURNAL_KEY); - const parsed: unknown = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) - ? parsed.filter((id): id is string => typeof id === "string") - : []; - } catch { - return []; - } -} - -function writeDeleteJournal(id: string, present: boolean): void { - try { - const ids = new Set(readDeleteJournal()); - if (present) ids.add(id); - else ids.delete(id); - if (ids.size === 0) { - window.sessionStorage.removeItem(DELETE_JOURNAL_KEY); - } else { - window.sessionStorage.setItem( - DELETE_JOURNAL_KEY, - JSON.stringify([...ids]), - ); - } - } catch { - // Storage unavailable: the keepalive request still carries the delete. - } -} - const deletesInFlight = new Set(); /** @@ -477,16 +444,20 @@ const deletesInFlight = new Set(); * `pagehide` is on the wire before the page is torn down. Every list cache * drops the row first; a failure restores them, shows the row again and * calls `onFailed`. A 404 is success: the row is gone, which is the point. + * That reading holds because the delete is only ever sent in the scope it + * was journaled under (`owner`, see `@/lib/ai/coach/delete-journal`); with no + * known owner nothing is journaled and the keepalive request is all there is. */ export function commitCoachConversationDelete( queryClient: QueryClient, id: string, + owner: string | null, onFailed: () => void, ): void { if (deletesInFlight.has(id)) return; deletesInFlight.add(id); setConversationHidden(id, true); - writeDeleteJournal(id, true); + if (owner !== null) writeCoachDeleteJournal(owner, id, true); const request = deleteCoachConversationRequest(id); const snapshot = removeCoachConversationFromCaches(queryClient, id); request @@ -506,7 +477,7 @@ export function commitCoachConversationDelete( ) .finally(() => { deletesInFlight.delete(id); - writeDeleteJournal(id, false); + if (owner !== null) writeCoachDeleteJournal(owner, id, false); void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); }); } @@ -522,7 +493,7 @@ export function commitCoachConversationDelete( * sent to the background, a tab switched away), on `pagehide` (reload, * close, navigation away) and when the owning component unmounts; the * request goes out with `keepalive`, and a delete the page could not confirm - * is sent again by the next one (see the journal above). Before that, a + * is sent again by the next one (see `@/lib/ai/coach/delete-journal`). Before that, a * delete followed by a reload inside the window was never sent, and the * conversation came back. * @@ -532,6 +503,11 @@ export function commitCoachConversationDelete( export function useDeleteCoachConversationWithUndo() { const queryClient = useQueryClient(); const { t } = useTranslations(); + const { user } = useAuth(); + // The account and the record this page acts on; the journal is bound to it. + const owner = user + ? coachDeleteJournalOwner(user.id, getRecordScope()) + : null; const pendingDeleteIds = useSyncExternalStore( subscribeHiddenConversations, () => hiddenConversationIds, @@ -544,7 +520,7 @@ export function useDeleteCoachConversationWithUndo() { useEffect(() => { const failedMessage = t("insights.coach.historyDeleteFailed"); commitRef.current = (id) => - commitCoachConversationDelete(queryClient, id, () => + commitCoachConversationDelete(queryClient, id, owner, () => toast.error(failedMessage), ); }); @@ -560,9 +536,13 @@ export function useDeleteCoachConversationWithUndo() { ); useEffect(() => { - // Finish what a previous page of this tab could not confirm. - for (const id of readDeleteJournal()) commitRef.current(id); + // Finish what a previous page of this tab could not confirm, in this + // scope only: another account's or another record's entries stay put. + if (owner === null) return; + resendCoachDeleteJournal(owner, (id) => commitRef.current(id)); + }, [owner]); + useEffect(() => { const onVisibilityChange = () => { if (document.visibilityState === "hidden") getQueue().flush(); }; diff --git a/src/hooks/__tests__/use-auth-session-clear.test.ts b/src/hooks/__tests__/use-auth-session-clear.test.ts index e98968f80..6c64d4a92 100644 --- a/src/hooks/__tests__/use-auth-session-clear.test.ts +++ b/src/hooks/__tests__/use-auth-session-clear.test.ts @@ -29,6 +29,12 @@ vi.mock("@/lib/day/last-day", () => ({ clearLastDay: () => clearLastDay(), })); +// Coach deletes the ending session could not confirm belong to its account. +const clearCoachDeleteJournal = vi.fn(); +vi.mock("@/lib/ai/coach/delete-journal", () => ({ + clearCoachDeleteJournal: () => clearCoachDeleteJournal(), +})); + // `use-auth` pulls in the typed fetch wrapper, the router, and the i18n // context at module load. None of them matter for the pure session-end // helper, so stub them so the import is side-effect free in the test env. @@ -48,6 +54,7 @@ import { clearCachesForSessionEnd } from "../use-auth"; afterEach(() => { clearOfflineCachesForSessionEnd.mockClear(); clearLastDay.mockClear(); + clearCoachDeleteJournal.mockClear(); }); describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => { @@ -80,4 +87,9 @@ describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => { clearCachesForSessionEnd(new QueryClient()); expect(clearLastDay).toHaveBeenCalledTimes(1); }); + + it("clears the Coach delete journal", () => { + clearCachesForSessionEnd(new QueryClient()); + expect(clearCoachDeleteJournal).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index a8830df66..038625e60 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -28,6 +28,7 @@ import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearLastDay } from "@/lib/day/last-day"; +import { clearCoachDeleteJournal } from "@/lib/ai/coach/delete-journal"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { setRecordScope, @@ -624,6 +625,9 @@ export function clearCachesForSessionEnd(queryClient: QueryClient): void { clearWasAuthenticated(); // The day the layer last showed is a date of this account's record. clearLastDay(); + // Coach deletes this session could not confirm belong to its account; the + // next one must not re-send them against its own record. + clearCoachDeleteJournal(); void clearOfflineCachesForSessionEnd(); } diff --git a/src/lib/ai/coach/__tests__/delete-journal.test.ts b/src/lib/ai/coach/__tests__/delete-journal.test.ts new file mode 100644 index 000000000..a3be2f9cd --- /dev/null +++ b/src/lib/ai/coach/__tests__/delete-journal.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + clearCoachDeleteJournal, + coachDeleteJournalOwner, + readCoachDeleteJournal, + resendCoachDeleteJournal, + writeCoachDeleteJournal, +} from "../delete-journal"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (key) => map.get(key) ?? null, + key: (index) => [...map.keys()][index] ?? null, + removeItem: (key) => void map.delete(key), + setItem: (key, value) => void map.set(key, String(value)), + }; +} + +const KEY = "healthlog:coach-conversation-deletes"; +let storage: Storage; + +beforeEach(() => { + storage = memoryStorage(); + vi.stubGlobal("window", { sessionStorage: storage }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const alice = coachDeleteJournalOwner("user-a", null); +const aliceInManaged = coachDeleteJournalOwner("user-a", "managed-1"); +const bob = coachDeleteJournalOwner("user-b", null); + +describe("Coach delete journal", () => { + it("binds each entry to the account and the record it was sent for", () => { + writeCoachDeleteJournal(alice, "c1", true); + writeCoachDeleteJournal(aliceInManaged, "c2", true); + expect(readCoachDeleteJournal(alice)).toEqual(["c1"]); + expect(readCoachDeleteJournal(aliceInManaged)).toEqual(["c2"]); + expect(readCoachDeleteJournal(bob)).toEqual([]); + + writeCoachDeleteJournal(alice, "c1", false); + expect(readCoachDeleteJournal(alice)).toEqual([]); + expect(readCoachDeleteJournal(aliceInManaged)).toEqual(["c2"]); + }); + + it("re-sends nothing after a logout and another login in the same tab", () => { + writeCoachDeleteJournal(alice, "c1", true); + const commit = vi.fn(); + resendCoachDeleteJournal(bob, commit); + expect(commit).not.toHaveBeenCalled(); + // The entry waits for its own scope instead of being spent on the wrong + // one, where a 404 would have read as "already gone". + expect(readCoachDeleteJournal(alice)).toEqual(["c1"]); + }); + + it("re-sends nothing outside the record after a switch into a managed profile", () => { + writeCoachDeleteJournal(alice, "own-1", true); + writeCoachDeleteJournal(aliceInManaged, "managed-c", true); + + const inManaged = vi.fn(); + resendCoachDeleteJournal(aliceInManaged, inManaged); + expect(inManaged.mock.calls).toEqual([["managed-c"]]); + + const backHome = vi.fn(); + resendCoachDeleteJournal(alice, backHome); + expect(backHome.mock.calls).toEqual([["own-1"]]); + }); + + it("drops entries without an owner instead of re-sending them anywhere", () => { + storage.setItem(KEY, JSON.stringify(["legacy-id"])); + const commit = vi.fn(); + resendCoachDeleteJournal(alice, commit); + expect(commit).not.toHaveBeenCalled(); + }); + + it("is emptied by a session end, whoever the entries belonged to", () => { + writeCoachDeleteJournal(alice, "c1", true); + writeCoachDeleteJournal(bob, "c2", true); + clearCoachDeleteJournal(); + expect(storage.getItem(KEY)).toBeNull(); + }); + + it("stays quiet without storage", () => { + vi.stubGlobal("window", undefined); + expect(() => writeCoachDeleteJournal(alice, "c1", true)).not.toThrow(); + expect(readCoachDeleteJournal(alice)).toEqual([]); + expect(() => clearCoachDeleteJournal()).not.toThrow(); + }); +}); diff --git a/src/lib/ai/coach/delete-journal.ts b/src/lib/ai/coach/delete-journal.ts new file mode 100644 index 000000000..372092e4f --- /dev/null +++ b/src/lib/ai/coach/delete-journal.ts @@ -0,0 +1,111 @@ +/** + * Coach conversation deletes sent but not yet confirmed, kept in + * `sessionStorage` so they outlive a reload. A reload inside the undo window + * sends the DELETE with `keepalive` from the old page, and the new page's list + * read can reach the server before it: without the journal that read brings + * the row back. The new page hides every journaled id and sends its DELETE + * again; a 404 then means it is already gone. + * + * Every entry carries the owner it was sent for: the signed-in account and + * the record it was reading (`:`). A tab outlives + * a logout, another login and a switch into a managed profile, and a delete + * re-sent in any of those other scopes asks the wrong record: the server + * answers 404, which would read as "already gone" and drop the entry while the + * conversation still exists, or the record fence refuses it and a failure + * toast names a row the person cannot see. So only entries of the current + * owner are re-sent, which is also what keeps a 404 meaningful, and a session + * end clears the journal with the rest of the session's state. + */ +const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes"; + +export interface CoachDeleteJournalEntry { + id: string; + owner: string; +} + +/** The journal owner for an account reading `recordScope` (null = own). */ +export function coachDeleteJournalOwner( + userId: string, + recordScope: string | null, +): string { + return `${userId}:${recordScope ?? "own"}`; +} + +function safeSessionStorage(): Storage | null { + try { + return typeof window === "undefined" ? null : window.sessionStorage; + } catch { + return null; + } +} + +function readAll(storage: Storage): CoachDeleteJournalEntry[] { + try { + const raw = storage.getItem(DELETE_JOURNAL_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + // An entry without an owner (the earlier bare-id shape) cannot be bound + // to a scope and is dropped rather than re-sent anywhere. + return parsed.filter( + (entry): entry is CoachDeleteJournalEntry => + typeof entry === "object" && + entry !== null && + typeof (entry as CoachDeleteJournalEntry).id === "string" && + typeof (entry as CoachDeleteJournalEntry).owner === "string", + ); + } catch { + return []; + } +} + +/** The ids journaled for `owner`, and only those. */ +export function readCoachDeleteJournal(owner: string): string[] { + const storage = safeSessionStorage(); + if (!storage) return []; + return readAll(storage) + .filter((entry) => entry.owner === owner) + .map((entry) => entry.id); +} + +/** + * Re-send what a previous page of this tab could not confirm, for `owner` + * only: another account's or another record's entries stay where they are + * until their own scope comes back or the session ends. + */ +export function resendCoachDeleteJournal( + owner: string, + commit: (id: string) => void, +): void { + for (const id of readCoachDeleteJournal(owner)) commit(id); +} + +/** Add (`present`) or remove one delete of `owner`. Best effort. */ +export function writeCoachDeleteJournal( + owner: string, + id: string, + present: boolean, +): void { + const storage = safeSessionStorage(); + if (!storage) return; + try { + const entries = readAll(storage).filter( + (entry) => !(entry.owner === owner && entry.id === id), + ); + if (present) entries.push({ id, owner }); + if (entries.length === 0) storage.removeItem(DELETE_JOURNAL_KEY); + else storage.setItem(DELETE_JOURNAL_KEY, JSON.stringify(entries)); + } catch { + // Storage unavailable: the keepalive request still carries the delete. + } +} + +/** Forget every journaled delete, whoever it belonged to (a session end). */ +export function clearCoachDeleteJournal(): void { + const storage = safeSessionStorage(); + if (!storage) return; + try { + storage.removeItem(DELETE_JOURNAL_KEY); + } catch { + return; + } +} diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 614a4d4a3..282d01a70 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -506,10 +506,15 @@ export async function buildProfileBackupSection( ...(disasterRecovery ? { id: pattern.id, - createdAt: pattern.createdAt.toISOString(), updatedAt: pattern.updatedAt.toISOString(), } : {}), + // Carried in every export, not only DR: when a finding was first made + // is part of what it means. The correlation surface reads it to tell a + // finding of the earlier engine from one of the seasonally adjusted + // engine, and a restore that reset it to the restore day would drop + // that note for good. + createdAt: pattern.createdAt.toISOString(), canonicalKey: pattern.canonicalKey, family: pattern.family, factorKey: pattern.factorKey, @@ -945,9 +950,11 @@ export async function restoreProfileData( dismissedEvidenceHash: pattern.dismissedEvidenceHash, dismissedEffectSize: pattern.dismissedEffectSize, dismissedSampleSize: pattern.dismissedSampleSize, - ...(pattern.createdAt - ? { createdAt: new Date(pattern.createdAt) } - : {}), + // A file from before portable exports carried `createdAt` falls back + // to the last computation, the latest instant the finding certainly + // existed: still earlier than this restore, which is what keeps a + // finding of the earlier engine counted as one. + createdAt: new Date(pattern.createdAt ?? pattern.lastComputedAt), ...(pattern.updatedAt ? { updatedAt: new Date(pattern.updatedAt) } : {}), diff --git a/src/lib/insights/correlation-patterns.ts b/src/lib/insights/correlation-patterns.ts index 66625b36f..7d72fe4bb 100644 --- a/src/lib/insights/correlation-patterns.ts +++ b/src/lib/insights/correlation-patterns.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; export const PATTERN_FAMILIES = { discoveryRetrospective: "DISCOVERY_RETROSPECTIVE", @@ -140,6 +141,12 @@ export async function syncAcceptedPatterns(args: { })); const keys = prepared.map((item) => item.canonicalKey); + // The earlier-engine cutoff has to exist before the new engine writes its + // first discovery pattern, or that pattern would count as an earlier one. + if (DISCOVERY_FAMILIES.includes(args.family)) { + await stampSeasonalEngineStart(); + } + return prisma.$transaction(async (tx) => { const existing = await tx.correlationPattern.findMany({ where: { userId: args.userId, canonicalKey: { in: keys } }, @@ -252,33 +259,78 @@ export function decisionForEvidence( } /** - * The day the discovery engine started correlating seasonally adjusted - * residuals with an effective-sample-size p-value (v1.42). Before it, two - * series that merely shared the season or a long trend surfaced as a - * finding, so an account with history usually sees fewer pairs afterwards. + * The discovery engine correlates seasonally adjusted residuals with an + * effective-sample-size p-value since v1.42. Before it, two series that + * merely shared the season or a long trend surfaced as a finding, so an + * account with history usually sees fewer pairs afterwards. + * + * Where that change took effect is an instance fact, not a calendar date: an + * instance keeps writing earlier-engine findings until it runs the new build. + * The first discovery sync of the new engine stamps + * `AppSettings.correlationSeasonalEngineSince`, before it writes anything, and + * the cutoff below reads that stamp. Stamped once per instance; the process + * remembers it so later syncs skip the write. */ -export const SEASONAL_ADJUSTMENT_SINCE = new Date("2026-10-08T00:00:00.000Z"); +const DISCOVERY_FAMILIES: readonly PatternFamily[] = [ + PATTERN_FAMILIES.discoveryRetrospective, + PATTERN_FAMILIES.discoveryRecent, +]; + +let seasonalEngineStamped = false; + +/** + * Record that the seasonally adjusted engine is running here, unless already + * recorded. Best effort: a failed stamp leaves the marker unset, which counts + * every pattern as an earlier finding (the note shows once more than it + * should), never the reverse. + */ +export async function stampSeasonalEngineStart( + now: Date = new Date(), +): Promise { + if (seasonalEngineStamped) return; + try { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", correlationSeasonalEngineSince: now }, + update: {}, + }); + await prisma.appSettings.updateMany({ + where: { id: "singleton", correlationSeasonalEngineSince: null }, + data: { correlationSeasonalEngineSince: now }, + }); + seasonalEngineStamped = true; + } catch { + getEvent()?.addWarning("correlation seasonal engine stamp failed"); + } +} + +/** Test seam: forget that this process already stamped the marker. */ +export function resetSeasonalEngineStampForTests(): void { + seasonalEngineStamped = false; +} /** * Whether the record held a discovery finding under the earlier engine: a - * stored discovery pattern first written before {@link SEASONAL_ADJUSTMENT_SINCE}. - * A dismissed pattern counts, it was a finding. An account created after the - * change can never qualify, so the one-time explanation on the correlation - * surface reaches only the people whose list actually changed. + * stored discovery pattern created before the instance's seasonal-engine + * stamp, or any stored discovery pattern while there is no stamp yet. A + * dismissed pattern counts, it was a finding. An account whose first finding + * came from the new engine never qualifies, so the one-time explanation on + * the correlation surface reaches only the people whose list actually + * changed. */ export async function hadFindingsBeforeSeasonalAdjustment( userId: string, ): Promise { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { correlationSeasonalEngineSince: true }, + }); + const since = settings?.correlationSeasonalEngineSince ?? null; const row = await prisma.correlationPattern.findFirst({ where: { userId, - family: { - in: [ - PATTERN_FAMILIES.discoveryRetrospective, - PATTERN_FAMILIES.discoveryRecent, - ], - }, - createdAt: { lt: SEASONAL_ADJUSTMENT_SINCE }, + family: { in: [...DISCOVERY_FAMILIES] }, + ...(since ? { createdAt: { lt: since } } : {}), }, select: { id: true }, }); diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index 864da8252..088cb1032 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -239,16 +239,6 @@ const versionResponse = z .describe( "Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default.", ), - geoLookup: z - .enum(["offline", "online", "off"]) - .describe( - "How login IPs are placed (since v1.42): `offline` from the GeoLite2 databases, `online` by the provider above, `off` not at all (`IP_GEO_LOOKUP_DISABLED`).", - ), - geoProviderChosen: z - .boolean() - .describe( - "The operator named the online provider in `IP_GEO_LOOKUP_URL` (since v1.42). False while the default is in use.", - ), }) .meta({ id: "VersionResponse", diff --git a/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts index 7534347f2..91af5b9ee 100644 --- a/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts +++ b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts @@ -3,25 +3,33 @@ * * The flag decides who sees the one-time note on the correlation surface, so * it must hold only for a record that kept a discovery pattern from before the - * engine change: not for a pattern written since, not for another account's - * old pattern, and not for a non-discovery family. + * engine change. The change is located by the instance's own marker + * (`AppSettings.correlationSeasonalEngineSince`), which the first discovery + * sync of the new engine stamps before it writes, not by a calendar date. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { + canonicalPatternKey, hadFindingsBeforeSeasonalAdjustment, PATTERN_FAMILIES, - SEASONAL_ADJUSTMENT_SINCE, + resetSeasonalEngineStampForTests, + syncAcceptedPatterns, } from "@/lib/insights/correlation-patterns"; +import { + buildProfileBackupSection, + restoreProfileData, +} from "@/lib/export/profile-backup"; vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); const DAY_MS = 24 * 60 * 60 * 1000; -const BEFORE = new Date(SEASONAL_ADJUSTMENT_SINCE.getTime() - 30 * DAY_MS); -const AFTER = new Date(SEASONAL_ADJUSTMENT_SINCE.getTime() + DAY_MS); +const MARKER = new Date("2026-10-08T00:00:00.000Z"); +const BEFORE = new Date(MARKER.getTime() - 30 * DAY_MS); +const AFTER = new Date(MARKER.getTime() + DAY_MS); let seq = 0; async function seedUser() { @@ -40,40 +48,60 @@ async function seedPattern(userId: string, family: string, createdAt: Date) { await getPrismaClient().correlationPattern.create({ data: { userId, - canonicalKey: `p1:test-${seq}`, + canonicalKey: canonicalPatternKey(`FACTOR_${seq}`, "MOOD", 1), family, - factorKey: "ACTIVITY_STEPS", + factorKey: `FACTOR_${seq}`, outcomeKey: "MOOD", lagDays: 1, sampleSize: 60, effectSize: 0.4, pValue: 0.001, qValue: 0.01, - evidenceHash: `hash-${seq}`, + evidenceHash: "a".repeat(64), lastComputedAt: createdAt, createdAt, }, }); } +async function setMarker(at: Date | null) { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", correlationSeasonalEngineSince: at }, + update: { correlationSeasonalEngineSince: at }, + }); +} + beforeEach(async () => { await truncateAllTables(getPrismaClient()); + resetSeasonalEngineStampForTests(); }); describe("hadFindingsBeforeSeasonalAdjustment", () => { - it("holds for a record with a discovery pattern stored before the change", async () => { + it("counts every existing discovery pattern while the instance has no marker", async () => { + const user = await seedUser(); + // Written by the earlier engine right up to the deploy: no fixed date + // may exclude it. + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, AFTER); + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); + + it("holds for a record with a discovery pattern stored before the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); }); - it("holds for an early-detection pattern stored before the change", async () => { + it("holds for an early-detection pattern stored before the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRecent, BEFORE); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); }); - it("does not hold for a pattern first stored after the change", async () => { + it("does not hold for a pattern first stored after the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, AFTER); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false); @@ -96,3 +124,123 @@ describe("hadFindingsBeforeSeasonalAdjustment", () => { expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false); }); }); + +describe("the first discovery sync of the new engine", () => { + const evidence = { + factorKey: "ACTIVITY_STEPS", + outcomeKey: "SLEEP_DURATION", + lagDays: 0, + sampleSize: 90, + effectSize: 0.35, + pValue: 0.002, + qValue: 0.02, + }; + + it("stamps the marker before it writes, once", async () => { + const earlier = await seedUser(); + await seedPattern( + earlier.id, + PATTERN_FAMILIES.discoveryRetrospective, + BEFORE, + ); + const fresh = await seedUser(); + + await syncAcceptedPatterns({ + userId: fresh.id, + family: PATTERN_FAMILIES.discoveryRetrospective, + accepted: [evidence], + }); + + const settings = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + const stamp = settings?.correlationSeasonalEngineSince; + expect(stamp).toBeInstanceOf(Date); + // The new engine's own finding is not an earlier one; the old one is. + expect(await hadFindingsBeforeSeasonalAdjustment(fresh.id)).toBe(false); + expect(await hadFindingsBeforeSeasonalAdjustment(earlier.id)).toBe(true); + + // A later run (another process) leaves the first stamp standing. + resetSeasonalEngineStampForTests(); + await syncAcceptedPatterns({ + userId: fresh.id, + family: PATTERN_FAMILIES.discoveryRecent, + accepted: [evidence], + }); + const again = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + expect(again?.correlationSeasonalEngineSince?.toISOString()).toBe( + stamp?.toISOString(), + ); + }); + + it("is not triggered by a non-discovery family", async () => { + const user = await seedUser(); + await syncAcceptedPatterns({ + userId: user.id, + family: PATTERN_FAMILIES.fixed, + accepted: [evidence], + }); + const settings = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + expect(settings?.correlationSeasonalEngineSince ?? null).toBeNull(); + }); +}); + +describe("a restore", () => { + it("keeps when a finding was first made, so the note survives it", async () => { + await setMarker(MARKER); + const user = await seedUser(); + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); + const prisma = getPrismaClient(); + + // The portable export is the one that used to drop `createdAt`. + const section = await buildProfileBackupSection(prisma, user.id, { + purpose: "portable-export", + }); + expect(section.correlationPatterns[0]?.createdAt).toBe( + BEFORE.toISOString(), + ); + + await prisma.$transaction((tx) => + restoreProfileData(tx, user.id, { + healthProfile: section.healthProfile, + healthProfileFacts: section.healthProfileFacts, + customMetrics: section.customMetrics, + correlationPatterns: section.correlationPatterns, + }), + ); + + const restored = await prisma.correlationPattern.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(restored.createdAt.toISOString()).toBe(BEFORE.toISOString()); + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); + + it("falls back to the last computation for a file without createdAt", async () => { + await setMarker(MARKER); + const user = await seedUser(); + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); + const prisma = getPrismaClient(); + const section = await buildProfileBackupSection(prisma, user.id); + const legacy = section.correlationPatterns.map((pattern) => { + const copy = { ...pattern }; + delete copy.createdAt; + return copy; + }); + + await prisma.$transaction((tx) => + restoreProfileData(tx, user.id, { + healthProfile: section.healthProfile, + healthProfileFacts: section.healthProfileFacts, + customMetrics: section.customMetrics, + correlationPatterns: legacy, + }), + ); + + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); +});