task-d.7.7

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

View File

@@ -0,0 +1,114 @@
import 'server-only';
import { hashPreviewPayload, getEnvironmentBlockers, validateConfirmationToken, withToken } from './reset-guards';
import { getConfirmationPhrase, RESEED_TARGETS } from './reset-scopes';
import type { ReseedExecuteReport, ReseedPreviewReport, ReseedTarget } from './reset-report';
export interface ReseedPreviewInput {
target: ReseedTarget;
manifestId?: string | null;
options?: {
force?: boolean;
};
}
export interface ReseedExecuteInput extends ReseedPreviewInput {
previewHash: string;
confirmationToken: string;
confirmationPhrase: string;
actorId: string;
}
function isReseedTarget(value: string): value is ReseedTarget {
return RESEED_TARGETS.includes(value as ReseedTarget);
}
export async function previewReseed(input: ReseedPreviewInput): Promise<ReseedPreviewReport> {
if (!isReseedTarget(input.target)) {
throw new Error('Unsupported reseed target.');
}
const warnings: string[] = [];
const errors = getEnvironmentBlockers();
const actions: ReseedPreviewReport['actions'] = [];
if (input.target === 'foundation') {
actions.push({
key: 'foundation',
status: 'warning',
message: 'Foundation reseed should run through existing seed services; API execution is blocked until service wrappers are extracted.'
});
warnings.push('Foundation reseed is preview-only in D.7.7 to avoid duplicating seed data behavior.');
}
if (input.target === 'demo_uat') {
actions.push({
key: 'demo_uat',
status: 'warning',
message: 'Demo/UAT reseed is deterministic through existing scripts, but API execution is blocked until UAT ownership markers are available.'
});
warnings.push('Demo/UAT reseed is preview-only in D.7.7.');
}
if (input.target === 'csv_manifest') {
actions.push({
key: 'csv_manifest',
status: 'blocked',
message: 'CSV manifest reseed requires original CSV files or persisted seed package inputs, which are not stored by D.7.8.'
});
errors.push('CSV manifest reseed is blocked because original CSV payloads are not persisted.');
}
const previewHash = hashPreviewPayload({
target: input.target,
manifestId: input.manifestId ?? null,
actions,
warnings,
errors
});
return withToken<ReseedPreviewReport>({
target: input.target,
summary: {
actions: actions.length,
warnings: warnings.length,
errors: errors.length
},
actions,
warnings,
errors,
confirmationPhrase: getConfirmationPhrase({
target: input.target,
manifestId: input.manifestId ?? null
}),
previewHash
});
}
export async function executeReseed(input: ReseedExecuteInput): Promise<ReseedExecuteReport> {
const preview = await previewReseed(input);
const tokenError = validateConfirmationToken({
token: input.confirmationToken,
kind: 'reseed',
target: input.target,
previewHash: input.previewHash
});
const errors = [...preview.errors];
if (preview.previewHash !== input.previewHash) errors.push('Preview hash mismatch. Re-run reseed preview.');
if (tokenError) errors.push(tokenError);
if (input.confirmationPhrase !== preview.confirmationPhrase) {
errors.push('Typed confirmation phrase does not match preview.');
}
return {
...preview,
errors: errors.length > 0 ? errors : ['Reseed execution is blocked until safe service wrappers are available.'],
summary: {
...preview.summary,
errors: errors.length > 0 ? errors.length : preview.summary.errors + 1
},
executedAt: new Date().toISOString(),
executed: false
};
}

View File

