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

-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.",
});