task-d.7.2

This commit is contained in:
phaichayon
2026-07-02 13:42:09 +07:00
parent bca8d3f7e0
commit 0ae4ae3e38
22 changed files with 4423 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import { queryOptions } from '@tanstack/react-query';
import { getSetupReadiness, verifySetup } from './service';
import type { SetupVerificationPayload } from './types';
export const setupKeys = {
all: ['setup'] as const,
readiness: () => [...setupKeys.all, 'readiness'] as const,
verification: (payload: SetupVerificationPayload = {}) =>
[...setupKeys.all, 'verification', payload] as const
};
export const setupReadinessQueryOptions = () =>
queryOptions({
queryKey: setupKeys.readiness(),
queryFn: () => getSetupReadiness()
});
export const setupVerificationQueryOptions = (payload: SetupVerificationPayload = {}) =>
queryOptions({
queryKey: setupKeys.verification(payload),
queryFn: () => verifySetup(payload)
});

View File

@@ -0,0 +1,19 @@
import { apiClient } from '@/lib/api-client';
import type {
SetupReadinessReport,
SetupVerificationPayload,
SetupVerificationReport
} from './types';
export async function getSetupReadiness(): Promise<SetupReadinessReport> {
return apiClient<SetupReadinessReport>('/setup/readiness');
}
export async function verifySetup(
payload: SetupVerificationPayload = {}
): Promise<SetupVerificationReport> {
return apiClient<SetupVerificationReport>('/setup/verify', {
method: 'POST',
body: JSON.stringify(payload)
});
}

View File

@@ -0,0 +1,39 @@
export type SetupCheckStatus = 'PASS' | 'WARNING' | 'FAIL';
export interface SetupCheckResult {
id: string;
area: string;
name: string;
status: SetupCheckStatus;
requiredFor: string[];
purpose: string;
message: string;
suggestedFix?: string;
details?: Record<string, unknown>;
checkedAt: string;
}
export interface SetupReportSummary {
pass: number;
warning: number;
fail: number;
total: number;
}
export interface SetupReadinessReport {
success: boolean;
time: string;
status: SetupCheckStatus;
message: string;
summary: SetupReportSummary;
checks: SetupCheckResult[];
}
export interface SetupVerificationPayload {
organizationId?: string;
administratorEmail?: string;
}
export interface SetupVerificationReport extends SetupReadinessReport {
organizationId: string | null;
}

View File

