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