stack/apps/backend/scripts/verify-data-integrity.ts
Konsti Wohlwend 8a77e07f19
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Rename offer to product, offer group to product catalog (#914)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- RECURSEML_SUMMARY:START -->
## High-level PR Summary
This PR implements a comprehensive renaming of "offer" to "product" and
"offer group" to "product catalog" throughout the codebase. The changes
include database migrations, schema updates, API compatibility layers,
function renames, and updates to client and server implementations.
Backwards compatibility is maintained through migration layers that
handle requests using the old terminology, translating them to the new
terminology before processing. The PR includes documentation of this
approach in CLAUDE-KNOWLEDGE.md. This rename affects multiple parts of
the system including the database schema, API endpoints, error types,
and SDK interfaces.

⏱️ Estimated Review Time: 1-3 hours

<details>
<summary>💡 Review Order Suggestion</summary>

| Order | File Path |
|-------|-----------|
| 1 |
`apps/backend/prisma/migrations/20250923191615_rename_offers_to_products/migration.sql`
|
| 2 |
`apps/backend/src/app/api/migrations/v2beta1/payments/purchases/offers-compat.ts`
|
| 3 |
`apps/backend/src/app/api/migrations/v2beta1/payments/purchases/create-purchase-url/route.ts`
|
| 4 |
`apps/backend/src/app/api/migrations/v2beta1/payments/purchases/validate-code/route.ts`
|
| 5 | `apps/backend/src/lib/payments.tsx` |
| 6 | `.claude/CLAUDE-KNOWLEDGE.md` |
| 7 | `packages/stack-shared/src/schema-fields.ts` |
| 8 | `packages/stack-shared/src/known-errors.tsx` |
| 9 | `packages/stack-shared/src/config/schema.ts` |
| 10 | `packages/template/src/lib/stack-app/customers/index.ts` |
| 11 |
`packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`
|
| 12 |
`packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts`
|
</details>



[![Need help? Join our
Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U)

<!-- RECURSEML_SUMMARY:END -->
<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Renames 'offer' to 'product' and 'offer group' to 'product catalog'
across the codebase, updating database schema, API endpoints, and
application logic for consistency and backward compatibility.
> 
>   - **Database**:
> - Rename columns `offer` to `product` and `offerId` to `productId` in
`OneTimePurchase` and `Subscription` tables in `migration.sql`.
>   - **API & Migrations**:
> - Update API endpoints to accept `product_id`/`product_inline` instead
of `offer_id`/`offer_inline`.
> - Add `v2beta5` compatibility layer to map legacy `offer` fields to
`product` equivalents.
>   - **Shared Schemas**:
> - Rename `offerSchema` to `productSchema` and related schemas in
`schema-fields.ts`.
>   - **Server Implementation**:
> - Update `createCheckoutUrl` method in `server-app-impl.ts` to use
`productId`/`InlineProduct`.
>   - **Tests**:
> - Update tests to reflect renaming in `backend-helpers.ts` and other
test files.
>   - **Miscellaneous**:
>     - Remove dummy data related to offers in `dummy-data.tsx`.
> - Update documentation and comments to reflect terminology changes.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for e3227bcbd2. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>

----


<!-- ELLIPSIS_HIDDEN -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Backwards-compatibility: legacy offer_id/offer_inline requests are
accepted, normalized, and routed to product-based handlers.

* **Refactor**
* Global rename from Offer/Group → Product/Catalog across UI, APIs,
types, client/server interfaces, and error codes.

* **Bug Fixes**
* Responses, webhooks and UI consistently surface product_display_name
and product-related metadata.

* **Documentation**
* Migration notes and docs updated to explain compatibility and
parameter changes.

* **Tests**
  * Unit and E2E suites updated to cover product/catalog flows.

* **Chores**
  * Database schema migration, seed and config updates applied.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Renames offers→products and groups→catalogs end-to-end (DB, APIs,
schemas, UI, SDK, docs), adding v2beta5 compatibility to accept legacy
offer fields while updating all internals.
> 
> - **Backend/DB**:
> - Prisma migration: rename `offer`/`offerId`→`product`/`productId` in
`OneTimePurchase` and `Subscription`.
> - Update Stripe webhook, purchase-session, and internal test-mode
flows to use `product*` metadata/fields.
> - **API & Migrations**:
>   - Latest endpoints now accept `product_id`/`product_inline`.
> - Add `v2beta5` compat layer mapping legacy `offer_id`/`offer_inline`
to product equivalents; responses alias conflicting products.
> - **Shared Schemas/Errors/Config**:
> - `offerSchema`→`productSchema`,
`inlineOfferSchema`→`inlineProductSchema`, prices/types renamed.
>   - KnownErrors renamed (e.g., `PRODUCT_DOES_NOT_EXIST`).
> - Config: `groups`→`catalogs`, defaults/migrations updated; improved
override validation messages; ID regex loosened; formatter tweaks; add
schema fuzzer tests.
> - **Payments Lib**:
> - Rename APIs and logic (`offers`→`products`, `groupId`→`catalogId`),
subscription and item-quantity computation updated.
> - **Dashboard/UI**:
> - Routes, dialogs, editors, tables, and code samples switched to
products/catalogs; removed offers dummy data.
> - **SDK/Template**:
> - Client/server `createCheckoutUrl` now uses
`productId`/`InlineProduct`.
> - **Tests/Docs/Utilities**:
>   - E2E and unit tests updated; add legacy (pre-rename) tests.
> - Docs and knowledge base revised; minor script tweaks (recent-first,
limits).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e6e20ecd72. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: BilalG1 <bg2002@gmail.com>
2025-10-04 02:28:28 -07:00

366 lines
13 KiB
TypeScript

import { PrismaClient } from "@prisma/client";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { deepPlainEquals, filterUndefined, omit } from "@stackframe/stack-shared/dist/utils/objects";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import fs from "fs";
const prismaClient = new PrismaClient();
const OUTPUT_FILE_PATH = "./verify-data-integrity-output.untracked.json";
type EndpointOutput = {
status: number,
responseJson: any,
};
type OutputData = Record<string, EndpointOutput[]>;
let targetOutputData: OutputData | undefined = undefined;
const currentOutputData: OutputData = {};
async function main() {
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log("===================================================");
console.log("Welcome to verify-data-integrity.ts.");
console.log();
console.log("This script will ensure that the data in the");
console.log("database is not corrupted.");
console.log();
console.log("It will call the most important endpoints for");
console.log("each project and every user, and ensure that");
console.log("the status codes are what they should be.");
console.log();
console.log("It's a good idea to run this script on REPLICAS");
console.log("of the production database regularly (not the actual");
console.log("prod db!); it should never fail at any point in time.");
console.log();
console.log("");
console.log("\x1b[41mIMPORTANT\x1b[0m: This script may modify");
console.log("the database during its execution in all sorts of");
console.log("ways, so don't run it on production!");
console.log();
console.log("===================================================");
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log("Starting in 3 seconds...");
await wait(1000);
console.log("2...");
await wait(1000);
console.log("1...");
await wait(1000);
console.log();
console.log();
console.log();
console.log();
const numericArgs = process.argv.filter(arg => arg.match(/^[0-9]+$/)).map(arg => +arg);
const startAt = Math.max(0, (numericArgs[0] ?? 1) - 1);
const count = numericArgs[1] ?? Infinity;
const flags = process.argv.slice(1);
const skipUsers = flags.includes("--skip-users");
const shouldSaveOutput = flags.includes("--save-output");
const shouldVerifyOutput = flags.includes("--verify-output");
const shouldSkipNeon = flags.includes("--skip-neon");
const recentFirst = flags.includes("--recent-first");
if (shouldSaveOutput) {
console.log(`Will save output to ${OUTPUT_FILE_PATH}`);
}
if (shouldSkipNeon) {
console.log(`Will skip Neon projects.`);
}
if (shouldVerifyOutput) {
if (!fs.existsSync(OUTPUT_FILE_PATH)) {
throw new Error(`Cannot verify output: ${OUTPUT_FILE_PATH} does not exist`);
}
try {
targetOutputData = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH, 'utf8'));
// TODO next-release these are hacks for the migration, delete them
if (targetOutputData) {
targetOutputData["/api/v1/internal/projects/current"] = targetOutputData["/api/v1/internal/projects/current"].map(output => {
if ("config" in output.responseJson) {
delete output.responseJson.config.id;
output.responseJson.config.oauth_providers = output.responseJson.config.oauth_providers
.filter((provider: any) => provider.enabled)
.map((provider: any) => omit(provider, ["enabled"]));
}
return output;
});
}
console.log(`Loaded previous output data for verification`);
} catch (error) {
throw new Error(`Failed to parse output file: ${error}`);
}
}
const projects = await prismaClient.project.findMany({
select: {
id: true,
displayName: true,
description: true,
},
orderBy: recentFirst ? {
updatedAt: "desc",
} : {
id: "asc",
},
});
console.log(`Found ${projects.length} projects, iterating over them.`);
if (startAt !== 0) {
console.log(`Starting at project ${startAt}.`);
}
const maxUsersPerProject = 100;
const endAt = Math.min(startAt + count, projects.length);
for (let i = startAt; i < endAt; i++) {
const projectId = projects[i].id;
await recurse(`[project ${(i + 1) - startAt}/${endAt - startAt}] ${projectId} ${projects[i].displayName}`, async (recurse) => {
if (shouldSkipNeon && projects[i].description.includes("Neon")) {
return;
}
const [currentProject, users, projectPermissionDefinitions, teamPermissionDefinitions] = await Promise.all([
expectStatusCode(200, `/api/v1/internal/projects/current`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
expectStatusCode(200, `/api/v1/users?limit=${maxUsersPerProject}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
expectStatusCode(200, `/api/v1/project-permission-definitions`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
expectStatusCode(200, `/api/v1/team-permission-definitions`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
]);
if (!skipUsers) {
for (let j = 0; j < users.items.length; j++) {
const user = users.items[j];
await recurse(`[user ${j + 1}/${users.items.length}] ${user.display_name ?? user.primary_email}`, async (recurse) => {
// get user individually
await expectStatusCode(200, `/api/v1/users/${user.id}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
// list project permissions
const projectPermissions = await expectStatusCode(200, `/api/v1/project-permissions?user_id=${user.id}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
for (const projectPermission of projectPermissions.items) {
if (!projectPermissionDefinitions.items.some((p: any) => p.id === projectPermission.id)) {
throw new StackAssertionError(deindent`
Project permission ${projectPermission.id} not found in project permission definitions.
`);
}
}
// list teams
const teams = await expectStatusCode(200, `/api/v1/teams?user_id=${user.id}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
for (const team of teams.items) {
await recurse(`[team ${team.id}] ${team.name}`, async (recurse) => {
// list team permissions
const teamPermissions = await expectStatusCode(200, `/api/v1/team-permissions?team_id=${team.id}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
for (const teamPermission of teamPermissions.items) {
if (!teamPermissionDefinitions.items.some((p: any) => p.id === teamPermission.id)) {
throw new StackAssertionError(deindent`
Team permission ${teamPermission.id} not found in team permission definitions.
`);
}
}
});
}
});
}
}
});
}
if (targetOutputData && !deepPlainEquals(currentOutputData, targetOutputData)) {
throw new StackAssertionError(deindent`
Output data mismatch between final and target output data.
`);
}
if (shouldSaveOutput) {
fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(currentOutputData, null, 2));
console.log(`Output saved to ${OUTPUT_FILE_PATH}`);
}
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log("===================================================");
console.log("All good!");
console.log();
console.log("Goodbye.");
console.log("===================================================");
console.log();
console.log();
}
// eslint-disable-next-line no-restricted-syntax
main().catch((...args) => {
console.error();
console.error();
console.error(`\x1b[41mERROR\x1b[0m! Could not verify data integrity. See the error message for more details.`);
console.error(...args);
process.exit(1);
});
async function expectStatusCode(expectedStatusCode: number, endpoint: string, request: RequestInit) {
const apiUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
const response = await fetch(new URL(endpoint, apiUrl), {
...request,
headers: {
"x-stack-disable-artificial-development-delay": "yes",
"x-stack-development-disable-extended-logging": "yes",
...filterUndefined(request.headers ?? {}),
},
});
const responseText = await response.text();
if (response.status !== expectedStatusCode) {
throw new StackAssertionError(deindent`
Expected status code ${expectedStatusCode} but got ${response.status} for ${endpoint}:
${responseText}
`, { request, response });
}
const responseJson = JSON.parse(responseText);
const currentOutput: EndpointOutput = {
status: response.status,
responseJson,
};
appendOutputData(endpoint, currentOutput);
return responseJson;
}
function appendOutputData(endpoint: string, output: EndpointOutput) {
if (!(endpoint in currentOutputData)) {
currentOutputData[endpoint] = [];
}
const newLength = currentOutputData[endpoint].push(output);
if (targetOutputData) {
if (!(endpoint in targetOutputData)) {
throw new StackAssertionError(deindent`
Output data mismatch for endpoint ${endpoint}:
Expected ${endpoint} to be in targetOutputData, but it is not.
`, { endpoint });
}
if (targetOutputData[endpoint].length < newLength) {
throw new StackAssertionError(deindent`
Output data mismatch for endpoint ${endpoint}:
Expected ${targetOutputData[endpoint].length} outputs but got at least ${newLength}.
`, { endpoint });
}
if (!(deepPlainEquals(targetOutputData[endpoint][newLength - 1], output))) {
throw new StackAssertionError(deindent`
Output data mismatch for endpoint ${endpoint}:
Expected output[${JSON.stringify(endpoint)}][${newLength - 1}] to be:
${JSON.stringify(targetOutputData[endpoint][newLength - 1], null, 2)}
but got:
${JSON.stringify(output, null, 2)}.
`, { endpoint });
}
}
}
let lastProgress = performance.now() - 9999999999;
type RecurseFunction = (progressPrefix: string, inner: (recurse: RecurseFunction) => Promise<void>) => Promise<void>;
const _recurse = async (progressPrefix: string | ((...args: any[]) => void), inner: Parameters<RecurseFunction>[1]): Promise<void> => {
const progressFunc = typeof progressPrefix === "function" ? progressPrefix : (...args: any[]) => {
console.log(`${progressPrefix}`, ...args);
};
if (performance.now() - lastProgress > 1000) {
progressFunc();
lastProgress = performance.now();
}
try {
return await inner(
(progressPrefix, inner) => _recurse(
(...args) => progressFunc(progressPrefix, ...args),
inner,
),
);
} catch (error) {
progressFunc(`\x1b[41mERROR\x1b[0m!`);
throw error;
}
};
const recurse: RecurseFunction = _recurse;