task-d.7.7
This commit is contained in:
19
src/app/api/setup/reseed/execute/route.ts
Normal file
19
src/app/api/setup/reseed/execute/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { executeReseed } from '@/features/setup/server/reseed.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireSystemRole('super_admin');
|
||||
const body = await request.json();
|
||||
const report = await executeReseed({ ...body, actorId: session.user.id });
|
||||
|
||||
return NextResponse.json(report, { status: report.executed ? 200 : 400 });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to execute setup reseed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/setup/reseed/preview/route.ts
Normal file
17
src/app/api/setup/reseed/preview/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { previewReseed } from '@/features/setup/server/reseed.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireSystemRole('super_admin');
|
||||
const body = await request.json();
|
||||
return NextResponse.json(await previewReseed(body));
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to preview setup reseed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
19
src/app/api/setup/reset/execute/route.ts
Normal file
19
src/app/api/setup/reset/execute/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { executeReset } from '@/features/setup/server/reset.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireSystemRole('super_admin');
|
||||
const body = await request.json();
|
||||
const report = await executeReset({ ...body, actorId: session.user.id });
|
||||
|
||||
return NextResponse.json(report, { status: report.executed ? 200 : 400 });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to execute setup reset' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/setup/reset/preview/route.ts
Normal file
17
src/app/api/setup/reset/preview/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { previewReset } from '@/features/setup/server/reset.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await requireSystemRole('super_admin');
|
||||
const body = await request.json();
|
||||
return NextResponse.json(await previewReset(body));
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to preview setup reset' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
114
src/features/setup/server/reseed.service.ts
Normal file
114
src/features/setup/server/reseed.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'server-only';
|
||||
|
||||
import { hashPreviewPayload, getEnvironmentBlockers, validateConfirmationToken, withToken } from './reset-guards';
|
||||
import { getConfirmationPhrase, RESEED_TARGETS } from './reset-scopes';
|
||||
import type { ReseedExecuteReport, ReseedPreviewReport, ReseedTarget } from './reset-report';
|
||||
|
||||
export interface ReseedPreviewInput {
|
||||
target: ReseedTarget;
|
||||
manifestId?: string | null;
|
||||
options?: {
|
||||
force?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReseedExecuteInput extends ReseedPreviewInput {
|
||||
previewHash: string;
|
||||
confirmationToken: string;
|
||||
confirmationPhrase: string;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
function isReseedTarget(value: string): value is ReseedTarget {
|
||||
return RESEED_TARGETS.includes(value as ReseedTarget);
|
||||
}
|
||||
|
||||
export async function previewReseed(input: ReseedPreviewInput): Promise<ReseedPreviewReport> {
|
||||
if (!isReseedTarget(input.target)) {
|
||||
throw new Error('Unsupported reseed target.');
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const errors = getEnvironmentBlockers();
|
||||
const actions: ReseedPreviewReport['actions'] = [];
|
||||
|
||||
if (input.target === 'foundation') {
|
||||
actions.push({
|
||||
key: 'foundation',
|
||||
status: 'warning',
|
||||
message: 'Foundation reseed should run through existing seed services; API execution is blocked until service wrappers are extracted.'
|
||||
});
|
||||
warnings.push('Foundation reseed is preview-only in D.7.7 to avoid duplicating seed data behavior.');
|
||||
}
|
||||
|
||||
if (input.target === 'demo_uat') {
|
||||
actions.push({
|
||||
key: 'demo_uat',
|
||||
status: 'warning',
|
||||
message: 'Demo/UAT reseed is deterministic through existing scripts, but API execution is blocked until UAT ownership markers are available.'
|
||||
});
|
||||
warnings.push('Demo/UAT reseed is preview-only in D.7.7.');
|
||||
}
|
||||
|
||||
if (input.target === 'csv_manifest') {
|
||||
actions.push({
|
||||
key: 'csv_manifest',
|
||||
status: 'blocked',
|
||||
message: 'CSV manifest reseed requires original CSV files or persisted seed package inputs, which are not stored by D.7.8.'
|
||||
});
|
||||
errors.push('CSV manifest reseed is blocked because original CSV payloads are not persisted.');
|
||||
}
|
||||
|
||||
const previewHash = hashPreviewPayload({
|
||||
target: input.target,
|
||||
manifestId: input.manifestId ?? null,
|
||||
actions,
|
||||
warnings,
|
||||
errors
|
||||
});
|
||||
|
||||
return withToken<ReseedPreviewReport>({
|
||||
target: input.target,
|
||||
summary: {
|
||||
actions: actions.length,
|
||||
warnings: warnings.length,
|
||||
errors: errors.length
|
||||
},
|
||||
actions,
|
||||
warnings,
|
||||
errors,
|
||||
confirmationPhrase: getConfirmationPhrase({
|
||||
target: input.target,
|
||||
manifestId: input.manifestId ?? null
|
||||
}),
|
||||
previewHash
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReseed(input: ReseedExecuteInput): Promise<ReseedExecuteReport> {
|
||||
const preview = await previewReseed(input);
|
||||
const tokenError = validateConfirmationToken({
|
||||
token: input.confirmationToken,
|
||||
kind: 'reseed',
|
||||
target: input.target,
|
||||
previewHash: input.previewHash
|
||||
});
|
||||
const errors = [...preview.errors];
|
||||
|
||||
if (preview.previewHash !== input.previewHash) errors.push('Preview hash mismatch. Re-run reseed preview.');
|
||||
if (tokenError) errors.push(tokenError);
|
||||
if (input.confirmationPhrase !== preview.confirmationPhrase) {
|
||||
errors.push('Typed confirmation phrase does not match preview.');
|
||||
}
|
||||
|
||||
return {
|
||||
...preview,
|
||||
errors: errors.length > 0 ? errors : ['Reseed execution is blocked until safe service wrappers are available.'],
|
||||
summary: {
|
||||
...preview.summary,
|
||||
errors: errors.length > 0 ? errors.length : preview.summary.errors + 1
|
||||
},
|
||||
executedAt: new Date().toISOString(),
|
||||
executed: false
|
||||
};
|
||||
}
|
||||
112
src/features/setup/server/reset-guards.ts
Normal file
112
src/features/setup/server/reset-guards.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'server-only';
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import type { ResetPreviewReport, ResetScope, ReseedPreviewReport, ReseedTarget } from './reset-report';
|
||||
|
||||
const TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function getSecret() {
|
||||
return process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || 'setup-reset-local-development-secret';
|
||||
}
|
||||
|
||||
function encode(value: unknown) {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
function decode<T>(value: string): T {
|
||||
return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as T;
|
||||
}
|
||||
|
||||
function sign(payload: string) {
|
||||
return createHmac('sha256', getSecret()).update(payload).digest('base64url');
|
||||
}
|
||||
|
||||
export function hashPreviewPayload(payload: unknown) {
|
||||
return `sha256:${createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`;
|
||||
}
|
||||
|
||||
export function createConfirmationToken(input: {
|
||||
kind: 'reset' | 'reseed';
|
||||
scope?: ResetScope;
|
||||
target?: ReseedTarget;
|
||||
previewHash: string;
|
||||
}) {
|
||||
const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
|
||||
const payload = encode({ ...input, expiresAt });
|
||||
return {
|
||||
token: `${payload}.${sign(payload)}`,
|
||||
expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
export function validateConfirmationToken(input: {
|
||||
token: string;
|
||||
kind: 'reset' | 'reseed';
|
||||
scope?: ResetScope;
|
||||
target?: ReseedTarget;
|
||||
previewHash: string;
|
||||
}) {
|
||||
const [payload, signature] = input.token.split('.');
|
||||
if (!payload || !signature || signature !== sign(payload)) {
|
||||
return 'Invalid confirmation token.';
|
||||
}
|
||||
|
||||
const decoded = decode<{
|
||||
kind: 'reset' | 'reseed';
|
||||
scope?: ResetScope;
|
||||
target?: ReseedTarget;
|
||||
previewHash: string;
|
||||
expiresAt: string;
|
||||
}>(payload);
|
||||
|
||||
if (decoded.kind !== input.kind) return 'Confirmation token kind mismatch.';
|
||||
if (decoded.scope !== input.scope) return 'Confirmation token scope mismatch.';
|
||||
if (decoded.target !== input.target) return 'Confirmation token target mismatch.';
|
||||
if (decoded.previewHash !== input.previewHash) return 'Confirmation token preview mismatch.';
|
||||
if (Date.parse(decoded.expiresAt) < Date.now()) return 'Confirmation token expired.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getEnvironmentBlockers() {
|
||||
const blockers: string[] = [];
|
||||
const nodeEnv = (process.env.NODE_ENV ?? '').toLowerCase();
|
||||
const databaseUrl = process.env.DATABASE_URL ?? '';
|
||||
|
||||
if (nodeEnv === 'production') {
|
||||
blockers.push('Reset and reseed APIs are blocked when NODE_ENV=production.');
|
||||
}
|
||||
|
||||
if (databaseUrl) {
|
||||
try {
|
||||
const parsed = new URL(databaseUrl);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const databaseName = parsed.pathname.replace(/^\//, '').toLowerCase();
|
||||
const blockedFragments = ['prod', 'production', 'rds.amazonaws.com'];
|
||||
if (blockedFragments.some((fragment) => host.includes(fragment) || databaseName.includes(fragment))) {
|
||||
blockers.push('Reset and reseed APIs are blocked for production-like database hosts or names.');
|
||||
}
|
||||
} catch {
|
||||
blockers.push('DATABASE_URL could not be parsed for reset safety checks.');
|
||||
}
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
export function withToken<T extends ResetPreviewReport | ReseedPreviewReport>(
|
||||
report: Omit<T, 'confirmationToken' | 'expiresAt'>
|
||||
): T {
|
||||
const token = createConfirmationToken({
|
||||
kind: 'scope' in report ? 'reset' : 'reseed',
|
||||
scope: 'scope' in report ? (report.scope as ResetScope) : undefined,
|
||||
target: 'target' in report ? (report.target as ReseedTarget) : undefined,
|
||||
previewHash: report.previewHash
|
||||
});
|
||||
|
||||
return {
|
||||
...report,
|
||||
confirmationToken: token.token,
|
||||
expiresAt: token.expiresAt
|
||||
} as T;
|
||||
}
|
||||
93
src/features/setup/server/reset-report.ts
Normal file
93
src/features/setup/server/reset-report.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'server-only';
|
||||
|
||||
export type ResetScope =
|
||||
| 'foundation_reset'
|
||||
| 'organization_reset'
|
||||
| 'business_reset'
|
||||
| 'demo_uat_reset'
|
||||
| 'full_reset'
|
||||
| 'csv_import_rollback'
|
||||
| 'setup_run_reset';
|
||||
|
||||
export type ReseedTarget = 'foundation' | 'demo_uat' | 'csv_manifest';
|
||||
|
||||
export interface ResetAffectedTable {
|
||||
table: string;
|
||||
estimatedRows: number;
|
||||
action: 'delete' | 'update_manifest' | 'report_only' | 'blocked';
|
||||
retainedData: string;
|
||||
deletedData: string;
|
||||
}
|
||||
|
||||
export interface StorageCleanupPlan {
|
||||
mode: 'none' | 'best_effort' | 'blocked';
|
||||
plannedKeys: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ResetPreviewReport {
|
||||
scope: ResetScope;
|
||||
targetOrganizationId: string | null;
|
||||
targetOrganizationCode: string | null;
|
||||
manifestId: string | null;
|
||||
summary: {
|
||||
tables: number;
|
||||
rows: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
};
|
||||
affectedTables: ResetAffectedTable[];
|
||||
retainedData: string[];
|
||||
deletedData: string[];
|
||||
storageCleanup: StorageCleanupPlan;
|
||||
manifestImpact: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
status: string;
|
||||
action: 'none' | 'rolled_back' | 'warning' | 'deleted';
|
||||
}>;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
confirmationPhrase: string;
|
||||
previewHash: string;
|
||||
confirmationToken: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ResetExecuteReport extends ResetPreviewReport {
|
||||
executedAt: string;
|
||||
executed: boolean;
|
||||
deletedRows: number;
|
||||
updatedManifests: number;
|
||||
storageCleanupResult: {
|
||||
deletedKeys: string[];
|
||||
leftoverKeys: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReseedPreviewReport {
|
||||
target: ReseedTarget;
|
||||
summary: {
|
||||
actions: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
};
|
||||
actions: Array<{
|
||||
key: string;
|
||||
status: 'ready' | 'blocked' | 'warning';
|
||||
message: string;
|
||||
}>;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
confirmationPhrase: string;
|
||||
previewHash: string;
|
||||
confirmationToken: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ReseedExecuteReport extends ReseedPreviewReport {
|
||||
executedAt: string;
|
||||
executed: boolean;
|
||||
}
|
||||
41
src/features/setup/server/reset-scopes.ts
Normal file
41
src/features/setup/server/reset-scopes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'server-only';
|
||||
|
||||
import type { ResetScope, ReseedTarget } from './reset-report';
|
||||
|
||||
export const RESET_SCOPES: ResetScope[] = [
|
||||
'foundation_reset',
|
||||
'organization_reset',
|
||||
'business_reset',
|
||||
'demo_uat_reset',
|
||||
'full_reset',
|
||||
'csv_import_rollback',
|
||||
'setup_run_reset'
|
||||
];
|
||||
|
||||
export const RESEED_TARGETS: ReseedTarget[] = ['foundation', 'demo_uat', 'csv_manifest'];
|
||||
|
||||
export const ORGANIZATION_AWARE_RESET_SCOPES = new Set<ResetScope>([
|
||||
'foundation_reset',
|
||||
'organization_reset',
|
||||
'business_reset'
|
||||
]);
|
||||
|
||||
export function getConfirmationPhrase(input: {
|
||||
scope?: ResetScope;
|
||||
target?: ReseedTarget;
|
||||
organizationCode?: string | null;
|
||||
manifestId?: string | null;
|
||||
setupRunId?: string | null;
|
||||
}) {
|
||||
if (input.scope === 'business_reset') return `RESET BUSINESS ${input.organizationCode ?? input.organizationCode ?? 'ORG'}`;
|
||||
if (input.scope === 'foundation_reset') return `RESET FOUNDATION ${input.organizationCode ?? 'ORG'}`;
|
||||
if (input.scope === 'organization_reset') return `RESET ORGANIZATION ${input.organizationCode ?? 'ORG'}`;
|
||||
if (input.scope === 'setup_run_reset') return `RESET SETUP RUN ${input.setupRunId ?? 'CURRENT'}`;
|
||||
if (input.scope === 'csv_import_rollback') return `ROLLBACK CSV MANIFEST ${input.manifestId ?? 'MANIFEST'}`;
|
||||
if (input.scope === 'demo_uat_reset') return 'RESET DEMO UAT';
|
||||
if (input.scope === 'full_reset') return 'FULL RESET LOCAL DATABASE';
|
||||
if (input.target === 'foundation') return 'RESEED FOUNDATION';
|
||||
if (input.target === 'demo_uat') return 'RESEED DEMO UAT';
|
||||
if (input.target === 'csv_manifest') return `RESEED CSV MANIFEST ${input.manifestId ?? 'MANIFEST'}`;
|
||||
return 'CONFIRM SETUP MAINTENANCE';
|
||||
}
|
||||
393
src/features/setup/server/reset.service.ts
Normal file
393
src/features/setup/server/reset.service.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import 'server-only';
|
||||
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
import {
|
||||
seedManifestItems,
|
||||
seedManifests,
|
||||
setupRuns,
|
||||
setupRunSteps
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { hashPreviewPayload, getEnvironmentBlockers, validateConfirmationToken, withToken } from './reset-guards';
|
||||
import {
|
||||
getConfirmationPhrase,
|
||||
ORGANIZATION_AWARE_RESET_SCOPES,
|
||||
RESET_SCOPES
|
||||
} from './reset-scopes';
|
||||
import type { ResetAffectedTable, ResetExecuteReport, ResetPreviewReport, ResetScope } from './reset-report';
|
||||
|
||||
export interface ResetPreviewInput {
|
||||
scope: ResetScope;
|
||||
organizationId?: string | null;
|
||||
organizationCode?: string | null;
|
||||
manifestId?: string | null;
|
||||
setupRunId?: string | null;
|
||||
options?: {
|
||||
force?: boolean;
|
||||
allowFullReset?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResetExecuteInput extends ResetPreviewInput {
|
||||
previewHash: string;
|
||||
confirmationToken: string;
|
||||
confirmationPhrase: string;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
const BUSINESS_TABLES = [
|
||||
'crm_approval_actions',
|
||||
'crm_approval_requests',
|
||||
'crm_quotation_attachments',
|
||||
'crm_quotation_followups',
|
||||
'crm_quotation_topic_items',
|
||||
'crm_quotation_topics',
|
||||
'crm_quotation_customers',
|
||||
'crm_quotation_items',
|
||||
'crm_quotations',
|
||||
'crm_opportunity_attachments',
|
||||
'crm_opportunity_customers',
|
||||
'crm_opportunity_followups',
|
||||
'crm_opportunities',
|
||||
'crm_contact_shares',
|
||||
'crm_customer_contacts',
|
||||
'crm_customer_owner_history',
|
||||
'crm_leads',
|
||||
'crm_customers'
|
||||
] as const;
|
||||
|
||||
const FOUNDATION_TABLES = [
|
||||
'crm_approval_matrices',
|
||||
'crm_approval_steps',
|
||||
'crm_approval_workflows',
|
||||
'document_sequences',
|
||||
'ms_options'
|
||||
] as const;
|
||||
|
||||
function isResetScope(value: string): value is ResetScope {
|
||||
return RESET_SCOPES.includes(value as ResetScope);
|
||||
}
|
||||
|
||||
async function countRows(table: string, organizationId?: string | null) {
|
||||
const result = await db.execute(
|
||||
organizationId
|
||||
? sql`select count(*)::int as count from ${sql.raw(table)} where organization_id = ${organizationId}`
|
||||
: sql`select count(*)::int as count from ${sql.raw(table)}`
|
||||
);
|
||||
const row = result[0] as { count?: number | string } | undefined;
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
async function countRowsByColumn(table: string, column: string, value: string) {
|
||||
const result = await db.execute(
|
||||
sql`select count(*)::int as count from ${sql.raw(table)} where ${sql.raw(column)} = ${value}`
|
||||
);
|
||||
const row = result[0] as { count?: number | string } | undefined;
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
async function countSeedManifestItemsForRun(setupRunId: string) {
|
||||
const manifests = await db.select().from(seedManifests).where(eq(seedManifests.setupRunId, setupRunId));
|
||||
const manifestIds = manifests.map((manifest) => manifest.id);
|
||||
if (manifestIds.length === 0) return 0;
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(seedManifestItems)
|
||||
.where(inArray(seedManifestItems.seedManifestId, manifestIds));
|
||||
|
||||
return result.length;
|
||||
}
|
||||
|
||||
function buildSummary(affectedTables: ResetAffectedTable[], warnings: string[], errors: string[]) {
|
||||
return {
|
||||
tables: affectedTables.length,
|
||||
rows: affectedTables.reduce((total, table) => total + table.estimatedRows, 0),
|
||||
warnings: warnings.length,
|
||||
errors: errors.length
|
||||
};
|
||||
}
|
||||
|
||||
async function getManifestImpact(input: ResetPreviewInput): Promise<ResetPreviewReport['manifestImpact']> {
|
||||
if (input.scope === 'setup_run_reset') {
|
||||
const manifests = input.setupRunId
|
||||
? await db.select().from(seedManifests).where(eq(seedManifests.setupRunId, input.setupRunId))
|
||||
: await db.select().from(seedManifests);
|
||||
|
||||
return manifests.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
status: manifest.status,
|
||||
action: 'deleted'
|
||||
}));
|
||||
}
|
||||
|
||||
if (input.scope === 'csv_import_rollback' && input.manifestId) {
|
||||
const manifests = await db.select().from(seedManifests).where(eq(seedManifests.id, input.manifestId));
|
||||
return manifests.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
status: manifest.status,
|
||||
action: 'warning'
|
||||
}));
|
||||
}
|
||||
|
||||
if (input.organizationId) {
|
||||
const manifests = await db
|
||||
.select()
|
||||
.from(seedManifests)
|
||||
.where(eq(seedManifests.organizationId, input.organizationId));
|
||||
|
||||
return manifests.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
status: manifest.status,
|
||||
action: input.scope === 'business_reset' ? 'rolled_back' : 'warning'
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function buildAffectedTables(input: ResetPreviewInput) {
|
||||
if (input.scope === 'business_reset' && input.organizationId) {
|
||||
return Promise.all(
|
||||
BUSINESS_TABLES.map(async (table) => ({
|
||||
table,
|
||||
estimatedRows: await countRows(table, input.organizationId),
|
||||
action: 'delete' as const,
|
||||
retainedData: 'Organization, users, memberships, foundation rows, and data outside target organization.',
|
||||
deletedData: `Business rows for organization ${input.organizationId}.`
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (input.scope === 'foundation_reset' && input.organizationId) {
|
||||
return Promise.all(
|
||||
FOUNDATION_TABLES.map(async (table) => ({
|
||||
table,
|
||||
estimatedRows: await countRows(table, input.organizationId),
|
||||
action: 'blocked' as const,
|
||||
retainedData: 'Business data and users are retained.',
|
||||
deletedData: 'Foundation rows require follow-up service-specific reseed support before deletion.'
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (input.scope === 'setup_run_reset') {
|
||||
const runCount = input.setupRunId
|
||||
? await countRowsByColumn('setup_runs', 'id', input.setupRunId)
|
||||
: await countRows('setup_runs');
|
||||
return [
|
||||
{
|
||||
table: 'setup_runs',
|
||||
estimatedRows: runCount,
|
||||
action: 'delete' as const,
|
||||
retainedData: 'Business, organization, user, and foundation data.',
|
||||
deletedData: input.setupRunId ? `Setup run ${input.setupRunId}.` : 'All setup run metadata.'
|
||||
},
|
||||
{
|
||||
table: 'setup_run_steps',
|
||||
estimatedRows: input.setupRunId
|
||||
? await countRowsByColumn('setup_run_steps', 'setup_run_id', input.setupRunId)
|
||||
: await countRows('setup_run_steps'),
|
||||
action: 'delete' as const,
|
||||
retainedData: 'Actual CRM and foundation data.',
|
||||
deletedData: 'Setup step reports.'
|
||||
},
|
||||
{
|
||||
table: 'seed_manifests',
|
||||
estimatedRows: input.setupRunId
|
||||
? await countRowsByColumn('seed_manifests', 'setup_run_id', input.setupRunId)
|
||||
: await countRows('seed_manifests'),
|
||||
action: 'delete' as const,
|
||||
retainedData: 'Actual imported data remains untouched.',
|
||||
deletedData: 'Seed manifest metadata.'
|
||||
},
|
||||
{
|
||||
table: 'seed_manifest_items',
|
||||
estimatedRows: input.setupRunId
|
||||
? await countSeedManifestItemsForRun(input.setupRunId)
|
||||
: await countRows('seed_manifest_items'),
|
||||
action: 'delete' as const,
|
||||
retainedData: 'Actual imported data remains untouched.',
|
||||
deletedData: 'Seed manifest item metadata.'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function previewReset(input: ResetPreviewInput): Promise<ResetPreviewReport> {
|
||||
if (!isResetScope(input.scope)) {
|
||||
throw new Error('Unsupported reset scope.');
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const errors = getEnvironmentBlockers();
|
||||
|
||||
if (ORGANIZATION_AWARE_RESET_SCOPES.has(input.scope) && !input.organizationId) {
|
||||
errors.push('Target organization is required for this reset scope.');
|
||||
}
|
||||
|
||||
if (input.scope === 'full_reset') {
|
||||
errors.push('Full reset is not exposed through the dashboard API. Use the guarded npm reset script with explicit local confirmation.');
|
||||
}
|
||||
|
||||
if (input.scope === 'demo_uat_reset') {
|
||||
errors.push('Demo/UAT reset requires persisted UAT seed ownership markers and is blocked until those markers are available.');
|
||||
}
|
||||
|
||||
if (input.scope === 'csv_import_rollback') {
|
||||
if (!input.manifestId) {
|
||||
errors.push('CSV import rollback requires a seed manifest id.');
|
||||
}
|
||||
warnings.push('CSV import rollback is report-only unless manifest-owned rows are proven safely reversible.');
|
||||
}
|
||||
|
||||
if (input.scope === 'foundation_reset') {
|
||||
warnings.push('Foundation reset is preview-only until service-specific reseed wrappers are available.');
|
||||
}
|
||||
|
||||
const affectedTables = await buildAffectedTables(input);
|
||||
const manifestImpact = await getManifestImpact(input);
|
||||
const confirmationPhrase = getConfirmationPhrase({
|
||||
scope: input.scope,
|
||||
organizationCode: input.organizationCode ?? input.organizationId ?? null,
|
||||
manifestId: input.manifestId ?? null,
|
||||
setupRunId: input.setupRunId ?? null
|
||||
});
|
||||
const previewPayload = {
|
||||
scope: input.scope,
|
||||
organizationId: input.organizationId ?? null,
|
||||
manifestId: input.manifestId ?? null,
|
||||
affectedTables,
|
||||
manifestImpact,
|
||||
warnings,
|
||||
errors
|
||||
};
|
||||
const previewHash = hashPreviewPayload(previewPayload);
|
||||
|
||||
return withToken<ResetPreviewReport>({
|
||||
scope: input.scope,
|
||||
targetOrganizationId: input.organizationId ?? null,
|
||||
targetOrganizationCode: input.organizationCode ?? null,
|
||||
manifestId: input.manifestId ?? null,
|
||||
summary: buildSummary(affectedTables, warnings, errors),
|
||||
affectedTables,
|
||||
retainedData: [...new Set(affectedTables.map((table) => table.retainedData))],
|
||||
deletedData: [...new Set(affectedTables.map((table) => table.deletedData))],
|
||||
storageCleanup: {
|
||||
mode: input.scope === 'business_reset' ? 'best_effort' : 'none',
|
||||
plannedKeys: [],
|
||||
warnings:
|
||||
input.scope === 'business_reset'
|
||||
? ['Storage cleanup is best-effort and limited to setup-owned temporary or import-owned objects.']
|
||||
: []
|
||||
},
|
||||
manifestImpact,
|
||||
warnings,
|
||||
errors,
|
||||
confirmationPhrase,
|
||||
previewHash
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSetupRunReset(input: ResetExecuteInput) {
|
||||
await db.transaction(async (tx) => {
|
||||
if (input.setupRunId) {
|
||||
const manifests = await tx
|
||||
.select()
|
||||
.from(seedManifests)
|
||||
.where(eq(seedManifests.setupRunId, input.setupRunId));
|
||||
const manifestIds = manifests.map((manifest) => manifest.id);
|
||||
if (manifestIds.length > 0) {
|
||||
await tx.delete(seedManifestItems).where(inArray(seedManifestItems.seedManifestId, manifestIds));
|
||||
}
|
||||
await tx.delete(seedManifests).where(eq(seedManifests.setupRunId, input.setupRunId));
|
||||
await tx.delete(setupRunSteps).where(eq(setupRunSteps.setupRunId, input.setupRunId));
|
||||
await tx.delete(setupRuns).where(eq(setupRuns.id, input.setupRunId));
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.delete(seedManifestItems);
|
||||
await tx.delete(seedManifests);
|
||||
await tx.delete(setupRunSteps);
|
||||
await tx.delete(setupRuns);
|
||||
});
|
||||
}
|
||||
|
||||
async function executeBusinessReset(organizationId: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
for (const table of BUSINESS_TABLES) {
|
||||
await tx.execute(sql`delete from ${sql.raw(table)} where organization_id = ${organizationId}`);
|
||||
}
|
||||
await tx
|
||||
.update(seedManifests)
|
||||
.set({ status: 'rolled_back', updatedAt: new Date() })
|
||||
.where(eq(seedManifests.organizationId, organizationId));
|
||||
await tx
|
||||
.update(setupRunSteps)
|
||||
.set({ status: 'stale', updatedAt: new Date() })
|
||||
.where(inArray(setupRunSteps.stepKey, ['csv_commit', 'verification']));
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReset(input: ResetExecuteInput): Promise<ResetExecuteReport> {
|
||||
const preview = await previewReset(input);
|
||||
const tokenError = validateConfirmationToken({
|
||||
token: input.confirmationToken,
|
||||
kind: 'reset',
|
||||
scope: input.scope,
|
||||
previewHash: input.previewHash
|
||||
});
|
||||
const errors = [...preview.errors];
|
||||
|
||||
if (preview.previewHash !== input.previewHash) {
|
||||
errors.push('Preview hash mismatch. Re-run reset preview.');
|
||||
}
|
||||
if (tokenError) errors.push(tokenError);
|
||||
if (input.confirmationPhrase !== preview.confirmationPhrase) {
|
||||
errors.push('Typed confirmation phrase does not match preview.');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { ...preview, errors, summary: { ...preview.summary, errors: errors.length }, executedAt: new Date().toISOString(), executed: false, deletedRows: 0, updatedManifests: 0, storageCleanupResult: { deletedKeys: [], leftoverKeys: [], warnings: [] } };
|
||||
}
|
||||
|
||||
const beforeRows = preview.summary.rows;
|
||||
|
||||
if (input.scope === 'setup_run_reset') {
|
||||
await executeSetupRunReset(input);
|
||||
} else if (input.scope === 'business_reset' && input.organizationId) {
|
||||
await executeBusinessReset(input.organizationId);
|
||||
} else {
|
||||
return {
|
||||
...preview,
|
||||
errors: ['This reset scope is preview-only until a reversible, service-specific implementation is available.'],
|
||||
summary: { ...preview.summary, errors: preview.summary.errors + 1 },
|
||||
executedAt: new Date().toISOString(),
|
||||
executed: false,
|
||||
deletedRows: 0,
|
||||
updatedManifests: 0,
|
||||
storageCleanupResult: { deletedKeys: [], leftoverKeys: [], warnings: [] }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...preview,
|
||||
executedAt: new Date().toISOString(),
|
||||
executed: true,
|
||||
deletedRows: beforeRows,
|
||||
updatedManifests: preview.manifestImpact.length,
|
||||
storageCleanupResult: {
|
||||
deletedKeys: [],
|
||||
leftoverKeys: [],
|
||||
warnings: preview.storageCleanup.warnings
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user