@@ -0,0 +1,112 @@
import 'server-only';
import { createHash, createHmac } from 'node:crypto';
import type { ResetPreviewReport, ResetScope, ReseedPreviewReport, ReseedTarget } from './reset-report';
const TOKEN_TTL_MS = 10 * 60 * 1000;
function getSecret() {
return process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || 'setup-reset-local-development-secret';
}
function encode(value: unknown) {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
function decode<T>(value: string): T {
return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as T;
}
function sign(payload: string) {
return createHmac('sha256', getSecret()).update(payload).digest('base64url');
}
export function hashPreviewPayload(payload: unknown) {
return `sha256:${createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`;
}
export function createConfirmationToken(input: {
kind: 'reset' | 'reseed';
scope?: ResetScope;
target?: ReseedTarget;
previewHash: string;
}) {
const expiresAt = new Date(Date.now() + TOKEN_TTL_MS).toISOString();
const payload = encode({ ...input, expiresAt });
return {
token: `${payload}.${sign(payload)}`,
expiresAt
};
}
export function validateConfirmationToken(input: {
token: string;
kind: 'reset' | 'reseed';
scope?: ResetScope;
target?: ReseedTarget;
previewHash: string;
}) {
const [payload, signature] = input.token.split('.');
if (!payload || !signature || signature !== sign(payload)) {
return 'Invalid confirmation token.';
}
const decoded = decode<{
kind: 'reset' | 'reseed';
scope?: ResetScope;
target?: ReseedTarget;
previewHash: string;
expiresAt: string;
}>(payload);
if (decoded.kind !== input.kind) return 'Confirmation token kind mismatch.';
if (decoded.scope !== input.scope) return 'Confirmation token scope mismatch.';
if (decoded.target !== input.target) return 'Confirmation token target mismatch.';
if (decoded.previewHash !== input.previewHash) return 'Confirmation token preview mismatch.';
if (Date.parse(decoded.expiresAt) < Date.now()) return 'Confirmation token expired.';
return null;
}
export function getEnvironmentBlockers() {
const blockers: string[] = [];
const nodeEnv = (process.env.NODE_ENV ?? '').toLowerCase();
const databaseUrl = process.env.DATABASE_URL ?? '';
if (nodeEnv === 'production') {
blockers.push('Reset and reseed APIs are blocked when NODE_ENV=production.');
}
if (databaseUrl) {
try {
const parsed = new URL(databaseUrl);
const host = parsed.hostname.toLowerCase();
const databaseName = parsed.pathname.replace(/^\//, '').toLowerCase();
const blockedFragments = ['prod', 'production', 'rds.amazonaws.com'];
if (blockedFragments.some((fragment) => host.includes(fragment) || databaseName.includes(fragment))) {
blockers.push('Reset and reseed APIs are blocked for production-like database hosts or names.');
}
} catch {
blockers.push('DATABASE_URL could not be parsed for reset safety checks.');
}
}
return blockers;
}
export function withToken<T extends ResetPreviewReport | ReseedPreviewReport>(
report: Omit<T, 'confirmationToken' | 'expiresAt'>
): T {
const token = createConfirmationToken({
kind: 'scope' in report ? 'reset' : 'reseed',
scope: 'scope' in report ? (report.scope as ResetScope) : undefined,
target: 'target' in report ? (report.target as ReseedTarget) : undefined,
previewHash: report.previewHash
});
return {
...report,
confirmationToken: token.token,
expiresAt: token.expiresAt
} as T;
}

View File

@@ -0,0 +1,93 @@
import 'server-only';
export type ResetScope =
| 'foundation_reset'
| 'organization_reset'
| 'business_reset'
| 'demo_uat_reset'
| 'full_reset'
| 'csv_import_rollback'
| 'setup_run_reset';
export type ReseedTarget = 'foundation' | 'demo_uat' | 'csv_manifest';
export interface ResetAffectedTable {
table: string;
estimatedRows: number;
action: 'delete' | 'update_manifest' | 'report_only' | 'blocked';
retainedData: string;
deletedData: string;
}
export interface StorageCleanupPlan {
mode: 'none' | 'best_effort' | 'blocked';
plannedKeys: string[];
warnings: string[];
}
export interface ResetPreviewReport {
scope: ResetScope;
targetOrganizationId: string | null;
targetOrganizationCode: string | null;
manifestId: string | null;
summary: {
tables: number;
rows: number;
warnings: number;
errors: number;
};
affectedTables: ResetAffectedTable[];
retainedData: string[];
deletedData: string[];
storageCleanup: StorageCleanupPlan;
manifestImpact: Array<{
id: string;
name: string;
version: string;
status: string;
action: 'none' | 'rolled_back' | 'warning' | 'deleted';
}>;
warnings: string[];
errors: string[];
confirmationPhrase: string;
previewHash: string;
confirmationToken: string;
expiresAt: string;
}
export interface ResetExecuteReport extends ResetPreviewReport {
executedAt: string;
executed: boolean;
deletedRows: number;
updatedManifests: number;
storageCleanupResult: {
deletedKeys: string[];
leftoverKeys: string[];
warnings: string[];
};
}
export interface ReseedPreviewReport {
target: ReseedTarget;
summary: {
actions: number;
warnings: number;
errors: number;
};
actions: Array<{
key: string;
status: 'ready' | 'blocked' | 'warning';
message: string;
}>;
warnings: string[];
errors: string[];
confirmationPhrase: string;
previewHash: string;
confirmationToken: string;
expiresAt: string;
}
export interface ReseedExecuteReport extends ReseedPreviewReport {
executedAt: string;
executed: boolean;
}

View File

@@ -0,0 +1,41 @@
import 'server-only';
import type { ResetScope, ReseedTarget } from './reset-report';
export const RESET_SCOPES: ResetScope[] = [
'foundation_reset',
'organization_reset',
'business_reset',
'demo_uat_reset',
'full_reset',
'csv_import_rollback',
'setup_run_reset'
];
export const RESEED_TARGETS: ReseedTarget[] = ['foundation', 'demo_uat', 'csv_manifest'];
export const ORGANIZATION_AWARE_RESET_SCOPES = new Set<ResetScope>([
'foundation_reset',
'organization_reset',
'business_reset'
]);
export function getConfirmationPhrase(input: {
scope?: ResetScope;
target?: ReseedTarget;
organizationCode?: string | null;
manifestId?: string | null;
setupRunId?: string | null;
}) {
if (input.scope === 'business_reset') return `RESET BUSINESS ${input.organizationCode ?? input.organizationCode ?? 'ORG'}`;
if (input.scope === 'foundation_reset') return `RESET FOUNDATION ${input.organizationCode ?? 'ORG'}`;
if (input.scope === 'organization_reset') return `RESET ORGANIZATION ${input.organizationCode ?? 'ORG'}`;
if (input.scope === 'setup_run_reset') return `RESET SETUP RUN ${input.setupRunId ?? 'CURRENT'}`;
if (input.scope === 'csv_import_rollback') return `ROLLBACK CSV MANIFEST ${input.manifestId ?? 'MANIFEST'}`;
if (input.scope === 'demo_uat_reset') return 'RESET DEMO UAT';
if (input.scope === 'full_reset') return 'FULL RESET LOCAL DATABASE';
if (input.target === 'foundation') return 'RESEED FOUNDATION';
if (input.target === 'demo_uat') return 'RESEED DEMO UAT';
if (input.target === 'csv_manifest') return `RESEED CSV MANIFEST ${input.manifestId ?? 'MANIFEST'}`;
return 'CONFIRM SETUP MAINTENANCE';
}

View File

@@ -0,0 +1,393 @@
import 'server-only';
import { eq, inArray, sql } from 'drizzle-orm';
import {
seedManifestItems,
seedManifests,
setupRuns,
setupRunSteps
} from '@/db/schema';
import { db } from '@/lib/db';
import { hashPreviewPayload, getEnvironmentBlockers, validateConfirmationToken, withToken } from './reset-guards';
import {
getConfirmationPhrase,
ORGANIZATION_AWARE_RESET_SCOPES,
RESET_SCOPES
} from './reset-scopes';
import type { ResetAffectedTable, ResetExecuteReport, ResetPreviewReport, ResetScope } from './reset-report';
export interface ResetPreviewInput {
scope: ResetScope;
organizationId?: string | null;
organizationCode?: string | null;
manifestId?: string | null;
setupRunId?: string | null;
options?: {
force?: boolean;
allowFullReset?: boolean;
};
}
export interface ResetExecuteInput extends ResetPreviewInput {
previewHash: string;
confirmationToken: string;
confirmationPhrase: string;
actorId: string;
}
const BUSINESS_TABLES = [
'crm_approval_actions',
'crm_approval_requests',
'crm_quotation_attachments',
'crm_quotation_followups',
'crm_quotation_topic_items',
'crm_quotation_topics',
'crm_quotation_customers',
'crm_quotation_items',
'crm_quotations',
'crm_opportunity_attachments',
'crm_opportunity_customers',
'crm_opportunity_followups',
'crm_opportunities',
'crm_contact_shares',
'crm_customer_contacts',
'crm_customer_owner_history',
'crm_leads',
'crm_customers'
] as const;
const FOUNDATION_TABLES = [
'crm_approval_matrices',
'crm_approval_steps',
'crm_approval_workflows',
'document_sequences',
'ms_options'
] as const;
function isResetScope(value: string): value is ResetScope {
return RESET_SCOPES.includes(value as ResetScope);
}
async function countRows(table: string, organizationId?: string | null) {
const result = await db.execute(
organizationId
? sql`select count(*)::int as count from ${sql.raw(table)} where organization_id = ${organizationId}`
: sql`select count(*)::int as count from ${sql.raw(table)}`
);
const row = result[0] as { count?: number | string } | undefined;
return Number(row?.count ?? 0);
}
async function countRowsByColumn(table: string, column: string, value: string) {
const result = await db.execute(
sql`select count(*)::int as count from ${sql.raw(table)} where ${sql.raw(column)} = ${value}`
);
const row = result[0] as { count?: number | string } | undefined;
return Number(row?.count ?? 0);
}
async function countSeedManifestItemsForRun(setupRunId: string) {
const manifests = await db.select().from(seedManifests).where(eq(seedManifests.setupRunId, setupRunId));
const manifestIds = manifests.map((manifest) => manifest.id);
if (manifestIds.length === 0) return 0;
const result = await db
.select()
.from(seedManifestItems)
.where(inArray(seedManifestItems.seedManifestId, manifestIds));
return result.length;
}
function buildSummary(affectedTables: ResetAffectedTable[], warnings: string[], errors: string[]) {
return {
tables: affectedTables.length,
rows: affectedTables.reduce((total, table) => total + table.estimatedRows, 0),
warnings: warnings.length,
errors: errors.length
};
}
async function getManifestImpact(input: ResetPreviewInput): Promise<ResetPreviewReport['manifestImpact']> {
if (input.scope === 'setup_run_reset') {
const manifests = input.setupRunId
? await db.select().from(seedManifests).where(eq(seedManifests.setupRunId, input.setupRunId))
: await db.select().from(seedManifests);
return manifests.map((manifest) => ({
id: manifest.id,
name: manifest.name,
version: manifest.version,
status: manifest.status,
action: 'deleted'
}));
}
if (input.scope === 'csv_import_rollback' && input.manifestId) {
const manifests = await db.select().from(seedManifests).where(eq(seedManifests.id, input.manifestId));
return manifests.map((manifest) => ({
id: manifest.id,
name: manifest.name,
version: manifest.version,
status: manifest.status,
action: 'warning'
}));
}
if (input.organizationId) {
const manifests = await db
.select()
.from(seedManifests)
.where(eq(seedManifests.organizationId, input.organizationId));
return manifests.map((manifest) => ({
id: manifest.id,
name: manifest.name,
version: manifest.version,
status: manifest.status,
action: input.scope === 'business_reset' ? 'rolled_back' : 'warning'
}));
}
return [];
}
async function buildAffectedTables(input: ResetPreviewInput) {
if (input.scope === 'business_reset' && input.organizationId) {
return Promise.all(
BUSINESS_TABLES.map(async (table) => ({
table,
estimatedRows: await countRows(table, input.organizationId),
action: 'delete' as const,
retainedData: 'Organization, users, memberships, foundation rows, and data outside target organization.',
deletedData: `Business rows for organization ${input.organizationId}.`
}))
);
}
if (input.scope === 'foundation_reset' && input.organizationId) {
return Promise.all(
FOUNDATION_TABLES.map(async (table) => ({
table,
estimatedRows: await countRows(table, input.organizationId),
action: 'blocked' as const,
retainedData: 'Business data and users are retained.',
deletedData: 'Foundation rows require follow-up service-specific reseed support before deletion.'
}))
);
}
if (input.scope === 'setup_run_reset') {
const runCount = input.setupRunId
? await countRowsByColumn('setup_runs', 'id', input.setupRunId)
: await countRows('setup_runs');
return [
{
table: 'setup_runs',
estimatedRows: runCount,
action: 'delete' as const,
retainedData: 'Business, organization, user, and foundation data.',
deletedData: input.setupRunId ? `Setup run ${input.setupRunId}.` : 'All setup run metadata.'
},
{
table: 'setup_run_steps',
estimatedRows: input.setupRunId
? await countRowsByColumn('setup_run_steps', 'setup_run_id', input.setupRunId)
: await countRows('setup_run_steps'),
action: 'delete' as const,
retainedData: 'Actual CRM and foundation data.',
deletedData: 'Setup step reports.'
},
{
table: 'seed_manifests',
estimatedRows: input.setupRunId
? await countRowsByColumn('seed_manifests', 'setup_run_id', input.setupRunId)
: await countRows('seed_manifests'),
action: 'delete' as const,
retainedData: 'Actual imported data remains untouched.',
deletedData: 'Seed manifest metadata.'
},
{
table: 'seed_manifest_items',
estimatedRows: input.setupRunId
? await countSeedManifestItemsForRun(input.setupRunId)
: await countRows('seed_manifest_items'),
action: 'delete' as const,
retainedData: 'Actual imported data remains untouched.',
deletedData: 'Seed manifest item metadata.'
}
];
}
return [];
}
export async function previewReset(input: ResetPreviewInput): Promise<ResetPreviewReport> {
if (!isResetScope(input.scope)) {
throw new Error('Unsupported reset scope.');
}
const warnings: string[] = [];
const errors = getEnvironmentBlockers();
if (ORGANIZATION_AWARE_RESET_SCOPES.has(input.scope) && !input.organizationId) {
errors.push('Target organization is required for this reset scope.');
}
if (input.scope === 'full_reset') {
errors.push('Full reset is not exposed through the dashboard API. Use the guarded npm reset script with explicit local confirmation.');
}
if (input.scope === 'demo_uat_reset') {
errors.push('Demo/UAT reset requires persisted UAT seed ownership markers and is blocked until those markers are available.');
}
if (input.scope === 'csv_import_rollback') {
if (!input.manifestId) {
errors.push('CSV import rollback requires a seed manifest id.');
}
warnings.push('CSV import rollback is report-only unless manifest-owned rows are proven safely reversible.');
}
if (input.scope === 'foundation_reset') {
warnings.push('Foundation reset is preview-only until service-specific reseed wrappers are available.');
}
const affectedTables = await buildAffectedTables(input);
const manifestImpact = await getManifestImpact(input);
const confirmationPhrase = getConfirmationPhrase({
scope: input.scope,
organizationCode: input.organizationCode ?? input.organizationId ?? null,
manifestId: input.manifestId ?? null,
setupRunId: input.setupRunId ?? null
});
const previewPayload = {
scope: input.scope,
organizationId: input.organizationId ?? null,
manifestId: input.manifestId ?? null,
affectedTables,
manifestImpact,
warnings,
errors
};
const previewHash = hashPreviewPayload(previewPayload);
return withToken<ResetPreviewReport>({
scope: input.scope,
targetOrganizationId: input.organizationId ?? null,
targetOrganizationCode: input.organizationCode ?? null,
manifestId: input.manifestId ?? null,
summary: buildSummary(affectedTables, warnings, errors),
affectedTables,
retainedData: [...new Set(affectedTables.map((table) => table.retainedData))],
deletedData: [...new Set(affectedTables.map((table) => table.deletedData))],
storageCleanup: {
mode: input.scope === 'business_reset' ? 'best_effort' : 'none',
plannedKeys: [],
warnings:
input.scope === 'business_reset'
? ['Storage cleanup is best-effort and limited to setup-owned temporary or import-owned objects.']
: []
},
manifestImpact,
warnings,
errors,
confirmationPhrase,
previewHash
});
}
async function executeSetupRunReset(input: ResetExecuteInput) {
await db.transaction(async (tx) => {
if (input.setupRunId) {
const manifests = await tx
.select()
.from(seedManifests)
.where(eq(seedManifests.setupRunId, input.setupRunId));
const manifestIds = manifests.map((manifest) => manifest.id);
if (manifestIds.length > 0) {
await tx.delete(seedManifestItems).where(inArray(seedManifestItems.seedManifestId, manifestIds));
}
await tx.delete(seedManifests).where(eq(seedManifests.setupRunId, input.setupRunId));
await tx.delete(setupRunSteps).where(eq(setupRunSteps.setupRunId, input.setupRunId));
await tx.delete(setupRuns).where(eq(setupRuns.id, input.setupRunId));
return;
}
await tx.delete(seedManifestItems);
await tx.delete(seedManifests);
await tx.delete(setupRunSteps);
await tx.delete(setupRuns);
});
}
async function executeBusinessReset(organizationId: string) {
await db.transaction(async (tx) => {
for (const table of BUSINESS_TABLES) {
await tx.execute(sql`delete from ${sql.raw(table)} where organization_id = ${organizationId}`);
}
await tx
.update(seedManifests)
.set({ status: 'rolled_back', updatedAt: new Date() })
.where(eq(seedManifests.organizationId, organizationId));
await tx
.update(setupRunSteps)
.set({ status: 'stale', updatedAt: new Date() })
.where(inArray(setupRunSteps.stepKey, ['csv_commit', 'verification']));
});
}
export async function executeReset(input: ResetExecuteInput): Promise<ResetExecuteReport> {
const preview = await previewReset(input);
const tokenError = validateConfirmationToken({
token: input.confirmationToken,
kind: 'reset',
scope: input.scope,
previewHash: input.previewHash
});
const errors = [...preview.errors];
if (preview.previewHash !== input.previewHash) {
errors.push('Preview hash mismatch. Re-run reset preview.');
}
if (tokenError) errors.push(tokenError);
if (input.confirmationPhrase !== preview.confirmationPhrase) {
errors.push('Typed confirmation phrase does not match preview.');
}
if (errors.length > 0) {
return { ...preview, errors, summary: { ...preview.summary, errors: errors.length }, executedAt: new Date().toISOString(), executed: false, deletedRows: 0, updatedManifests: 0, storageCleanupResult: { deletedKeys: [], leftoverKeys: [], warnings: [] } };
}
const beforeRows = preview.summary.rows;
if (input.scope === 'setup_run_reset') {
await executeSetupRunReset(input);
} else if (input.scope === 'business_reset' && input.organizationId) {
await executeBusinessReset(input.organizationId);
} else {
return {
...preview,
errors: ['This reset scope is preview-only until a reversible, service-specific implementation is available.'],
summary: { ...preview.summary, errors: preview.summary.errors + 1 },
executedAt: new Date().toISOString(),
executed: false,
deletedRows: 0,
updatedManifests: 0,
storageCleanupResult: { deletedKeys: [], leftoverKeys: [], warnings: [] }
};
}
return {
...preview,
executedAt: new Date().toISOString(),
executed: true,
deletedRows: beforeRows,
updatedManifests: preview.manifestImpact.length,
storageCleanupResult: {
deletedKeys: [],
leftoverKeys: [],
warnings: preview.storageCleanup.warnings
}
};
}