From ccfd7c6cd77b561b859fbfff525ff6258e79f567 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Fri, 3 Jul 2026 11:05:13 +0700 Subject: [PATCH] task-d.7.7 --- .../task-d77-scoped-reset-reseed-engine.md | 130 ++++ plans/task-d.7.7.md | 611 ++++++++++++++++++ src/app/api/setup/reseed/execute/route.ts | 19 + src/app/api/setup/reseed/preview/route.ts | 17 + src/app/api/setup/reset/execute/route.ts | 19 + src/app/api/setup/reset/preview/route.ts | 17 + src/features/setup/server/reseed.service.ts | 114 ++++ src/features/setup/server/reset-guards.ts | 112 ++++ src/features/setup/server/reset-report.ts | 93 +++ src/features/setup/server/reset-scopes.ts | 41 ++ src/features/setup/server/reset.service.ts | 393 +++++++++++ 11 files changed, 1566 insertions(+) create mode 100644 docs/implementation/task-d77-scoped-reset-reseed-engine.md create mode 100644 plans/task-d.7.7.md create mode 100644 src/app/api/setup/reseed/execute/route.ts create mode 100644 src/app/api/setup/reseed/preview/route.ts create mode 100644 src/app/api/setup/reset/execute/route.ts create mode 100644 src/app/api/setup/reset/preview/route.ts create mode 100644 src/features/setup/server/reseed.service.ts create mode 100644 src/features/setup/server/reset-guards.ts create mode 100644 src/features/setup/server/reset-report.ts create mode 100644 src/features/setup/server/reset-scopes.ts create mode 100644 src/features/setup/server/reset.service.ts diff --git a/docs/implementation/task-d77-scoped-reset-reseed-engine.md b/docs/implementation/task-d77-scoped-reset-reseed-engine.md new file mode 100644 index 0000000..f8cce70 --- /dev/null +++ b/docs/implementation/task-d77-scoped-reset-reseed-engine.md @@ -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. diff --git a/plans/task-d.7.7.md b/plans/task-d.7.7.md new file mode 100644 index 0000000..c90d5b8 --- /dev/null +++ b/plans/task-d.7.7.md @@ -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 diff --git a/src/app/api/setup/reseed/execute/route.ts b/src/app/api/setup/reseed/execute/route.ts new file mode 100644 index 0000000..9ecf51b --- /dev/null +++ b/src/app/api/setup/reseed/execute/route.ts @@ -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 }); + } +} diff --git a/src/app/api/setup/reseed/preview/route.ts b/src/app/api/setup/reseed/preview/route.ts new file mode 100644 index 0000000..211dcfb --- /dev/null +++ b/src/app/api/setup/reseed/preview/route.ts @@ -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 }); + } +} diff --git a/src/app/api/setup/reset/execute/route.ts b/src/app/api/setup/reset/execute/route.ts new file mode 100644 index 0000000..89770e8 --- /dev/null +++ b/src/app/api/setup/reset/execute/route.ts @@ -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 }); + } +} diff --git a/src/app/api/setup/reset/preview/route.ts b/src/app/api/setup/reset/preview/route.ts new file mode 100644 index 0000000..b5e6d54 --- /dev/null +++ b/src/app/api/setup/reset/preview/route.ts @@ -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 }); + } +} diff --git a/src/features/setup/server/reseed.service.ts b/src/features/setup/server/reseed.service.ts new file mode 100644 index 0000000..a34033f --- /dev/null +++ b/src/features/setup/server/reseed.service.ts @@ -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 { + 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({ + 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 { + 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 + }; +} diff --git a/src/features/setup/server/reset-guards.ts b/src/features/setup/server/reset-guards.ts new file mode 100644 index 0000000..2637e54 --- /dev/null +++ b/src/features/setup/server/reset-guards.ts @@ -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(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( + report: Omit +): 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; +} diff --git a/src/features/setup/server/reset-report.ts b/src/features/setup/server/reset-report.ts new file mode 100644 index 0000000..63a72c3 --- /dev/null +++ b/src/features/setup/server/reset-report.ts @@ -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; +} diff --git a/src/features/setup/server/reset-scopes.ts b/src/features/setup/server/reset-scopes.ts new file mode 100644 index 0000000..766dedc --- /dev/null +++ b/src/features/setup/server/reset-scopes.ts @@ -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([ + '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'; +} diff --git a/src/features/setup/server/reset.service.ts b/src/features/setup/server/reset.service.ts new file mode 100644 index 0000000..0f8d2ee --- /dev/null +++ b/src/features/setup/server/reset.service.ts @@ -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 { + 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 { + 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({ + 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 { + 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 + } + }; +}