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

@@ -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);
});