diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index c76bededb..52ce1b605 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -1005,6 +1005,7 @@ export class AutumnInt { operations?: Operations | null; retry_failed?: boolean; no_billing_changes?: boolean; + archived?: boolean; }; }): Promise => { const data = await this.post(`/migrations.update`, params); diff --git a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts index ce5dbe71e..7e119d6a9 100644 --- a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts @@ -13,6 +13,7 @@ const PatchMigrationBody = z.object({ operations: OperationsSchema.nullable().optional(), retry_failed: z.boolean().optional(), no_billing_changes: z.boolean().nullable().optional(), + archived: z.boolean().optional(), }), }); diff --git a/server/src/internal/migrations/v2/repos/deleteMigration.ts b/server/src/internal/migrations/v2/repos/deleteMigration.ts index cfdf3beb2..7e7553948 100644 --- a/server/src/internal/migrations/v2/repos/deleteMigration.ts +++ b/server/src/internal/migrations/v2/repos/deleteMigration.ts @@ -1,4 +1,11 @@ -import { type Migration, migrationItemRuns, migrations } from "@autumn/shared"; +import { + ErrCode, + type Migration, + MigrationItemKind, + migrationItemRuns, + migrations, + RecaseError, +} from "@autumn/shared"; import { and, eq } from "drizzle-orm"; import type { RepoContext } from "@/db/repoContext.js"; @@ -10,15 +17,44 @@ export const deleteMigration = async ({ ctx: RepoContext; id: string; }): Promise => { - const [row] = await ctx.db - .delete(migrations) + const [migration] = await ctx.db + .select() + .from(migrations) .where( and( eq(migrations.id, id), eq(migrations.org_id, ctx.org.id), eq(migrations.env, ctx.env), + eq(migrations.archived, false), ), ) + .limit(1); + + if (!migration) return null; + + const [customerRun] = await ctx.db + .select({ id: migrationItemRuns.migration_item_run_id }) + .from(migrationItemRuns) + .where( + and( + eq(migrationItemRuns.migration_internal_id, migration.internal_id), + eq(migrationItemRuns.item_kind, MigrationItemKind.Customer), + eq(migrationItemRuns.dry_run, false), + ), + ) + .limit(1); + + if (customerRun) { + throw new RecaseError({ + message: `Migration ${id} has customer run history and cannot be deleted. Archive it instead.`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const [row] = await ctx.db + .delete(migrations) + .where(eq(migrations.internal_id, migration.internal_id)) .returning(); if (row) { await ctx.db diff --git a/server/src/internal/migrations/v2/repos/findMigration.ts b/server/src/internal/migrations/v2/repos/findMigration.ts index 96e02d1d5..4c3e9cbfc 100644 --- a/server/src/internal/migrations/v2/repos/findMigration.ts +++ b/server/src/internal/migrations/v2/repos/findMigration.ts @@ -24,6 +24,7 @@ export const findMigration = async ({ and( eq(m.org_id, ctx.org.id), eq(m.env, ctx.env), + eq(m.archived, false), id !== undefined ? eq(m.id, id) : eq(m.internal_id, internalId!), ), }); diff --git a/server/src/internal/migrations/v2/repos/insertMigration.ts b/server/src/internal/migrations/v2/repos/insertMigration.ts index 0084c0ea0..17a569017 100644 --- a/server/src/internal/migrations/v2/repos/insertMigration.ts +++ b/server/src/internal/migrations/v2/repos/insertMigration.ts @@ -30,6 +30,7 @@ export const insertMigration = async ({ operations: insert.operations ?? null, no_billing_changes: insert.no_billing_changes ?? null, retry_failed: false, + archived: false, created_at: Date.now(), updated_at: null, }; diff --git a/server/src/internal/migrations/v2/repos/updateMigration.ts b/server/src/internal/migrations/v2/repos/updateMigration.ts index fcbc301a4..a5f395767 100644 --- a/server/src/internal/migrations/v2/repos/updateMigration.ts +++ b/server/src/internal/migrations/v2/repos/updateMigration.ts @@ -28,6 +28,7 @@ export const updateMigration = async ({ | "prepared_state" | "retry_failed" | "no_billing_changes" + | "archived" > >; }): Promise => { @@ -39,6 +40,7 @@ export const updateMigration = async ({ eq(migrations.id, id), eq(migrations.org_id, ctx.org.id), eq(migrations.env, ctx.env), + eq(migrations.archived, false), ), ) .returning(); diff --git a/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts b/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts index 078a5c9d6..5a4d1b5c2 100644 --- a/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts +++ b/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts @@ -1,16 +1,31 @@ +import { expect, test } from "bun:test"; +import { + ErrCode, + MigrationItemKind, + MigrationItemRunStatus, + migrations, +} from "@autumn/shared"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { CusService } from "@/internal/customers/CusService.js"; +import { migrationItemRunRepo } from "@/internal/migrations/v2/repos/index.js"; + /** * TDD coverage for migration draft CRUD used by the dashboard. * - * Red-failure mode: PATCH strips `updates.no_billing_changes`, so the - * saved migration does not match the dashboard toggle. - * - * Green-success criteria: PATCH persists `no_billing_changes` like create. + * Contract under test: + * New fields: + * - migrations.archived: boolean, default false. + * New behaviors: + * - PATCH /migrations.update accepts updates.archived. + * - POST /migrations.delete hard-deletes migrations with no customer runs. + * - POST /migrations.delete rejects migrations with customer run history. + * Side effects: + * - Rejected deletes keep the migration row and run history unchanged. */ -import { expect, test } from "bun:test"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - test.concurrent( `${chalk.yellowBright("migrations.update: persists no_billing_changes from dashboard PATCH")}`, async () => { @@ -32,3 +47,101 @@ test.concurrent( expect(updated.no_billing_changes).toBe(true); }, ); + +test.concurrent( + `${chalk.yellowBright("migrations.delete: hard deletes drafts that have no customer runs")}`, + async () => { + const customerId = "migrations-delete-draft"; + const migrationId = `${customerId}-mig`; + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [s.customer()], + actions: [], + }); + + await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId }); + const deleted = await autumnV2_2.migrationsV2.delete({ id: migrationId }); + const list = await autumnV2_2.migrationsV2.list(); + + expect(deleted.id).toBe(migrationId); + expect(deleted.archived).toBe(false); + expect(list.list.some((migration) => migration.id === migrationId)).toBe(false); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations.delete: rejects migrations that have customer runs")}`, + async () => { + const customerId = `migrations-delete-reject-${Date.now()}`; + const migrationId = `${customerId}-mig`; + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer()], + actions: [], + }); + + const migration = await autumnV2_2.migrationsV2.deleteAndCreate({ + id: migrationId, + }); + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + if (!customer) throw new Error(`Expected customer ${customerId}`); + + await migrationItemRunRepo.markSucceeded({ + ctx, + migrationInternalId: migration.internal_id, + itemKind: MigrationItemKind.Customer, + itemId: customer.internal_id, + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + errMessage: "has customer run history and cannot be deleted", + func: () => autumnV2_2.migrationsV2.delete({ id: migrationId }), + }); + const list = await autumnV2_2.migrationsV2.list(); + const preserved = list.list.find((candidate) => candidate.id === migrationId); + + expect(preserved).toMatchObject({ id: migrationId, archived: false }); + expect( + await migrationItemRunRepo.getCustomer({ + ctx, + migrationInternalId: migration.internal_id, + internalCustomerId: customer.internal_id, + }), + ).toMatchObject({ status: MigrationItemRunStatus.Succeeded }); + }, +); + +test.concurrent( + `${chalk.yellowBright("migrations.update: persists archived from dashboard PATCH")}`, + async () => { + const customerId = `migrations-update-archived-${Date.now()}`; + const migrationId = `${customerId}-mig`; + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [s.customer()], + actions: [], + }); + + await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId }); + const updated = await autumnV2_2.migrationsV2.update({ + id: migrationId, + updates: { archived: true }, + }); + const [row] = await ctx.db + .select() + .from(migrations) + .where(eq(migrations.id, migrationId)); + + expect(updated.archived).toBe(true); + expect(row?.archived).toBe(true); + }, +); diff --git a/shared/drizzle/0001_talented_thor.sql b/shared/drizzle/0001_talented_thor.sql new file mode 100644 index 000000000..0fe098099 --- /dev/null +++ b/shared/drizzle/0001_talented_thor.sql @@ -0,0 +1,25 @@ +CREATE TABLE "passkey" ( + "id" text PRIMARY KEY NOT NULL, + "name" text, + "public_key" text NOT NULL, + "user_id" text NOT NULL, + "credential_id" text NOT NULL, + "counter" integer NOT NULL, + "device_type" text NOT NULL, + "backed_up" boolean NOT NULL, + "transports" text, + "created_at" timestamp with time zone, + "aaguid" text, + CONSTRAINT "passkey_credential_id_unique" UNIQUE("credential_id") +); +--> statement-breakpoint +ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint +ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint +CREATE INDEX "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint +CREATE INDEX "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL; \ No newline at end of file diff --git a/shared/models/migrationV2Models/migrationTable.ts b/shared/models/migrationV2Models/migrationTable.ts index 0124dd10c..ed7d40150 100644 --- a/shared/models/migrationV2Models/migrationTable.ts +++ b/shared/models/migrationV2Models/migrationTable.ts @@ -47,6 +47,7 @@ export const migrations = pgTable( // `false` → force Stripe path even when inference would say DB-only. no_billing_changes: boolean(), retry_failed: boolean().notNull().default(false), + archived: boolean().notNull().default(false), created_at: numeric({ mode: "number" }).notNull(), updated_at: numeric({ mode: "number" }),