@@ -0,0 +1,408 @@
import 'server-only';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { and, count, eq, inArray, isNull, sql } from 'drizzle-orm';
import {
crmApprovalWorkflows,
crmDocumentTemplateMappings,
crmDocumentTemplateVersions,
crmDocumentTemplates,
crmReportDefinitions,
documentSequences,
memberships,
msOptions,
organizations,
users
} from '@/db/schema';
import { EmailNotificationProvider } from '@/features/foundation/notifications/server/email-provider';
import { getStorageProvider } from '@/features/foundation/storage/service';
import { db } from '@/lib/db';
import type {
SetupCheckResult,
SetupCheckStatus,
SetupReadinessReport,
SetupReportSummary
} from '../api/types';
type CheckMeta = Omit<SetupCheckResult, 'status' | 'message' | 'details' | 'checkedAt'>;
const REQUIRED_TABLES = [
{ name: 'users', table: users },
{ name: 'organizations', table: organizations },
{ name: 'memberships', table: memberships },
{ name: 'ms_options', table: msOptions },
{ name: 'document_sequences', table: documentSequences },
{ name: 'crm_approval_workflows', table: crmApprovalWorkflows },
{ name: 'crm_document_templates', table: crmDocumentTemplates },
{ name: 'crm_report_definitions', table: crmReportDefinitions }
] as const;
function now() {
return new Date().toISOString();
}
export function buildSetupCheck(
meta: CheckMeta,
status: SetupCheckStatus,
message: string,
details?: Record<string, unknown>
): SetupCheckResult {
return {
...meta,
status,
message,
details,
checkedAt: now()
};
}
export function summarizeSetupChecks(checks: SetupCheckResult[]): SetupReportSummary {
return {
pass: checks.filter((check) => check.status === 'PASS').length,
warning: checks.filter((check) => check.status === 'WARNING').length,
fail: checks.filter((check) => check.status === 'FAIL').length,
total: checks.length
};
}
export function resolveSetupStatus(summary: SetupReportSummary): SetupCheckStatus {
if (summary.fail > 0) return 'FAIL';
if (summary.warning > 0) return 'WARNING';
return 'PASS';
}
export function buildSetupReport(
checks: SetupCheckResult[],
message: string
): SetupReadinessReport {
const summary = summarizeSetupChecks(checks);
const status = resolveSetupStatus(summary);
return {
success: status !== 'FAIL',
time: now(),
status,
message,
summary,
checks
};
}
async function runSafeCheck(
meta: CheckMeta,
run: () => Promise<SetupCheckResult>
): Promise<SetupCheckResult> {
try {
return await run();
} catch {
return buildSetupCheck(
meta,
'FAIL',
'The setup check failed unexpectedly.',
{ error: 'Unexpected setup check failure' }
);
}
}
export function checkAuthSecret(): SetupCheckResult {
const meta: CheckMeta = {
id: 'ENV_AUTH_SECRET',
area: 'Environment',
name: 'Auth secret present',
requiredFor: ['first login'],
purpose: 'Ensure Auth.js can sign sessions.',
suggestedFix: 'Set AUTH_SECRET in .env.local or deployment secrets.'
};
const value = process.env.AUTH_SECRET?.trim() ?? '';
if (!value) return buildSetupCheck(meta, 'FAIL', 'AUTH_SECRET is not configured.');
if (value.length < 32) {
return buildSetupCheck(meta, 'WARNING', 'AUTH_SECRET is present but looks short.');
}
return buildSetupCheck(meta, 'PASS', 'AUTH_SECRET is configured.');
}
export function checkDatabaseUrl(): SetupCheckResult {
const meta: CheckMeta = {
id: 'ENV_DATABASE_URL',
area: 'Environment',
name: 'Database URL present',
requiredFor: ['all setup'],
purpose: 'Ensure app can connect to PostgreSQL.',
suggestedFix: 'Set DATABASE_URL.'
};
const value = process.env.DATABASE_URL?.trim() ?? '';
if (!value) return buildSetupCheck(meta, 'FAIL', 'DATABASE_URL is not configured.');
if (/localhost|127\.0\.0\.1/.test(value) && process.env.NODE_ENV === 'production') {
return buildSetupCheck(meta, 'WARNING', 'DATABASE_URL points to a local host in production.');
}
return buildSetupCheck(meta, 'PASS', 'DATABASE_URL is configured.');
}
export async function checkDatabaseConnectivity(): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'DB_CONNECTIVITY',
area: 'Database',
name: 'Database connectivity',
requiredFor: ['all setup'],
purpose: 'Prove DB is reachable.',
suggestedFix: 'Verify database host, network, and credentials.'
};
return runSafeCheck(meta, async () => {
const startedAt = Date.now();
await db.execute(sql`select 1`);
const latencyMs = Date.now() - startedAt;
if (latencyMs > 1000) {
return buildSetupCheck(meta, 'WARNING', 'Database is reachable but responding slowly.', {
latencyMs
});
}
return buildSetupCheck(meta, 'PASS', 'Database connectivity check passed.', { latencyMs });
});
}
export async function checkMigrationStatus(): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'DB_MIGRATIONS',
area: 'Database',
name: 'Migration status',
requiredFor: ['all setup'],
purpose: 'Prevent setup against stale schema.',
suggestedFix: 'Run npm run db:migrate.'
};
return runSafeCheck(meta, async () => {
await Promise.all(
REQUIRED_TABLES.map(({ table }) => db.select({ value: count() }).from(table).limit(1))
);
return buildSetupCheck(meta, 'PASS', 'Required setup tables are queryable.', {
checkedTables: REQUIRED_TABLES.map((table) => table.name)
});
});
}
export async function checkStorageProvider(): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'STORAGE_PROVIDER',
area: 'Storage',
name: 'Storage provider smoke test',
requiredFor: ['document library/PDF'],
purpose: 'Prove seed/document storage can write.',
suggestedFix: 'Fix local directory or S3 credentials.'
};
return runSafeCheck(meta, async () => {
const provider = getStorageProvider();
const key = `setup/readiness/${crypto.randomUUID()}.txt`;
await provider.putObject({
key,
body: Buffer.from('setup-readiness-smoke-test'),
contentType: 'text/plain',
fileName: 'readiness.txt'
});
const exists = await provider.objectExists({ key });
try {
await provider.deleteObject({ key });
} catch {
return buildSetupCheck(
meta,
'WARNING',
'Storage write/read succeeded, but cleanup failed.',
{ key, exists }
);
}
if (!exists) {
return buildSetupCheck(meta, 'FAIL', 'Storage object was not readable after write.', { key });
}
return buildSetupCheck(meta, 'PASS', 'Storage write/read/delete smoke test passed.', { key });
});
}
export async function checkUploadDirectory(): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'UPLOAD_DIRECTORY',
area: 'Storage',
name: 'Upload directory readiness',
requiredFor: ['local storage'],
purpose: 'Prove local provider path is writable.',
suggestedFix: 'Create the upload directory or fix permissions.'
};
return runSafeCheck(meta, async () => {
const provider = process.env.STORAGE_PROVIDER?.trim() || 'local';
if (provider !== 'local') {
return buildSetupCheck(
meta,
'WARNING',
'Upload directory check is only applicable to the local storage provider.',
{ provider }
);
}
const root = path.resolve(process.cwd(), process.env.LOCAL_STORAGE_ROOT?.trim() || 'storage');
const directory = path.join(root, 'setup-readiness');
const filePath = path.join(directory, `${crypto.randomUUID()}.txt`);
await mkdir(directory, { recursive: true });
await writeFile(filePath, 'setup-upload-directory-check');
await rm(filePath, { force: true });
return buildSetupCheck(meta, 'PASS', 'Local upload directory is writable.', { directory });
});
}
export async function checkPdfTemplateAsset(
organizationId?: string | null
): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'PDF_TEMPLATE_ASSET',
area: 'PDF',
name: 'PDF template assets exist',
requiredFor: ['quotation'],
purpose: 'Ensure quotation PDF can render.',
suggestedFix: 'Run foundation/PDF template seed.'
};
return runSafeCheck(meta, async () => {
if (!organizationId) {
return buildSetupCheck(
meta,
'WARNING',
'No organization context was available for PDF template verification.'
);
}
const templates = await db
.select({ id: crmDocumentTemplates.id })
.from(crmDocumentTemplates)
.where(
and(
eq(crmDocumentTemplates.organizationId, organizationId),
eq(crmDocumentTemplates.documentType, 'quotation'),
eq(crmDocumentTemplates.fileType, 'pdfme'),
eq(crmDocumentTemplates.isActive, true),
isNull(crmDocumentTemplates.deletedAt)
)
);
if (!templates.length) {
return buildSetupCheck(meta, 'FAIL', 'No active quotation PDF template exists.');
}
const templateIds = templates.map((template) => template.id);
const versions = await db
.select({ id: crmDocumentTemplateVersions.id })
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
inArray(crmDocumentTemplateVersions.templateId, templateIds),
eq(crmDocumentTemplateVersions.isActive, true),
isNull(crmDocumentTemplateVersions.deletedAt)
)
);
if (!versions.length) {
return buildSetupCheck(meta, 'FAIL', 'No active quotation PDF template version exists.', {
templateCount: templates.length
});
}
return buildSetupCheck(meta, 'PASS', 'Active quotation PDF template and version exist.', {
templateCount: templates.length,
activeVersionCount: versions.length
});
});
}
export async function checkPdfMappingCoverage(
organizationId?: string | null
): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'PDF_MAPPING_COVERAGE',
area: 'PDF',
name: 'PDF mappings exist',
requiredFor: ['quotation'],
purpose: 'Ensure placeholders can resolve.',
suggestedFix: 'Run PDF mapping seed/audit.'
};
return runSafeCheck(meta, async () => {
if (!organizationId) {
return buildSetupCheck(
meta,
'WARNING',
'No organization context was available for PDF mapping verification.'
);
}
const versions = await db
.select({ id: crmDocumentTemplateVersions.id })
.from(crmDocumentTemplateVersions)
.innerJoin(
crmDocumentTemplates,
eq(crmDocumentTemplates.id, crmDocumentTemplateVersions.templateId)
)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
eq(crmDocumentTemplateVersions.isActive, true),
isNull(crmDocumentTemplateVersions.deletedAt),
eq(crmDocumentTemplates.documentType, 'quotation'),
eq(crmDocumentTemplates.fileType, 'pdfme'),
isNull(crmDocumentTemplates.deletedAt)
)
);
const versionIds = versions.map((row) => row.id);
if (!versionIds.length) {
return buildSetupCheck(meta, 'FAIL', 'No active quotation PDF template version exists.');
}
const [mappingCount] = await db
.select({ value: count() })
.from(crmDocumentTemplateMappings)
.where(
and(
eq(crmDocumentTemplateMappings.organizationId, organizationId),
inArray(crmDocumentTemplateMappings.templateVersionId, versionIds),
isNull(crmDocumentTemplateMappings.deletedAt)
)
);
const total = Number(mappingCount?.value ?? 0);
if (total === 0) return buildSetupCheck(meta, 'FAIL', 'No PDF template mappings exist.');
if (total < 10) {
return buildSetupCheck(meta, 'WARNING', 'PDF mappings exist but coverage looks low.', {
mappingCount: total
});
}
return buildSetupCheck(meta, 'PASS', 'PDF mappings exist for the active quotation template.', {
mappingCount: total
});
});
}
export async function checkEmailConfig(): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'EMAIL_CONFIG',
area: 'Email',
name: 'Email configuration',
requiredFor: ['optional notifications'],
purpose: 'Confirm optional email readiness.',
suggestedFix: 'Fix SMTP provider settings.'
};
return runSafeCheck(meta, async () => {
const hasSmtpConfig = Boolean(process.env.SMTP_HOST?.trim() || process.env.SMTP_FROM?.trim());
if (!hasSmtpConfig) {
return buildSetupCheck(
meta,
'WARNING',
'SMTP email is not configured; in-app notifications can still work.'
);
}
const provider = new EmailNotificationProvider();
await provider.validate();
return buildSetupCheck(meta, 'PASS', 'SMTP email configuration is valid.');
});
}
export async function getSetupReadinessReport(input: {
organizationId?: string | null;
} = {}): Promise<SetupReadinessReport> {
const checks = await Promise.all([
Promise.resolve(checkAuthSecret()),
Promise.resolve(checkDatabaseUrl()),
checkDatabaseConnectivity(),
checkMigrationStatus(),
checkStorageProvider(),
checkUploadDirectory(),
checkPdfTemplateAsset(input.organizationId),
checkPdfMappingCoverage(input.organizationId),
checkEmailConfig()
]);
return buildSetupReport(checks, 'Setup readiness checks completed.');
}

