task-d.7.7

This commit is contained in:
phaichayon
2026-07-03 11:05:13 +07:00
parent 9b05743b1e
commit ccfd7c6cd7
11 changed files with 1566 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
# Task D.7.7 Scoped Reset Reseed Engine
Date: 2026-07-03
Status: implemented
## Scope Delivered
Added setup reset/reseed server foundations:
- `src/features/setup/server/reset.service.ts`
- `src/features/setup/server/reseed.service.ts`
- `src/features/setup/server/reset-guards.ts`
- `src/features/setup/server/reset-report.ts`
- `src/features/setup/server/reset-scopes.ts`
Added protected setup maintenance APIs:
- `POST /api/setup/reset/preview`
- `POST /api/setup/reset/execute`
- `POST /api/setup/reseed/preview`
- `POST /api/setup/reseed/execute`
## Reset Behavior
Reset preview is implemented for all D.7.7 scopes:
- `foundation_reset`
- `organization_reset`
- `business_reset`
- `demo_uat_reset`
- `full_reset`
- `csv_import_rollback`
- `setup_run_reset`
Reset execute is implemented only where ownership and safety are clear:
- `setup_run_reset`
- Deletes setup metadata only.
- Deletes `setup_runs`, `setup_run_steps`, `seed_manifests`, and `seed_manifest_items`.
- Does not delete business, organization, foundation, or user data.
- `business_reset`
- Deletes organization-scoped CRM business rows only.
- Retains organization, users, memberships, and foundation rows.
- Marks matching seed manifests `rolled_back`.
- Marks CSV commit and verification setup steps `stale`.
The following scopes intentionally block execution with safe reports:
- `foundation_reset`
- `organization_reset`
- `demo_uat_reset`
- `full_reset`
- `csv_import_rollback`
These are blocked until service-specific seed/reseed wrappers, deterministic UAT ownership markers, or reversible manifest ownership metadata are available.
## Reseed Behavior
Reseed preview exists for:
- `foundation`
- `demo_uat`
- `csv_manifest`
Reseed execute is blocked in D.7.7 and returns a safe report. This avoids duplicating seed scripts or pretending CSV manifests can be replayed when original CSV payloads are not persisted.
## Safety Guards
All reset/reseed APIs require:
- `super_admin`
- non-production environment
- non-production-like database host/name
- explicit scope or target
- preview before execute
- valid `previewHash`
- valid short-lived `confirmationToken`
- exact typed confirmation phrase
Organization-aware scopes require `organizationId`.
## Report Behavior
Preview reports include:
- scope or reseed target
- affected tables
- estimated row counts
- retained data
- deleted data
- storage cleanup plan
- seed manifest impact
- warnings and blocking errors
- confirmation phrase
- preview hash
- confirmation token and expiry
Execute reports include:
- execution status
- deleted row estimate
- updated manifest count
- best-effort storage cleanup result
Storage cleanup is report-only/best-effort and never silently deletes approved production artifacts.
## Out Of Scope Preserved
- Production reset workflow.
- Silent automatic reset.
- Arbitrary rollback without manifest ownership.
- Deleting approved production artifacts.
- New seed data creation.
- Installation profile plugin runtime.
- Backup/restore.
- Data anonymization.
- Cross-environment migration.
- Email/storage secret reset.
- CRM business behavior changes.
## Validation
- `npm run typecheck -- --pretty false` passed.
- `npx oxlint src/features/setup/server/reset.service.ts src/features/setup/server/reseed.service.ts src/features/setup/server/reset-guards.ts src/features/setup/server/reset-report.ts src/features/setup/server/reset-scopes.ts src/app/api/setup/reset src/app/api/setup/reseed` passed.
## Residual Notes
- Full reset remains delegated to existing guarded scripts operationally; the dashboard API returns a blocking report instead of running process-level database reset commands.
- `csv_import_rollback` remains report-only because D.7.8 seed manifest records do not persist enough row-level reversible ownership metadata for arbitrary rollback.
- `demo_uat_reset` remains blocked until UAT rows have deterministic persisted ownership markers beyond seed conventions.

611
plans/task-d.7.7.md Normal file
View File

