task-d.7.5

This commit is contained in:
phaichayon
2026-07-03 09:30:57 +07:00
parent 0b7aeea0a7
commit b7589017ab
7 changed files with 2171 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service';
import { AuthError, requireSystemRole } from '@/lib/auth/session';
function isFile(value: FormDataEntryValue): value is File {
return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value;
}
function parseBoolean(value: FormDataEntryValue | null): boolean {
if (typeof value !== 'string') return false;
return value.trim().toLowerCase() === 'true';
}
export async function POST(request: NextRequest) {
try {
const session = await requireSystemRole('super_admin');
const formData = await request.formData();
const previewHash = String(formData.get('previewHash') ?? '');
const dryRun = parseBoolean(formData.get('dryRun'));
const uploadedFiles = Array.from(formData.values()).filter(isFile);
if (uploadedFiles.length === 0) {
return NextResponse.json(
{ message: 'Upload at least one setup CSV file using multipart/form-data.' },
{ status: 400 }
);
}
const files = await Promise.all(
uploadedFiles.map(async (file) => ({
fileName: file.name,
bytes: new Uint8Array(await file.arrayBuffer())
}))
);
const report = await commitSetupCsvImport({
files,
previewHash,
dryRun,
actorId: session.user.id
});
return NextResponse.json(report, { status: report.status === 'FAIL' ? 400 : 200 });
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable commit setup CSV import' }, { status: 500 });
}
}

View File

@@ -0,0 +1,131 @@
import 'server-only';
import { buildCsvCommitPlan } from './csv/commit-builder';
import { buildImportReport, createImportError, type ImportReport } from './csv/import-report';
import { CsvCommitExecutionError, executeCsvCommitPlan } from './csv/upsert-executor';
import { previewSetupCsvImport } from './csv-preview.service';
import type { CsvPreviewFileInput } from './csv/types';
export interface CommitSetupCsvImportInput {
files: CsvPreviewFileInput[];
previewHash: string;
dryRun?: boolean;
actorId: string;
}
export async function commitSetupCsvImport(input: CommitSetupCsvImportInput): Promise<ImportReport> {
const preview = await previewSetupCsvImport(input.files);
const plan = buildCsvCommitPlan(preview);
if (!input.previewHash) {
return buildImportReport({
previewHash: preview.previewHash,
committedHash: plan.committedHash,
dryRun: Boolean(input.dryRun),
files: [
{
fileName: '__request__',
template: 'setup-csv-commit',
status: 'FAIL',
imported: 0,
updated: 0,
skipped: 0,
errors: [
createImportError({
file: '__request__',
row: 1,
column: 'previewHash',
code: 'PREVIEW_HASH_REQUIRED',
message: 'previewHash is required before committing CSV import.',
suggestedFix: 'Call /api/setup/import/preview and submit the returned previewHash.'
})
],
warnings: [],
rows: []
}
]
});
}
if (preview.previewHash !== input.previewHash) {
return buildImportReport({
previewHash: preview.previewHash,
committedHash: plan.committedHash,
dryRun: Boolean(input.dryRun),
files: [
{
fileName: '__request__',
template: 'setup-csv-commit',
status: 'FAIL',
imported: 0,
updated: 0,
skipped: 0,
errors: [
createImportError({
file: '__request__',
row: 1,
column: 'previewHash',
code: 'PREVIEW_HASH_MISMATCH',
message: 'Submitted previewHash does not match the uploaded CSV files.',
suggestedFix: 'Re-run preview for the exact files being committed and retry with the new previewHash.'
})
],
warnings: [],
rows: []
}
]
});
}
if (preview.summary.error > 0) {
return buildImportReport({
previewHash: preview.previewHash,
committedHash: plan.committedHash,
dryRun: Boolean(input.dryRun),
files: preview.files.map((file) => ({
fileName: file.fileName,
template: file.template,
status: 'FAIL',
imported: 0,
updated: 0,
skipped: 0,
errors: file.errors,
warnings: file.warnings,
rows: file.rows.map((row) => ({
row: row.row,
action: row.action === 'error' ? 'error' : 'skip',
businessKey: row.key,
message: row.action === 'error' ? 'Preview row has blocking errors.' : 'Commit blocked because preview contains errors.',
warnings: row.warnings,
errors: row.errors
}))
}))
});
}
let files;
try {
files = await executeCsvCommitPlan(plan, {
actorId: input.actorId,
dryRun: Boolean(input.dryRun)
});
} catch (error) {
if (error instanceof CsvCommitExecutionError) {
return buildImportReport({
previewHash: preview.previewHash,
committedHash: plan.committedHash,
dryRun: Boolean(input.dryRun),
files: error.files
});
}
throw error;
}
return buildImportReport({
previewHash: preview.previewHash,
committedHash: plan.committedHash,
dryRun: Boolean(input.dryRun),
files
});
}

View File

