task-d.7.2
This commit is contained in:
20
src/app/api/setup/readiness/route.ts
Normal file
20
src/app/api/setup/readiness/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSetupReadinessReport } from '@/features/setup/server/readiness.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await requireSystemRole('super_admin');
|
||||
const report = await getSetupReadinessReport({
|
||||
organizationId: session.user.activeOrganizationId ?? null
|
||||
});
|
||||
|
||||
return NextResponse.json(report);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to run setup readiness checks' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/setup/verify/route.ts
Normal file
37
src/app/api/setup/verify/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getSetupVerificationReport } from '@/features/setup/server/verification.service';
|
||||
import { AuthError, requireSystemRole } from '@/lib/auth/session';
|
||||
|
||||
const verificationSchema = z
|
||||
.object({
|
||||
organizationId: z.string().min(1).optional(),
|
||||
administratorEmail: z.string().email().optional()
|
||||
})
|
||||
.optional();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await requireSystemRole('super_admin');
|
||||
const body = verificationSchema.parse(await request.json().catch(() => undefined)) ?? {};
|
||||
const report = await getSetupVerificationReport({
|
||||
organizationId: body.organizationId ?? session.user.activeOrganizationId ?? null,
|
||||
administratorEmail: body.administratorEmail
|
||||
});
|
||||
|
||||
return NextResponse.json(report);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Invalid setup verification payload', errors: error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to run setup verification checks' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
22
src/features/setup/api/queries.ts
Normal file
22
src/features/setup/api/queries.ts
Normal 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)
|
||||
});
|
||||
19
src/features/setup/api/service.ts
Normal file
19
src/features/setup/api/service.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
39
src/features/setup/api/types.ts
Normal file
39
src/features/setup/api/types.ts
Normal 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;
|
||||
}
|
||||
408
src/features/setup/server/readiness.service.ts
Normal file
408
src/features/setup/server/readiness.service.ts
Normal 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.');
|
||||
}
|
||||
435
src/features/setup/server/verification.service.ts
Normal file
435
src/features/setup/server/verification.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user