@@ -0,0 +1,611 @@
# Task D.7.7 Scoped Reset and Reseed Engine
## Objective
Implement a safe, non-production Scoped Reset and Reseed Engine for ALLA OS Initial Setup.
This task must allow controlled reset/reseed operations for setup, foundation, organization, business, demo/UAT, and CSV-imported data while preserving production safety guards.
D.7.7 must use the Setup State and Seed Manifest foundations from D.7.8 where possible.
This task must not introduce any destructive reset path for production.
---
## Review Before Implementation
Review:
- AGENTS.md
- plans/task-d.7.md
- plans/task-d.7.1.md
- plans/task-d.7.2.md
- plans/task-d.7.3.md
- plans/task-d.7.4.md
- plans/task-d.7.5.md
- plans/task-d.7.6.md
- plans/task-d.7.8.md
- docs/setup/reset-reseed-matrix.md
- docs/setup/seed-version-manifest.md
- docs/setup/setup-state-machine.md
- docs/setup/seed-matrix.md
- docs/setup/import-mapping.md
- src/db/schema.ts
- src/features/setup/**
- src/app/api/setup/**
- scripts/seed-reset.ts
- scripts/db/reset-database.ts
- scripts/seed-system.ts
- scripts/seed-uat.ts
- src/db/seeds/**/*
- existing storage provider implementation
---
## Implementation Scope
Create reset/reseed services:
src/features/setup/server/reset.service.ts
src/features/setup/server/reseed.service.ts
Optional helpers:
src/features/setup/server/reset-guards.ts
src/features/setup/server/reset-report.ts
src/features/setup/server/reset-scopes.ts
Create API routes:
POST /api/setup/reset/preview
POST /api/setup/reset/execute
POST /api/setup/reseed/preview
POST /api/setup/reseed/execute
Optional UI integration:
Add a Maintenance panel in Setup Wizard or Administration Setup page.
---
## Reset Scopes
Implement scopes from docs/setup/reset-reseed-matrix.md:
foundation_reset
organization_reset
business_reset
demo_uat_reset
csv_import_rollback
setup_run_reset
full_reset
---
## Scope Rules
### foundation_reset
Deletes and optionally reseeds organization-scoped foundation rows.
May affect:
- ms_options
- document_sequences
- approval workflows
- approval steps
- approval matrices
- document template foundation rows
- document library foundation rows
- notification templates
- report definitions
Must retain:
- users
- organizations
- memberships
- business CRM data unless explicitly selected
---
### organization_reset
Deletes target organization and tenant-scoped rows.
Must require exact organization code/name confirmation.
Must retain:
- global users unless explicitly selected
Must delete:
- memberships for target organization
- CRM role assignments/profiles for target organization
- organization-scoped foundation rows
- business rows for target organization
---
### business_reset
Deletes CRM business records for target organization.
May affect:
- customers
- contacts
- contact shares
- leads
- opportunities
- quotations
- quotation items
- quotation parties
- quotation topics
- quotation topic items
- followups
- business attachment metadata
Must retain:
- organization
- users
- memberships
- foundation rows
- setup run history unless explicitly selected
---
### demo_uat_reset
Deletes deterministic Demo/UAT rows only.
Must rely on:
- UAT seed markers
- deterministic emails/codes
- seed manifest records
- known UAT seed package names
Must retain:
- production foundation
- non-UAT business data
---
### csv_import_rollback
Rolls back rows associated with a seed manifest/import package when safely reversible.
Rules:
- only rollback manifest-owned rows
- block rollback if rows were changed after import and no safe conflict policy exists
- if rollback is not fully safe, return warning and require manual review
- do not pretend arbitrary rollback is safe
---
### setup_run_reset
Deletes setup run metadata only.
May affect:
- setup_runs
- setup_run_steps
- seed_manifests
- seed_manifest_items
Must not delete:
- business data
- foundation data
- organizations
- users
---
### full_reset
Must use existing guarded reset script behavior.
Rules:
- block in production
- require explicit allow flag
- require typed confirmation
- local/test only by default
- do not expose casually in UI
---
## Safety Guards
All destructive actions require:
- super_admin
- non-production environment
- explicit scope
- target organization when scope is organization-aware
- typed confirmation
- preview before execute
- confirmation token generated from preview
- token expires after short duration
- execute must match preview hash/token
Block when:
- NODE_ENV=production
- production-like database host/name is detected
- missing target organization
- missing typed confirmation
- preview was not run
- preview token expired
- preview hash mismatch
- affected row count exceeds safe threshold unless force flag is provided
---
## Preview Behavior
Reset preview must not delete anything.
Preview returns:
- scope
- target organization
- affected tables
- estimated row counts
- retained data
- deleted data
- storage cleanup plan
- manifest impact
- warnings
- blocking errors
- confirmation phrase
- previewHash
- confirmationToken
Example:
{
"scope": "business_reset",
"targetOrganization": "ALLA",
"summary": {
"tables": 12,
"rows": 520,
"warnings": 2,
"errors": 0
},
"affectedTables": [],
"confirmationPhrase": "RESET BUSINESS ALLA",
"previewHash": "...",
"confirmationToken": "..."
}
---
## Execute Behavior
Execute requires:
- scope
- previewHash
- confirmationToken
- typed confirmation phrase
Execution must:
1. Rebuild preview.
2. Compare previewHash.
3. Validate token.
4. Validate typed confirmation.
5. Run reset inside transaction where DB-only.
6. Handle storage cleanup best-effort.
7. Persist reset report to setup run if available.
8. Return final report.
---
## Reseed Behavior
Reseed is optional but recommended for:
- foundation_reset
- demo_uat_reset
- csv_import_rollback after cleanup
Reseed must call existing seed services/scripts through safe wrappers.
Do not duplicate seed data.
Supported reseed targets:
foundation
demo_uat
csv_manifest
Rules:
- foundation reseed is idempotent
- demo/UAT reseed is deterministic
- csv_manifest reseed requires original manifest/files available or block with clear reason
---
## Storage Cleanup
Storage cleanup must be:
- best-effort
- scoped
- reported
- never silent
Rules:
- delete setup-owned temp objects only when safe
- delete demo-generated objects only under demo prefix
- do not delete approved immutable artifacts unless explicit full reset/demo reset and policy allows
- if cleanup fails, DB transaction should not necessarily rollback unless storage cleanup is required by scope
- report leftover keys
---
## Seed Manifest Integration
Use D.7.8 seed manifest tables.
Reset preview should show:
- affected seed manifests
- affected seed manifest items
- whether import rollback is safe
- checksum/package info
Reset execute should update manifest status when applicable:
- rolled_back
- warning
- failed
Do not delete manifest rows unless scope is setup_run_reset.
---
## Setup State Integration
Reset should update setup run state when applicable.
Examples:
- business_reset marks csv_commit and verification stale
- foundation_reset marks readiness, scope, sequence, approval, CSV, and verification stale
- setup_run_reset clears setup state only
- organization_reset cancels or invalidates active setup runs for that organization
Do not implement destructive RESET_STEP from UI unless explicitly scoped.
---
## API Contracts
### POST /api/setup/reset/preview
Input:
scope
organizationId?
organizationCode?
manifestId?
options?
Output:
ResetPreviewReport
---
### POST /api/setup/reset/execute
Input:
scope
previewHash
confirmationToken
confirmationPhrase
organizationId?
organizationCode?
manifestId?
options?
Output:
ResetExecuteReport
---
### POST /api/setup/reseed/preview
Input:
target
organizationId?
organizationCode?
manifestId?
options?
Output:
ReseedPreviewReport
---
### POST /api/setup/reseed/execute
Input:
target
previewHash
confirmationToken
confirmationPhrase
organizationId?
organizationCode?
manifestId?
options?
Output:
ReseedExecuteReport
---
## UI Requirements If Implemented
Add Maintenance panel:
- Reset scope selector
- Target organization selector
- Preview button
- Affected tables/counts
- Warnings/errors
- Typed confirmation input
- Execute button
- Final report
UI must hide or disable destructive actions when environment is production.
---
## Out Of Scope
- Production reset workflow
- Silent automatic reset
- Arbitrary rollback without manifest ownership
- Deleting approved production artifacts
- New seed data creation
- Installation profile engine
- Plugin runtime
- Backup/restore
- Data anonymization
- Cross-environment migration
- Email/storage secret reset
- Changing CRM business behavior
---
## Deliverables
Required:
src/features/setup/server/reset.service.ts
src/features/setup/server/reseed.service.ts
src/features/setup/server/reset-guards.ts
src/features/setup/server/reset-report.ts
API routes:
src/app/api/setup/reset/preview/route.ts
src/app/api/setup/reset/execute/route.ts
src/app/api/setup/reseed/preview/route.ts
src/app/api/setup/reseed/execute/route.ts
Optional:
src/features/setup/components/setup-maintenance-panel.tsx
---
## Acceptance Criteria
✓ Reset preview API exists.
✓ Reset execute API exists.
✓ Reseed preview API exists.
✓ Reseed execute API exists.
✓ All APIs require super_admin.
✓ Production destructive reset is blocked.
✓ Preview is required before execute.
✓ Execute requires valid previewHash.
✓ Execute requires valid confirmationToken.
✓ Execute requires typed confirmation phrase.
✓ Organization-aware scopes require target organization.
✓ Preview returns affected tables and estimated rows.
✓ Execute uses transaction for DB-only deletes.
✓ Storage cleanup is best-effort and reported.
✓ Seed manifest status updates for rollback/reset where applicable.
✓ Setup run steps are marked stale after reset.
✓ full_reset delegates to existing guarded reset behavior or blocks with clear instructions.
✓ No reset deletes approved immutable artifacts unless explicitly allowed by scope policy.
✓ No seed logic is duplicated.
✓ Typecheck passes or unrelated failures are documented.
---
## Suggested Verification
Run:
npm run typecheck
Run scoped lint:
npx oxlint src/features/setup src/app/api/setup
Manual test:
1. Run business_reset preview for ALLA.
2. Confirm affected table counts are shown.
3. Execute without preview token.
4. Confirm blocked.
5. Execute with wrong confirmation phrase.
6. Confirm blocked.
7. Execute with valid token and phrase in local environment.
8. Confirm rows deleted and foundation retained.
9. Run setup verification.
10. Confirm CSV/verification steps stale.
11. Try in NODE_ENV=production.
12. Confirm destructive reset is blocked.
13. Run setup_run_reset.
14. Confirm setup metadata deleted but business rows remain.
15. Run csv_import_rollback for manifest.
16. Confirm only manifest-owned rows are affected or rollback is blocked with manual review warning.
---
## Follow-up
After D.7.7:
D.7.9 Installation Profile and Plugin Runtime Enhancement
D.7.10 Setup Integration Test and Golden Dataset

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

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

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

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

View 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
};
}

View 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;
}

View 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;
}

View 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';
}

View 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
}
};
}