mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
refactor(settings): remove the preview-feature opt-ins
The per-account preview switch was kept for the topic overview pages, which were dropped before they shipped. Its one flag was stored, served and backed up, and read by nothing; the column-reader audit listed it as a pending consumer that will not come. Removed completely: - `/api/settings/preview-features` (GET, PATCH) is retired: it answers 410 Gone with `meta.errorCode: route.retired` and `removedIn: 1.46.0`, and the contract publishes it under Retired. The route, its validation schema, the `PreviewFeatures` OpenAPI schemas and the settings module are deleted. - Migration 0485 drops `users.preview_features_json` under a bounded lock wait, with the retry marker. - The backup no longer writes the field. A file from v1.44 or v1.45 that carries it still restores: the schema accepts it and no codec reads it, so nothing is refused or written for it. The backup-parity goldens lose the field and apply one setting fewer. - The data-wipe plan entry and the sharing-surface actor entry go with the route (frozen count 273 -> 272). No client build reads the route: neither iOS repository nor any local checkout references it.
This commit is contained in:
1 parent
f3b9329fb2
commit
2ae23f4ca8
26 files changed
+125
-437
No files matched your search
+55
-89
@@ -15428,45 +15428,6 @@ paths:
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/settings/preview-features:
|
||||
get:
|
||||
tags:
|
||||
- Meta
|
||||
summary: Get the preview-feature opt-ins
|
||||
description: Every flag, off unless the caller switched it on.
|
||||
responses:
|
||||
"200":
|
||||
description: The opt-ins.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PreviewFeaturesEnvelope"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
patch:
|
||||
tags:
|
||||
- Meta
|
||||
summary: Switch preview features on or off
|
||||
description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PreviewFeaturesPatchBody"
|
||||
responses:
|
||||
"200":
|
||||
description: The opt-ins as stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PreviewFeaturesSavedEnvelope"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/settings/privacy-summary:
|
||||
get:
|
||||
tags:
|
||||
@@ -27212,6 +27173,61 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/settings/preview-features:
|
||||
get:
|
||||
security: []
|
||||
deprecated: true
|
||||
tags:
|
||||
- Retired
|
||||
summary: GET /api/settings/preview-features — removed in v1.46.0
|
||||
description: >-
|
||||
Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it
|
||||
carries. There is no replacement; the capability is gone rather than moved.
|
||||
|
||||
|
||||
Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages
|
||||
were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no
|
||||
preview is gated per account.
|
||||
|
||||
|
||||
Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken
|
||||
deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and
|
||||
do not offer a retry.
|
||||
responses:
|
||||
"410":
|
||||
description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`,
|
||||
`meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
patch:
|
||||
security: []
|
||||
deprecated: true
|
||||
tags:
|
||||
- Retired
|
||||
summary: PATCH /api/settings/preview-features — removed in v1.46.0
|
||||
description: >-
|
||||
Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it
|
||||
carries. There is no replacement; the capability is gone rather than moved.
|
||||
|
||||
|
||||
Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages
|
||||
were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no
|
||||
preview is gated per account.
|
||||
|
||||
|
||||
Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken
|
||||
deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and
|
||||
do not offer a retry.
|
||||
responses:
|
||||
"410":
|
||||
description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`,
|
||||
`meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
components:
|
||||
schemas:
|
||||
CreateCycleCustomSymptomRequest:
|
||||
@@ -33264,12 +33280,6 @@ components:
|
||||
- version
|
||||
- list
|
||||
additionalProperties: false
|
||||
PreviewFeaturesPatchBody:
|
||||
type: object
|
||||
properties:
|
||||
topics:
|
||||
type: boolean
|
||||
additionalProperties: false
|
||||
ConsentPostBody:
|
||||
type: object
|
||||
properties:
|
||||
@@ -73560,50 +73570,6 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PreviewFeaturesEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/PreviewFeatures"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PreviewFeatures:
|
||||
type: object
|
||||
properties:
|
||||
topics:
|
||||
type: boolean
|
||||
description: Topic overview pages above the single-metric Insights pages. Off by default.
|
||||
required:
|
||||
- topics
|
||||
additionalProperties: false
|
||||
description: Per-user opt-in to preview features, one boolean per flag, every flag off by default.
|
||||
PreviewFeaturesSavedEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/PreviewFeatures"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PrivacySummaryEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Drop the per-user preview-feature opt-ins.
|
||||
--
|
||||
-- `users.preview_features_json` (0440) stored one flag, `topics`, for the
|
||||
-- topic overview pages. Those pages were dropped before they shipped, so no
|
||||
-- surface ever read the flag; `/api/settings/preview-features` is retired
|
||||
-- with this release and answers 410.
|
||||
--
|
||||
-- What is lost is a switch that switched nothing. A backup written by a
|
||||
-- release that still had the column carries the field, and the restore
|
||||
-- accepts and ignores it.
|
||||
--
|
||||
-- Dropping a column takes an ACCESS EXCLUSIVE lock on `users` for a catalogue
|
||||
-- update only (no rewrite). The lock wait is bounded so a long-running reader
|
||||
-- cannot queue every sign-in behind this statement; the deploy retries a
|
||||
-- timed-out attempt.
|
||||
--
|
||||
-- Reversibility: re-adding the nullable column restores the shape; every
|
||||
-- account then reads as all flags off, which is what every account had.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "preview_features_json";
|
||||
END
|
||||
$migration$;
|
||||
@@ -387,11 +387,6 @@ model User {
|
||||
// copies a stored browser value up once when the server has none for that list.
|
||||
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
|
||||
moduleListLayoutJson Json? @map("module_list_layout_json")
|
||||
/// Per-user opt-in to preview features, one boolean per flag, all off by
|
||||
/// default (`src/lib/settings/preview-features.ts`). Read and written
|
||||
/// through `/api/settings/preview-features` and carried by the backup.
|
||||
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
|
||||
previewFeaturesJson Json? @map("preview_features_json")
|
||||
/// Optional activity goals the person set for themselves: workouts, active
|
||||
/// minutes and steps per week, steps per month. Null or an absent key = no
|
||||
/// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through
|
||||
|
||||
@@ -2163,8 +2163,6 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
"The UI language belongs to the person reading the screen, never to the record on it. Under a record scope this would transplant a delegate's choice onto the owner's row and send the owner's cron mail in a language they may not read. It has to keep working rather than merely refuse safely: the switcher's mount-time backfill fires on every page load, including the shared dashboard.",
|
||||
"app/api/settings/module-list-layout/route.ts":
|
||||
"How the person's own labs, illness and checkups lists are laid out: card or list view, manual order, sort. It reads and writes the caller's own row only. Inside another record the ids of a saved order simply match nothing and the list keeps the server order, so serving the person's own choice there reveals nothing of the owner's and grants nothing; refusing it under a switch broke the list controls in exactly the context they are used in.",
|
||||
"app/api/settings/preview-features/route.ts":
|
||||
"The person's own opt-in to preview features. A preview entry is chrome of the person reading the screen, never a property of the record on it; it reads and writes the caller's own row and nothing of the owner's.",
|
||||
"app/api/feature-flags/route.ts":
|
||||
"Reads the `AppSettings` singleton and no user row at all, so there is no record for a switch to substitute. It answers about the deployment, which for this purpose is the caller's side of the request; the shell gates the Coach launcher and the assistant surfaces on it, so a 403 here is a piece of chrome deciding it does not exist.",
|
||||
};
|
||||
@@ -2273,8 +2271,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* side). The activity goals route does not: setting or changing goals stays
|
||||
* owner-only, and a delegate sees the goals only as the calendar reports them.
|
||||
* 270 -> 273.
|
||||
*
|
||||
* v1.46 -- the preview-feature switch is removed with its column; the topic
|
||||
* pages it was kept for were dropped. 273 -> 272.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 273;
|
||||
const FROZEN_ENTRY_COUNT = 272;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* GET / PATCH the caller's preview-feature opt-ins.
|
||||
*
|
||||
* GET answers every flag, off unless the caller switched it on. PATCH sets
|
||||
* the flags the body names and keeps the rest; unknown flags are refused
|
||||
* with the multi-issue 422 so a typo cannot silently do nothing. The blob
|
||||
* lives on its own `User` column (`preview_features_json`).
|
||||
*
|
||||
* The opt-ins are the caller's own, so this is an actor surface
|
||||
* (`requireActorAuth`): a browser acting for another record keeps reaching
|
||||
* the person's own switch. The PATCH reads and writes under a row lock, so two
|
||||
* flags switched at once both stay switched.
|
||||
*/
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
PREVIEW_FEATURE_KEYS,
|
||||
resolvePreviewFeatures,
|
||||
type PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
import { previewFeaturesPatchSchema } from "@/lib/validations/settings-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readStored(
|
||||
userId: string,
|
||||
db: Pick<typeof prisma, "user"> = prisma,
|
||||
): Promise<unknown> {
|
||||
const row = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { previewFeaturesJson: true },
|
||||
});
|
||||
return row?.previewFeaturesJson;
|
||||
}
|
||||
|
||||
function enabledFlags(features: PreviewFeatures): string[] {
|
||||
return PREVIEW_FEATURE_KEYS.filter((key) => features[key]);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireActorAuth();
|
||||
const features = resolvePreviewFeatures(await readStored(user.id));
|
||||
annotate({
|
||||
action: { name: "settings.preview-features.read" },
|
||||
meta: { enabled: enabledFlags(features) },
|
||||
});
|
||||
return apiSuccess(features);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireActorAuth();
|
||||
|
||||
const parsed = await parseJsonBody(request, previewFeaturesPatchSchema, {
|
||||
maxBytes: 1024,
|
||||
action: "settings.preview-features.validation-failed",
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const next = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`;
|
||||
const current = resolvePreviewFeatures(await readStored(user.id, tx));
|
||||
const merged: PreviewFeatures = {
|
||||
topics: parsed.data.topics ?? current.topics,
|
||||
};
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { previewFeaturesJson: toJson(merged) },
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.preview-features.update" },
|
||||
meta: { enabled: enabledFlags(next) },
|
||||
});
|
||||
return apiSuccess(next);
|
||||
});
|
||||
@@ -516,8 +516,6 @@ export const USER_KEPT_FIELDS: Readonly<Record<string, string>> = {
|
||||
oidcIssuer: "a sign-in credential — the external identity binding",
|
||||
oidcSub: "a sign-in credential — the external identity binding",
|
||||
createdAt: "when the account was created; the account is preserved",
|
||||
previewFeaturesJson:
|
||||
"which preview features the person opted into — how the interface is shown, not record content",
|
||||
updatedAt: "row bookkeeping maintained by Prisma",
|
||||
managedProfileAt:
|
||||
"whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.",
|
||||
|
||||
@@ -111,6 +111,21 @@ describe("admitAccountSettings", () => {
|
||||
expect(admitAccountSettings({}, ctx)).toEqual({ data: {}, refused: [] });
|
||||
});
|
||||
|
||||
it("accepts and ignores the retired preview-feature opt-ins of an older file", async () => {
|
||||
// Written by v1.44 and v1.45, whose column v1.46 dropped. The file must
|
||||
// still restore, so the field is neither refused nor written.
|
||||
const { accountSettingsBackupSchema } =
|
||||
await import("@/lib/export/account-settings-backup-schema");
|
||||
const entry = accountSettingsBackupSchema.parse({
|
||||
timezone: "Europe/Berlin",
|
||||
previewFeaturesJson: { topics: true },
|
||||
});
|
||||
expect(admitAccountSettings(entry, ctx)).toEqual({
|
||||
data: { timezone: "Europe/Berlin" },
|
||||
refused: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a language, unit or time zone this release does not know", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{
|
||||
|
||||
@@ -66,6 +66,9 @@ export const accountSettingsBackupSchema = z
|
||||
moodTagLayoutJson: z.unknown().optional(),
|
||||
documentsLayoutJson: z.unknown().optional(),
|
||||
moduleListLayoutJson: z.unknown().optional(),
|
||||
// Retired in v1.46.0 with its column (the preview-feature opt-ins). A file
|
||||
// written by v1.44 or v1.45 still carries it; it is accepted and ignored,
|
||||
// because no codec reads it, so such a file restores everything else.
|
||||
previewFeaturesJson: z.unknown().optional(),
|
||||
activityGoalsJson: z.unknown().optional(),
|
||||
reportSelectionJson: z.unknown().optional(),
|
||||
|
||||
@@ -539,7 +539,6 @@ const ACCOUNT_SETTING_CODECS = {
|
||||
moodTagLayoutJson: json("moodTagLayoutJson"),
|
||||
documentsLayoutJson: json("documentsLayoutJson"),
|
||||
moduleListLayoutJson: json("moduleListLayoutJson"),
|
||||
previewFeaturesJson: json("previewFeaturesJson"),
|
||||
activityGoalsJson: activityGoals,
|
||||
reportSelectionJson: json("reportSelectionJson"),
|
||||
globalExcludedInjectionSites: excludedSites,
|
||||
|
||||
@@ -160,6 +160,14 @@ export const RETIRED_ROUTES: readonly RetiredRoute[] = [
|
||||
"Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is simply part of the medication page now and needs no preference read.",
|
||||
methods: ["GET", "POST", "DELETE"],
|
||||
},
|
||||
{
|
||||
path: "/api/settings/preview-features",
|
||||
removedIn: "1.46.0",
|
||||
replacedBy: null,
|
||||
reason:
|
||||
"Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no preview is gated per account.",
|
||||
methods: ["GET", "PATCH"],
|
||||
},
|
||||
];
|
||||
|
||||
const BY_PATH: ReadonlyMap<string, RetiredRoute> = new Map(
|
||||
|
||||
@@ -27,10 +27,7 @@ import {
|
||||
MODULE_LIST_VIEWS,
|
||||
MODULE_SORT_DIRS,
|
||||
} from "@/lib/settings/module-list-layout";
|
||||
import {
|
||||
moduleListLayoutPutSchema,
|
||||
previewFeaturesPatchSchema,
|
||||
} from "@/lib/validations/settings-prefs";
|
||||
import { moduleListLayoutPutSchema } from "@/lib/validations/settings-prefs";
|
||||
|
||||
const moduleListPrefsEntry = z
|
||||
.object({
|
||||
@@ -72,20 +69,6 @@ const moduleListLayoutSchema = z
|
||||
"Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
|
||||
});
|
||||
|
||||
const previewFeaturesSchema = z
|
||||
.object({
|
||||
topics: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Topic overview pages above the single-metric Insights pages. Off by default.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "PreviewFeatures",
|
||||
description:
|
||||
"Per-user opt-in to preview features, one boolean per flag, every flag off by default.",
|
||||
});
|
||||
|
||||
// v1.16.11 — the one threshold read every dose-status consumer makes
|
||||
// (cards, table, take-all-due derivation). `lateMinutes` /
|
||||
// `missedMinutes` are the operator-level singleton; the low-stock
|
||||
@@ -970,57 +953,6 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/settings/preview-features": {
|
||||
get: {
|
||||
tags: ["Meta"],
|
||||
summary: "Get the preview-feature opt-ins",
|
||||
description: "Every flag, off unless the caller switched it on.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opt-ins.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
previewFeaturesSchema,
|
||||
"PreviewFeaturesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Meta"],
|
||||
summary: "Switch preview features on or off",
|
||||
description:
|
||||
"Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: previewFeaturesPatchSchema.meta({
|
||||
id: "PreviewFeaturesPatchBody",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opt-ins as stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
previewFeaturesSchema,
|
||||
"PreviewFeaturesSavedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/settings/privacy-summary": {
|
||||
get: {
|
||||
tags: ["Meta"],
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_PREVIEW_FEATURES,
|
||||
resolvePreviewFeatures,
|
||||
} from "../preview-features";
|
||||
|
||||
describe("resolvePreviewFeatures", () => {
|
||||
it("is all off by default", () => {
|
||||
expect(DEFAULT_PREVIEW_FEATURES).toEqual({ topics: false });
|
||||
expect(resolvePreviewFeatures(null)).toEqual({ topics: false });
|
||||
});
|
||||
|
||||
it("reads a flag only on an explicit true", () => {
|
||||
expect(resolvePreviewFeatures({ topics: true })).toEqual({ topics: true });
|
||||
expect(resolvePreviewFeatures({ topics: "true" })).toEqual({
|
||||
topics: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops unknown keys", () => {
|
||||
expect(resolvePreviewFeatures({ topics: true, retired: true })).toEqual({
|
||||
topics: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Per-user opt-in to preview features, persisted in `User.previewFeaturesJson`.
|
||||
*
|
||||
* One boolean per flag, every flag off by default. A preview feature is
|
||||
* something finished enough to try and not yet the default; switching one on
|
||||
* adds it beside what is there and never replaces an existing page.
|
||||
*
|
||||
* Flags:
|
||||
* - `topics`: topic overview pages above the single-metric Insights pages.
|
||||
* Stored, served by `/api/settings/preview-features` and carried by the
|
||||
* backup. The Settings > System switch is not listed until the topic pages
|
||||
* ship, because nothing reads the flag yet.
|
||||
*
|
||||
* Unknown keys in a stored blob are dropped on read, so a flag retired later
|
||||
* disappears cleanly and a malformed blob reads as everything off.
|
||||
*/
|
||||
|
||||
export const PREVIEW_FEATURE_KEYS = ["topics"] as const;
|
||||
export type PreviewFeatureKey = (typeof PREVIEW_FEATURE_KEYS)[number];
|
||||
|
||||
export type PreviewFeatures = Record<PreviewFeatureKey, boolean>;
|
||||
|
||||
export const DEFAULT_PREVIEW_FEATURES: PreviewFeatures = { topics: false };
|
||||
|
||||
export function resolvePreviewFeatures(raw: unknown): PreviewFeatures {
|
||||
const out: PreviewFeatures = { ...DEFAULT_PREVIEW_FEATURES };
|
||||
if (raw === null || raw === undefined || typeof raw !== "object") return out;
|
||||
const blob = raw as Record<string, unknown>;
|
||||
for (const key of PREVIEW_FEATURE_KEYS) {
|
||||
if (blob[key] === true) out[key] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -114,10 +114,6 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// pieces into one coherent privacy pane. Sits next to `advanced` (the
|
||||
// destructive-action home) and before `about`.
|
||||
"privacy",
|
||||
// v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not
|
||||
// listed until the first preview it switches, the topic pages, ships: a
|
||||
// switch that changes nothing is a promise the page cannot keep. The stored
|
||||
// flag, `/api/settings/preview-features` and its backup round-trip stay.
|
||||
"about",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Request schemas for the per-user Settings preferences that are not part of
|
||||
* the account payload: the list presentation of the labs, illness and
|
||||
* checkups lists, and the preview-feature opt-ins. Shared by the routes and
|
||||
* the OpenAPI registry so the wire contract has one source.
|
||||
* checkups lists. Shared by the route and the OpenAPI registry so the wire
|
||||
* contract has one source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -26,12 +26,3 @@ export const moduleListLayoutPutSchema = z
|
||||
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const previewFeaturesPatchSchema = z
|
||||
.object({
|
||||
topics: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((body) => Object.keys(body).length > 0, {
|
||||
message: "Name at least one preview feature.",
|
||||
});
|
||||
@@ -2660,7 +2660,6 @@
|
||||
"src/lib/search/__tests__/match.test.ts": 0,
|
||||
"src/lib/search/__tests__/search-records.test.ts": 0,
|
||||
"src/lib/settings/__tests__/module-list-layout.test.ts": 0,
|
||||
"src/lib/settings/__tests__/preview-features.test.ts": 0,
|
||||
"src/lib/sharing/__tests__/account-access-parse.test.ts": 0,
|
||||
"src/lib/sharing/__tests__/account-access.test.ts": 0,
|
||||
"src/lib/sharing/__tests__/account-label.test.ts": 0,
|
||||
|
||||
@@ -124,9 +124,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -2566,9 +2563,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -63,9 +63,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -1725,9 +1722,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"symptomDefinitions": 2,
|
||||
"lifeEvents": 3
|
||||
},
|
||||
"accountSettingsApplied": 62,
|
||||
"accountSettingsApplied": 61,
|
||||
"skipped": {
|
||||
"catalogueKeys": [],
|
||||
"links": 0
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"symptomDefinitions": 0,
|
||||
"lifeEvents": 0
|
||||
},
|
||||
"accountSettingsApplied": 62,
|
||||
"accountSettingsApplied": 61,
|
||||
"skipped": {
|
||||
"catalogueKeys": [],
|
||||
"links": 0
|
||||
@@ -2759,9 +2759,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -3029,9 +3026,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
"symptomDefinitions": 0,
|
||||
"lifeEvents": 0
|
||||
},
|
||||
"accountSettingsApplied": 62,
|
||||
"accountSettingsApplied": 61,
|
||||
"skipped": {
|
||||
"catalogueKeys": [
|
||||
{
|
||||
@@ -2254,9 +2254,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
@@ -2463,9 +2460,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -894,7 +894,6 @@
|
||||
"documentsLayoutJson": null,
|
||||
"moodTagLayoutJson": null,
|
||||
"moduleListLayoutJson": null,
|
||||
"previewFeaturesJson": null,
|
||||
"activityGoalsJson": null,
|
||||
"glucoseUnit": null,
|
||||
"hasDiabetes": false,
|
||||
|
||||
@@ -1027,7 +1027,6 @@
|
||||
"documentsLayoutJson": null,
|
||||
"moodTagLayoutJson": null,
|
||||
"moduleListLayoutJson": null,
|
||||
"previewFeaturesJson": null,
|
||||
"activityGoalsJson": null,
|
||||
"glucoseUnit": null,
|
||||
"hasDiabetes": false,
|
||||
@@ -1247,7 +1246,6 @@
|
||||
"moodTagLayoutJson": null,
|
||||
"documentsLayoutJson": null,
|
||||
"moduleListLayoutJson": null,
|
||||
"previewFeaturesJson": null,
|
||||
"activityGoalsJson": null,
|
||||
"reportSelectionJson": null,
|
||||
"globalExcludedInjectionSites": [],
|
||||
|
||||
@@ -2374,9 +2374,6 @@
|
||||
"moduleListLayoutJson": {
|
||||
"roundTrip": "rt-User-moduleListLayoutJson"
|
||||
},
|
||||
"previewFeaturesJson": {
|
||||
"roundTrip": "rt-User-previewFeaturesJson"
|
||||
},
|
||||
"activityGoalsJson": {
|
||||
"stepsPerMonth": 250000,
|
||||
"workoutsPerWeek": 4
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
/**
|
||||
* The two per-user Settings preferences that live outside the account
|
||||
* payload, end to end against Postgres:
|
||||
*
|
||||
* - `/api/settings/module-list-layout`: defaults without a write, one list
|
||||
* changed per PUT with preserve-when-absent fields and untouched neighbours,
|
||||
* the multi-issue 422 for unknown values, and the column carrying only what
|
||||
* was chosen.
|
||||
* - `/api/settings/preview-features`: all off by default, PATCH keeps the
|
||||
* flags it does not name, an unknown flag or an empty body is refused.
|
||||
* The per-user list layout, a Settings preference that lives outside the
|
||||
* account payload, end to end against Postgres:
|
||||
* `/api/settings/module-list-layout` answers defaults without a write,
|
||||
* changes one list per PUT with preserve-when-absent fields and untouched
|
||||
* neighbours, refuses unknown values with the multi-issue 422, and stores
|
||||
* only what was chosen.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -173,51 +170,8 @@ describe("/api/settings/module-list-layout — integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/settings/preview-features — integration", () => {
|
||||
const URL = "http://localhost/api/settings/preview-features";
|
||||
|
||||
it("is all off by default and keeps a flag once switched on", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { GET, PATCH } =
|
||||
await import("@/app/api/settings/preview-features/route");
|
||||
|
||||
const before = await (GET as Handler)(new Request(URL));
|
||||
expect(((await before.json()) as { data: unknown }).data).toEqual({
|
||||
topics: false,
|
||||
});
|
||||
|
||||
const on = await (PATCH as Handler)(json("PATCH", URL, { topics: true }));
|
||||
expect(on.status).toBe(200);
|
||||
expect(((await on.json()) as { data: unknown }).data).toEqual({
|
||||
topics: true,
|
||||
});
|
||||
|
||||
const after = await (GET as Handler)(new Request(URL));
|
||||
expect(((await after.json()) as { data: unknown }).data).toEqual({
|
||||
topics: true,
|
||||
});
|
||||
const row = await getPrismaClient().user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { previewFeaturesJson: true },
|
||||
});
|
||||
expect(row?.previewFeaturesJson).toEqual({ topics: true });
|
||||
});
|
||||
|
||||
it("refuses an unknown flag and an empty body", async () => {
|
||||
await seedUser();
|
||||
const { PATCH } = await import("@/app/api/settings/preview-features/route");
|
||||
const unknown = await (PATCH as Handler)(
|
||||
json("PATCH", URL, { topics: true, darkMagic: true }),
|
||||
);
|
||||
expect(unknown.status).toBe(422);
|
||||
const empty = await (PATCH as Handler)(json("PATCH", URL, {}));
|
||||
expect(empty.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings presentation follows the person, also while acting for a record", () => {
|
||||
const LAYOUT = "http://localhost/api/settings/module-list-layout";
|
||||
const PREVIEW = "http://localhost/api/settings/preview-features";
|
||||
|
||||
async function actingForAnotherRecord() {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -251,10 +205,9 @@ describe("settings presentation follows the person, also while acting for a reco
|
||||
return { ownerId: owner.id, actorId: actor.id };
|
||||
}
|
||||
|
||||
it("reads and saves the actor's own layout and preview switch", async () => {
|
||||
it("reads and saves the actor's own layout", async () => {
|
||||
const { ownerId, actorId } = await actingForAnotherRecord();
|
||||
const layout = await import("@/app/api/settings/module-list-layout/route");
|
||||
const preview = await import("@/app/api/settings/preview-features/route");
|
||||
|
||||
expect((await (layout.GET as Handler)(new Request(LAYOUT))).status).toBe(
|
||||
200,
|
||||
@@ -263,20 +216,12 @@ describe("settings presentation follows the person, also while acting for a reco
|
||||
json("PUT", LAYOUT, { version: 1, list: "labs", view: "cards" }),
|
||||
);
|
||||
expect(put.status).toBe(200);
|
||||
expect((await (preview.GET as Handler)(new Request(PREVIEW))).status).toBe(
|
||||
200,
|
||||
);
|
||||
const patch = await (preview.PATCH as Handler)(
|
||||
json("PATCH", PREVIEW, { topics: true }),
|
||||
);
|
||||
expect(patch.status).toBe(200);
|
||||
|
||||
const rows = await getPrismaClient().user.findMany({
|
||||
where: { id: { in: [ownerId, actorId] } },
|
||||
select: {
|
||||
id: true,
|
||||
moduleListLayoutJson: true,
|
||||
previewFeaturesJson: true,
|
||||
},
|
||||
});
|
||||
const actor = rows.find((r) => r.id === actorId)!;
|
||||
@@ -284,9 +229,7 @@ describe("settings presentation follows the person, also while acting for a reco
|
||||
expect(actor.moduleListLayoutJson).toMatchObject({
|
||||
labs: { view: "cards" },
|
||||
});
|
||||
expect(actor.previewFeaturesJson).toEqual({ topics: true });
|
||||
expect(owner.moduleListLayoutJson).toBeNull();
|
||||
expect(owner.previewFeaturesJson).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ import * as medicationLayout from "@/app/api/medications/layout/route";
|
||||
import * as moodTagLayout from "@/app/api/mood/tags/layout/route";
|
||||
import * as documentsLayout from "@/app/api/documents/inbound/layout/route";
|
||||
import * as moduleListLayout from "@/app/api/settings/module-list-layout/route";
|
||||
import * as previewFeatures from "@/app/api/settings/preview-features/route";
|
||||
import * as activityGoals from "@/app/api/activity/goals/route";
|
||||
import * as healthKit from "@/app/api/integrations/healthkit/route";
|
||||
import { POST as exportHealthRecord } from "@/app/api/export/health-record/route";
|
||||
@@ -888,21 +887,6 @@ function entries(user: Pick<User, "id">): Entry[] {
|
||||
},
|
||||
expected: ["list", "alphaAsc"],
|
||||
},
|
||||
{
|
||||
columns: ["previewFeaturesJson"],
|
||||
write: {
|
||||
handler: previewFeatures.PATCH,
|
||||
method: "PATCH",
|
||||
route: "/api/settings/preview-features",
|
||||
body: { topics: true },
|
||||
},
|
||||
read: get(
|
||||
previewFeatures.GET,
|
||||
"/api/settings/preview-features",
|
||||
"topics",
|
||||
),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
columns: ["activityGoalsJson"],
|
||||
write: {
|
||||
|
||||
Reference in new issue
Block a user