View File

@@ -0,0 +1,435 @@
import 'server-only';
import { and, count, eq, isNull } from 'drizzle-orm';
import {
appNotificationTemplates,
crmApprovalMatrices,
crmApprovalWorkflows,
crmReportDefinitions,
crmRoleProfiles,
crmUserRoleAssignments,
documentSequences,
memberships,
msOptions,
organizations,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import type { SetupCheckResult, SetupVerificationReport } from '../api/types';
import {
buildSetupCheck,
buildSetupReport,
checkEmailConfig,
checkPdfMappingCoverage,
checkPdfTemplateAsset
} from './readiness.service';
type VerificationInput = {
organizationId?: string | null;
administratorEmail?: string | null;
};
async function resolveOrganizationId(input: VerificationInput): Promise<string | null> {
if (input.organizationId) {
const [organization] = await db
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.id, input.organizationId))
.limit(1);
return organization?.id ?? null;
}
return null;
}
async function resolveAdminUser(input: VerificationInput, organizationId: string | null) {
if (input.administratorEmail) {
const [user] = await db
.select()
.from(users)
.where(eq(users.email, input.administratorEmail.trim().toLowerCase()))
.limit(1);
return user ?? null;
}
if (organizationId) {
const [adminMembership] = await db
.select({ user: users })
.from(memberships)
.innerJoin(users, eq(users.id, memberships.userId))
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.role, 'admin')))
.limit(1);
return adminMembership?.user ?? null;
}
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
if (!email) return null;
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
return user ?? null;
}
async function countActiveOption(organizationId: string, category: string) {
const [row] = await db
.select({ value: count() })
.from(msOptions)
.where(
and(
eq(msOptions.organizationId, organizationId),
eq(msOptions.category, category),
eq(msOptions.isActive, true),
isNull(msOptions.deletedAt)
)
);
return Number(row?.value ?? 0);
}
async function countActiveSequences(organizationId: string, documentType: string) {
const [row] = await db
.select({ value: count() })
.from(documentSequences)
.where(
and(
eq(documentSequences.organizationId, organizationId),
eq(documentSequences.documentType, documentType),
eq(documentSequences.isActive, true)
)
);
return Number(row?.value ?? 0);
}
export async function getSetupVerificationReport(
input: VerificationInput
): Promise<SetupVerificationReport> {
const organizationId = await resolveOrganizationId(input);
const adminUser = await resolveAdminUser(input, organizationId);
const checks: SetupCheckResult[] = [];
checks.push(
buildSetupCheck(
{
id: 'ADMIN_USER',
area: 'Auth',
name: 'First administrator exists',
requiredFor: ['first login'],
purpose: 'Ensure first login actor exists.',
suggestedFix: 'Create administrator step.'
},
adminUser ? 'PASS' : 'FAIL',
adminUser ? 'Administrator user exists.' : 'Administrator user was not found.',
adminUser ? { email: adminUser.email } : undefined
)
);
let adminMembershipRole: string | null = null;
if (organizationId && adminUser) {
const [membership] = await db
.select()
.from(memberships)
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, adminUser.id)))
.limit(1);
adminMembershipRole = membership?.role ?? null;
}
checks.push(
buildSetupCheck(
{
id: 'ADMIN_MEMBERSHIP',
area: 'Auth',
name: 'Administrator membership exists',
requiredFor: ['first login'],
purpose: 'Ensure admin can enter organization.',
suggestedFix: 'Create membership.'
},
adminMembershipRole === 'admin' ? 'PASS' : adminMembershipRole ? 'WARNING' : 'FAIL',
adminMembershipRole === 'admin'
? 'Administrator membership exists with admin role.'
: adminMembershipRole
? 'Administrator membership exists but is not an admin role.'
: 'Administrator membership was not found.',
adminMembershipRole ? { role: adminMembershipRole } : undefined
)
);
let crmAssignmentCount = 0;
let legacyCrmAdmin = false;
if (organizationId && adminUser) {
const [assignmentRow] = await db
.select({ value: count() })
.from(crmUserRoleAssignments)
.innerJoin(crmRoleProfiles, eq(crmRoleProfiles.id, crmUserRoleAssignments.roleProfileId))
.where(
and(
eq(crmUserRoleAssignments.organizationId, organizationId),
eq(crmUserRoleAssignments.userId, adminUser.id),
eq(crmUserRoleAssignments.isActive, true),
isNull(crmUserRoleAssignments.deletedAt),
isNull(crmRoleProfiles.deletedAt),
eq(crmRoleProfiles.code, 'crm_admin')
)
);
crmAssignmentCount = Number(assignmentRow?.value ?? 0);
const [membership] = await db
.select({ businessRole: memberships.businessRole })
.from(memberships)
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, adminUser.id)))
.limit(1);
legacyCrmAdmin = membership?.businessRole === 'crm_admin';
}
checks.push(
buildSetupCheck(
{
id: 'ADMIN_CRM_ASSIGNMENT',
area: 'Authorization',
name: 'Administrator CRM assignment resolves',
requiredFor: ['CRM'],
purpose: 'Ensure CRM admin permissions resolve.',
suggestedFix: 'Seed role profiles and assignment.'
},
crmAssignmentCount > 0 ? 'PASS' : legacyCrmAdmin ? 'WARNING' : 'FAIL',
crmAssignmentCount > 0
? 'CRM administrator role assignment exists.'
: legacyCrmAdmin
? 'Only compatibility business role crm_admin was found.'
: 'CRM administrator role assignment was not found.',
{ assignmentCount: crmAssignmentCount }
)
);
const orgExists = Boolean(organizationId);
const adminActiveOrgMatches = Boolean(adminUser && organizationId && adminUser.activeOrganizationId === organizationId);
checks.push(
buildSetupCheck(
{
id: 'ORG_ACTIVE',
area: 'Organization',
name: 'Organization active/resolvable',
requiredFor: ['all setup'],
purpose: 'Ensure active org context works.',
suggestedFix: 'Update users.active_organization_id.'
},
orgExists && adminActiveOrgMatches ? 'PASS' : orgExists ? 'WARNING' : 'FAIL',
orgExists && adminActiveOrgMatches
? 'Organization exists and is active for the administrator.'
: orgExists
? 'Organization exists, but it is not the administrator active organization.'
: 'Organization was not found.',
{ organizationId, adminActiveOrganizationId: adminUser?.activeOrganizationId ?? null }
)
);
if (!organizationId) {
const report = buildSetupReport(checks, 'Setup verification checks completed.');
return { ...report, organizationId };
}
const branchCount = await countActiveOption(organizationId, 'crm_branch');
checks.push(
buildSetupCheck(
{
id: 'BRANCH_OPTION',
area: 'Scope',
name: 'Branch option exists',
requiredFor: ['CRM/quotation'],
purpose: 'Ensure branch scope can resolve.',
suggestedFix: 'Configure branch step.'
},
branchCount > 0 ? 'PASS' : 'FAIL',
branchCount > 0 ? 'Active branch options exist.' : 'No active branch option exists.',
{ count: branchCount }
)
);
const productTypeCount = await countActiveOption(organizationId, 'crm_product_type');
checks.push(
buildSetupCheck(
{
id: 'PRODUCT_TYPE_OPTION',
area: 'Scope',
name: 'Product type option exists',
requiredFor: ['CRM/quotation'],
purpose: 'Ensure product scope can resolve.',
suggestedFix: 'Configure product types.'
},
productTypeCount > 0 ? 'PASS' : 'FAIL',
productTypeCount > 0 ? 'Active product type options exist.' : 'No active product type option exists.',
{ count: productTypeCount }
)
);
const sequenceChecks = [
['SEQUENCE_CUSTOMER', 'Customer sequence preview', 'customer'],
['SEQUENCE_LEAD', 'Lead sequence preview', 'crm_lead'],
['SEQUENCE_OPPORTUNITY', 'Opportunity sequence preview', 'crm_opportunity'],
['SEQUENCE_QUOTATION', 'Quotation sequence preview', 'quotation']
] as const;
for (const [id, name, documentType] of sequenceChecks) {
const sequenceCount = await countActiveSequences(organizationId, documentType);
checks.push(
buildSetupCheck(
{
id,
area: 'Document sequence',
name,
requiredFor: documentType === 'quotation' ? ['quotation'] : ['CRM'],
purpose: `Ensure ${documentType} codes can generate.`,
suggestedFix: 'Configure document sequence.'
},
sequenceCount > 0 ? 'PASS' : 'FAIL',
sequenceCount > 0
? `Active ${documentType} sequence exists.`
: `No active ${documentType} sequence exists.`,
{ documentType, count: sequenceCount }
)
);
}
const [workflowRow] = await db
.select({ value: count() })
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
eq(crmApprovalWorkflows.entityType, 'quotation'),
eq(crmApprovalWorkflows.isActive, true),
isNull(crmApprovalWorkflows.deletedAt)
)
);
const workflowCount = Number(workflowRow?.value ?? 0);
checks.push(
buildSetupCheck(
{
id: 'APPROVAL_WORKFLOW',
area: 'Approval',
name: 'Quotation workflow resolves',
requiredFor: ['quotation'],
purpose: 'Ensure quotation approval can start.',
suggestedFix: 'Configure approval workflow.'
},
workflowCount === 1 ? 'PASS' : workflowCount > 1 ? 'WARNING' : 'FAIL',
workflowCount === 1
? 'One active quotation approval workflow exists.'
: workflowCount > 1
? 'Multiple active quotation approval workflows exist.'
: 'No active quotation approval workflow exists.',
{ count: workflowCount }
)
);
const [matrixRow] = await db
.select({ value: count() })
.from(crmApprovalMatrices)
.where(
and(
eq(crmApprovalMatrices.organizationId, organizationId),
eq(crmApprovalMatrices.entityType, 'quotation'),
eq(crmApprovalMatrices.isActive, true),
isNull(crmApprovalMatrices.deletedAt)
)
);
const matrixCount = Number(matrixRow?.value ?? 0);
checks.push(
buildSetupCheck(
{
id: 'APPROVAL_MATRIX',
area: 'Approval',
name: 'Approval matrix resolves',
requiredFor: ['quotation'],
purpose: 'Ensure quotation can select workflow by scope/amount.',
suggestedFix: 'Configure approval matrix.'
},
matrixCount > 0 ? 'PASS' : 'FAIL',
matrixCount > 0 ? 'Active quotation approval matrix rows exist.' : 'No active quotation approval matrix exists.',
{ count: matrixCount }
)
);
checks.push(await checkPdfTemplateAsset(organizationId));
checks.push(await checkPdfMappingCoverage(organizationId));
const [notificationRow] = await db
.select({ value: count() })
.from(appNotificationTemplates)
.where(
and(
eq(appNotificationTemplates.organizationId, organizationId),
eq(appNotificationTemplates.channel, 'in_app'),
eq(appNotificationTemplates.isActive, true),
isNull(appNotificationTemplates.deletedAt)
)
);
const notificationCount = Number(notificationRow?.value ?? 0);
checks.push(
buildSetupCheck(
{
id: 'NOTIFICATION_TEMPLATES',
area: 'Notifications',
name: 'Approval notification templates exist',
requiredFor: ['CRM/quotation'],
purpose: 'Ensure in-app approval notifications can render.',
suggestedFix: 'Run foundation seed.'
},
notificationCount > 0 ? 'PASS' : 'FAIL',
notificationCount > 0
? 'Active in-app notification templates exist.'
: 'No active in-app notification template exists.',
{ count: notificationCount }
)
);
const [reportRow] = await db
.select({ value: count() })
.from(crmReportDefinitions)
.where(
and(
eq(crmReportDefinitions.organizationId, organizationId),
eq(crmReportDefinitions.isActive, true)
)
);
const reportCount = Number(reportRow?.value ?? 0);
checks.push(
buildSetupCheck(
{
id: 'REPORT_DEFINITIONS',
area: 'Reports',
name: 'Report definitions exist',
requiredFor: ['CRM reports'],
purpose: 'Ensure report catalog works.',
suggestedFix: 'Run report foundation seed.'
},
reportCount > 0 ? 'PASS' : 'FAIL',
reportCount > 0 ? 'Active report definitions exist.' : 'No active report definitions exist.',
{ count: reportCount }
)
);
checks.push(await checkEmailConfig());
checks.push(
buildSetupCheck(
{
id: 'CSV_PREVIEW',
area: 'Import',
name: 'CSV preview integrity',
requiredFor: ['CSV import'],
purpose: 'Ensure uploaded files were validated before commit.',
suggestedFix: 'Re-run CSV preview.'
},
'WARNING',
'CSV preview tracking is planned for a later setup import slice.'
)
);
checks.push(
buildSetupCheck(
{
id: 'SETUP_MANIFEST',
area: 'Setup',
name: 'Seed manifest recorded',
requiredFor: ['setup completion'],
purpose: 'Ensure lifecycle tracking exists.',
suggestedFix: 'Implement D.7.8.'
},
'WARNING',
'Seed manifest persistence is planned for D.7.8.'
)
);
const report = buildSetupReport(checks, 'Setup verification checks completed.');
return { ...report, organizationId };
}