From 2ae23f4ca806ba1eae5b2ede80586cdd674cbdf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:44:15 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 144 +++++++----------- .../migration.sql | 27 ++++ prisma/schema.prisma | 5 - src/__tests__/sharing-surface-guard.test.ts | 7 +- .../api/settings/preview-features/route.ts | 80 ---------- src/lib/data-wipe/wipe-plan.ts | 2 - .../__tests__/account-settings-backup.test.ts | 15 ++ .../export/account-settings-backup-schema.ts | 3 + src/lib/export/account-settings-backup.ts | 1 - src/lib/http/retired-routes.ts | 8 + src/lib/openapi/routes/settings.ts | 70 +-------- .../__tests__/preview-features.test.ts | 26 ---- src/lib/settings/preview-features.ts | 33 ---- src/lib/settings/section-slugs.ts | 4 - src/lib/validations/settings-prefs.ts | 13 +- tests/durations/unit.json | 1 - .../export-disaster-recovery.json | 6 - .../backup-parity/export-portable.json | 6 - .../restore-disaster-recovery-over-live.json | 2 +- .../restore-disaster-recovery.json | 8 +- .../backup-parity/restore-portable.json | 8 +- .../backup-parity/restore-skips.json | 1 - .../backup-parity/restore-version-1.json | 2 - .../backup-parity/seeded-account.json | 3 - .../integration/settings-prefs-routes.test.ts | 71 +-------- tests/integration/settings-round-trip.test.ts | 16 -- 26 files changed, 125 insertions(+), 437 deletions(-) create mode 100644 prisma/migrations/0485_drop_user_preview_features/migration.sql delete mode 100644 src/app/api/settings/preview-features/route.ts delete mode 100644 src/lib/settings/__tests__/preview-features.test.ts delete mode 100644 src/lib/settings/preview-features.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d9ce7210b..7c255e133 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/prisma/migrations/0485_drop_user_preview_features/migration.sql b/prisma/migrations/0485_drop_user_preview_features/migration.sql new file mode 100644 index 000000000..93c04cbf8 --- /dev/null +++ b/prisma/migrations/0485_drop_user_preview_features/migration.sql @@ -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$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ef090a1d..b20f8c070 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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": [""], "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 diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e8e67147d..3cb450621 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2163,8 +2163,6 @@ const ACTOR_ROUTES: Record = { "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 = { * 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` — diff --git a/src/app/api/settings/preview-features/route.ts b/src/app/api/settings/preview-features/route.ts deleted file mode 100644 index 8a6859f9a..000000000 --- a/src/app/api/settings/preview-features/route.ts +++ /dev/null @@ -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 = prisma, -): Promise { - 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); -}); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 316c278d5..92cead915 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -516,8 +516,6 @@ export const USER_KEPT_FIELDS: Readonly> = { 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.", diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index 4784869d2..27bf42b17 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -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( { diff --git a/src/lib/export/account-settings-backup-schema.ts b/src/lib/export/account-settings-backup-schema.ts index ea418f938..c8d2269a0 100644 --- a/src/lib/export/account-settings-backup-schema.ts +++ b/src/lib/export/account-settings-backup-schema.ts @@ -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(), diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 235827f93..3a2490a99 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -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, diff --git a/src/lib/http/retired-routes.ts b/src/lib/http/retired-routes.ts index 8ed40ff6d..22580e55c 100644 --- a/src/lib/http/retired-routes.ts +++ b/src/lib/http/retired-routes.ts @@ -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 = new Map( diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5b1ac7c2b..2a269fcb5 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -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 = { }, }, }, - "/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"], diff --git a/src/lib/settings/__tests__/preview-features.test.ts b/src/lib/settings/__tests__/preview-features.test.ts deleted file mode 100644 index df5251b6d..000000000 --- a/src/lib/settings/__tests__/preview-features.test.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/src/lib/settings/preview-features.ts b/src/lib/settings/preview-features.ts deleted file mode 100644 index 514b55fd1..000000000 --- a/src/lib/settings/preview-features.ts +++ /dev/null @@ -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; - -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; - for (const key of PREVIEW_FEATURE_KEYS) { - if (blob[key] === true) out[key] = true; - } - return out; -} diff --git a/src/lib/settings/section-slugs.ts b/src/lib/settings/section-slugs.ts index b6f69ed40..843203284 100644 --- a/src/lib/settings/section-slugs.ts +++ b/src/lib/settings/section-slugs.ts @@ -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; diff --git a/src/lib/validations/settings-prefs.ts b/src/lib/validations/settings-prefs.ts index c3cc82112..26ab7745f 100644 --- a/src/lib/validations/settings-prefs.ts +++ b/src/lib/validations/settings-prefs.ts @@ -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.", - }); diff --git a/tests/durations/unit.json b/tests/durations/unit.json index 58db3c2d0..f2898cc5f 100644 --- a/tests/durations/unit.json +++ b/tests/durations/unit.json @@ -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, diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index c741d2b27..9980c3b21 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -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 diff --git a/tests/integration/__golden__/backup-parity/export-portable.json b/tests/integration/__golden__/backup-parity/export-portable.json index 436afeeb9..5b89d119e 100644 --- a/tests/integration/__golden__/backup-parity/export-portable.json +++ b/tests/integration/__golden__/backup-parity/export-portable.json @@ -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 diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json index 77fa33c88..c9d652853 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json @@ -332,7 +332,7 @@ "symptomDefinitions": 2, "lifeEvents": 3 }, - "accountSettingsApplied": 62, + "accountSettingsApplied": 61, "skipped": { "catalogueKeys": [], "links": 0 diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index a8d078b9b..01506ddb6 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -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 diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index 68ae26e99..815f639ac 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -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 diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 1dec697bd..8879bf3f8 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -894,7 +894,6 @@ "documentsLayoutJson": null, "moodTagLayoutJson": null, "moduleListLayoutJson": null, - "previewFeaturesJson": null, "activityGoalsJson": null, "glucoseUnit": null, "hasDiabetes": false, diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index 68895abe8..ec1685ce6 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -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": [], diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index cd3618a3f..7758138c3 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -2374,9 +2374,6 @@ "moduleListLayoutJson": { "roundTrip": "rt-User-moduleListLayoutJson" }, - "previewFeaturesJson": { - "roundTrip": "rt-User-previewFeaturesJson" - }, "activityGoalsJson": { "stepsPerMonth": 250000, "workoutsPerWeek": 4 diff --git a/tests/integration/settings-prefs-routes.test.ts b/tests/integration/settings-prefs-routes.test.ts index 61aaa5a00..2a18cdf04 100644 --- a/tests/integration/settings-prefs-routes.test.ts +++ b/tests/integration/settings-prefs-routes.test.ts @@ -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(); }); }); diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts index 8460aa4da..0045ba1a8 100644 --- a/tests/integration/settings-round-trip.test.ts +++ b/tests/integration/settings-round-trip.test.ts @@ -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): 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: {