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:
Marc-André Bombeck committed 2026-10-25 00:44:15 +02:00
1 parent f3b9329fb2
commit 2ae23f4ca8
26 files changed
+125 -437

No files matched your search

+4 -3
View File
@@ -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);
});
-2
View File
@@ -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,
+8
View File
@@ -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(
+1 -69
View File
@@ -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,
});
});
});
-33
View File
@@ -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;
}
-4
View File
@@ -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;
+2 -11
View File
@@ -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.",
});