@@ -0,0 +1,65 @@
import { createHash } from 'node:crypto';
import type { CsvPreviewFileResult, CsvPreviewResult, CsvPreviewRow } from './types';
export interface CsvCommitPlanRow {
fileName: string;
template: string;
importOrder: number;
row: CsvPreviewRow;
}
export interface CsvCommitPlanFile {
fileName: string;
template: string;
importOrder: number;
rows: CsvCommitPlanRow[];
}
export interface CsvCommitPlan {
preview: CsvPreviewResult;
files: CsvCommitPlanFile[];
rows: CsvCommitPlanRow[];
committedHash: string;
}
function stableRowPayload(file: CsvPreviewFileResult, row: CsvPreviewRow) {
return {
fileName: file.fileName,
importOrder: file.importOrder,
row: row.row,
key: row.key,
action: row.action,
values: row.values
};
}
export function buildCsvCommitPlan(preview: CsvPreviewResult): CsvCommitPlan {
const files = preview.files
.toSorted((left, right) => left.importOrder - right.importOrder || left.fileName.localeCompare(right.fileName))
.map((file) => ({
fileName: file.fileName,
template: file.template,
importOrder: file.importOrder,
rows: file.rows
.filter((row) => row.action !== 'error')
.toSorted((left, right) => left.row - right.row)
.map((row) => ({
fileName: file.fileName,
template: file.template,
importOrder: file.importOrder,
row
}))
}));
const rows = files.flatMap((file) => file.rows);
const committedHash = createHash('sha256')
.update(
JSON.stringify({
previewHash: preview.previewHash,
rows: preview.files.flatMap((file) => file.rows.map((row) => stableRowPayload(file, row)))
})
)
.digest('hex');
return { preview, files, rows, committedHash };
}

View File

@@ -0,0 +1,121 @@
import type { CsvPreviewError, CsvPreviewWarning } from './types';
export type ImportStatus = 'PASS' | 'WARNING' | 'FAIL';
export type ImportAction = 'create' | 'update' | 'skip' | 'error';
export interface ImportRowReport {
row: number;
action: ImportAction;
businessKey: string | null;
entityId?: string;
message: string;
warnings: CsvPreviewWarning[];
errors: CsvPreviewError[];
}
export interface ImportFileReport {
fileName: string;
template: string;
status: ImportStatus;
imported: number;
updated: number;
skipped: number;
errors: CsvPreviewError[];
warnings: CsvPreviewWarning[];
rows: ImportRowReport[];
}
export interface ImportReport {
status: ImportStatus;
generatedAt: string;
previewHash: string;
committedHash: string;
dryRun: boolean;
summary: {
imported: number;
updated: number;
skipped: number;
errors: number;
warnings: number;
};
files: ImportFileReport[];
errors: CsvPreviewError[];
warnings: CsvPreviewWarning[];
}
export function createImportError(input: {
file: string;
row: number;
column: string;
code: string;
message: string;
suggestedFix: string;
}): CsvPreviewError {
return input;
}
export function createImportWarning(input: {
file: string;
row: number;
column: string;
code: string;
message: string;
suggestedFix: string;
}): CsvPreviewWarning {
return input;
}
export function buildImportReport(input: {
previewHash: string;
committedHash: string;
dryRun: boolean;
files: ImportFileReport[];
}): ImportReport {
const errors = input.files.flatMap((file) => file.errors);
const warnings = input.files.flatMap((file) => file.warnings);
const summary = input.files.reduce(
(accumulator, file) => ({
imported: accumulator.imported + file.imported,
updated: accumulator.updated + file.updated,
skipped: accumulator.skipped + file.skipped,
errors: accumulator.errors + file.errors.length,
warnings: accumulator.warnings + file.warnings.length
}),
{ imported: 0, updated: 0, skipped: 0, errors: 0, warnings: 0 }
);
return {
status: errors.length > 0 ? 'FAIL' : warnings.length > 0 ? 'WARNING' : 'PASS',
generatedAt: new Date().toISOString(),
previewHash: input.previewHash,
committedHash: input.committedHash,
dryRun: input.dryRun,
summary,
files: input.files,
errors,
warnings
};
}
export function summarizeFileRows(input: {
fileName: string;
template: string;
rows: ImportRowReport[];
warnings?: CsvPreviewWarning[];
}): ImportFileReport {
const errors = input.rows.flatMap((row) => row.errors);
const rowWarnings = input.rows.flatMap((row) => row.warnings);
const warnings = [...rowWarnings, ...(input.warnings ?? [])];
return {
fileName: input.fileName,
template: input.template,
status: errors.length > 0 ? 'FAIL' : warnings.length > 0 ? 'WARNING' : 'PASS',
imported: input.rows.filter((row) => row.action === 'create').length,
updated: input.rows.filter((row) => row.action === 'update').length,
skipped: input.rows.filter((row) => row.action === 'skip').length,
errors,
warnings,
rows: input.rows
};
}

File diff suppressed because it is too large Load Diff