task-d.7.7
This commit is contained in:
130
docs/implementation/task-d77-scoped-reset-reseed-engine.md
Normal file
130
docs/implementation/task-d77-scoped-reset-reseed-engine.md
Normal 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
611
plans/task-d.7.7.md
Normal 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
|
||||
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