mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the delete journal scope, seasonal marker and admin-only geo state into release/v1.42.0
This commit is contained in:
commit
fe87af7234
22 files changed
+667
-207
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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<Response>)();
|
||||
const body = (await response.json()) as { data: Record<string, unknown> };
|
||||
expect(Object.keys(body.data).sort()).toEqual([
|
||||
"buildSha",
|
||||
"builtAt",
|
||||
"changelog",
|
||||
"docs",
|
||||
"geoProviderHost",
|
||||
"license",
|
||||
"offlineGeoEnabled",
|
||||
"repository",
|
||||
"version",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -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 && (
|
||||
<StatusItem
|
||||
icon={Map}
|
||||
label={t("admin.offlineGeoLabel")}
|
||||
value={
|
||||
{
|
||||
offline: t("admin.offlineGeoEnabled"),
|
||||
off: t("admin.offlineGeoOff"),
|
||||
chosen: t("admin.offlineGeoChosen", {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
}),
|
||||
default: t("admin.offlineGeoFallback", {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
}),
|
||||
}[geoLookupState(version)]
|
||||
}
|
||||
tone={
|
||||
geoLookupState(version) === "default" ? "warning" : "success"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 R5 — offline GeoLite2 availability, read from the
|
||||
admin status rather than the public /api/version. */}
|
||||
<StatusItem
|
||||
icon={Map}
|
||||
label={t("admin.offlineGeoLabel")}
|
||||
value={
|
||||
{
|
||||
offline: t("admin.offlineGeoEnabled"),
|
||||
off: t("admin.offlineGeoOff"),
|
||||
chosen: t("admin.offlineGeoChosen", {
|
||||
host: status.geo.providerHost,
|
||||
}),
|
||||
default: t("admin.offlineGeoFallback", {
|
||||
host: status.geo.providerHost,
|
||||
}),
|
||||
}[geoLookupState(status.geo)]
|
||||
}
|
||||
tone={
|
||||
geoLookupState(status.geo) === "default" ? "warning" : "success"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : isError ? (
|
||||
// P19: surface load failures inline instead of leaving the
|
||||
|
||||
@@ -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 && (
|
||||
<StatusItem
|
||||
icon={Globe}
|
||||
label={t("admin.overview.snapshotOfflineGeo")}
|
||||
value={
|
||||
{
|
||||
offline: t("admin.overview.snapshotOfflineGeoOn"),
|
||||
off: t("admin.offlineGeoOff"),
|
||||
chosen: t("admin.offlineGeoChosen", {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
}),
|
||||
default: t("admin.overview.snapshotOfflineGeoOff", {
|
||||
host: version.geoProviderHost ?? "ipwho.is",
|
||||
}),
|
||||
}[geoLookupState(version)]
|
||||
}
|
||||
tone={
|
||||
geoLookupState(version) === "default" ? "warning" : "success"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
spots the missing GeoLite2 databases without crawling logs.
|
||||
Read from the admin status, not the public /api/version. */}
|
||||
<StatusItem
|
||||
icon={Globe}
|
||||
label={t("admin.overview.snapshotOfflineGeo")}
|
||||
value={
|
||||
{
|
||||
offline: t("admin.overview.snapshotOfflineGeoOn"),
|
||||
off: t("admin.offlineGeoOff"),
|
||||
chosen: t("admin.offlineGeoChosen", {
|
||||
host: status.geo.providerHost,
|
||||
}),
|
||||
default: t("admin.overview.snapshotOfflineGeoOff", {
|
||||
host: status.geo.providerHost,
|
||||
}),
|
||||
}[geoLookupState(status.geo)]
|
||||
}
|
||||
tone={
|
||||
geoLookupState(status.geo) === "default" ? "warning" : "success"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
CoachConversationsPage,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
readCoachDeleteJournal,
|
||||
writeCoachDeleteJournal,
|
||||
} from "@/lib/ai/coach/delete-journal";
|
||||
import {
|
||||
CONVERSATION_DELETE_UNDO_MS,
|
||||
commitCoachConversationDelete,
|
||||
@@ -180,18 +184,45 @@ describe("Coach conversation delete caches and request", () => {
|
||||
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<string, string>();
|
||||
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"]);
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
/**
|
||||
@@ -477,16 +444,20 @@ const deletesInFlight = new Set<string>();
|
||||
* `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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (`<userId>:<recordScope | "own">`). 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;
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user