Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 11 additions & 21 deletions codebenders-dashboard/app/admin/upload/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import { UploadSummary } from "@/components/upload/upload-summary"
import { Button } from "@/components/ui/button"
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react"
import { SCHEMAS, CONFIDENT_THRESHOLD, type ColumnMapping } from "@/lib/upload-schemas"
import type { UploadCommitApiResponse } from "@/lib/upload-validation-report"

type Step = "upload" | "preview" | "complete"

function stepIndicatorClass(i: number, stepIndex: number): string {
if (i < stepIndex) return "text-green-600 line-through"
if (i === stepIndex) return "font-bold text-purple-700 bg-purple-50 px-2 py-0.5 rounded-full"
return "text-muted-foreground"
}

interface PreviewData {
detectedSchema: string | null
detectedSchemaLabel: string | null
Expand All @@ -24,13 +31,6 @@ interface PreviewData {
errors: string[]
}

interface CommitResult {
inserted: number
skipped: number
errors: Array<{ row: number; message: string }>
uploadId: number
}

interface HistoryEntry {
id: number
filename: string
Expand All @@ -48,7 +48,7 @@ export default function UploadPage() {
const [columns, setColumns] = useState<ColumnMapping[]>([])
const [selectedSchema, setSelectedSchema] = useState<string | null>(null)
const [showSchemaOverride, setShowSchemaOverride] = useState(false)
const [commitResult, setCommitResult] = useState<CommitResult | null>(null)
const [commitResult, setCommitResult] = useState<UploadCommitApiResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [recentUploads, setRecentUploads] = useState<HistoryEntry[]>([])
Expand Down Expand Up @@ -123,7 +123,7 @@ export default function UploadPage() {
return
}

const data: CommitResult = await res.json()
const data: UploadCommitApiResponse = await res.json()
setCommitResult(data)
setStep("complete")
} catch (err) {
Expand Down Expand Up @@ -168,15 +168,7 @@ export default function UploadPage() {
{stepLabels.map((label, i) => (
<span key={label} className="flex items-center gap-2">
{i > 0 && <span className="text-muted-foreground">→</span>}
<span
className={
i < stepIndex
? "text-green-600 line-through"
: i === stepIndex
? "font-bold text-purple-700 bg-purple-50 px-2 py-0.5 rounded-full"
: "text-muted-foreground"
}
>
<span className={stepIndicatorClass(i, stepIndex)}>
{i < stepIndex ? `${label} ✓` : `${i + 1}. ${label}`}
</span>
</span>
Expand Down Expand Up @@ -368,9 +360,7 @@ export default function UploadPage() {
<UploadSummary
filename={file?.name ?? ""}
schemaLabel={selectedSchemaLabel ?? "Unknown"}
inserted={commitResult.inserted}
skipped={commitResult.skipped}
errorCount={commitResult.errors.length}
report={commitResult}
onUploadAnother={resetWizard}
onViewHistory={() => router.push("/admin/upload/history")}
/>
Expand Down
163 changes: 147 additions & 16 deletions codebenders-dashboard/app/api/admin/upload/commit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,89 @@ import { NextRequest, NextResponse } from "next/server"
import { parseFileBuffer, getFileType, validateFileSize } from "@/lib/upload-parser"
import { SCHEMAS, type UploadSchema, type ColumnMapping } from "@/lib/upload-schemas"
import { getPool } from "@/lib/db"
import {
computeUploadDiff,
type PreviousUploadSnapshot,
type UploadCommitApiResponse,
type UploadCurrentMetrics,
type UploadHistoryStoredReport,
type UploadRowError,
} from "@/lib/upload-validation-report"

const BATCH_SIZE = 500
const MAX_ERRORS_IN_REPORT = 5000
const MAX_ERRORS_IN_RESPONSE = 3000

type UploadHistoryRow = {
id: string
filename: string
rows_inserted: number
rows_skipped: number
error_count: number
uploaded_at: Date
}

function pgErrorCode(err: unknown): string | undefined {
if (err && typeof err === "object" && "code" in err) {
return String((err as { code: unknown }).code)
}
return undefined
}

function isUndefinedColumnPgError(err: unknown): boolean {
return pgErrorCode(err) === "42703"
}

function previousUploadFromRow(row: UploadHistoryRow): PreviousUploadSnapshot {
return {
id: Number(row.id),
filename: row.filename,
rowsInserted: row.rows_inserted,
rowsSkipped: row.rows_skipped,
errorCount: row.error_count,
uploadedAt: row.uploaded_at.toISOString(),
}
}

function uploadStatus(errorsCount: number, inserted: number): "failed" | "partial" | "success" {
if (errorsCount > 0 && inserted === 0) return "failed"
if (errorsCount > 0) return "partial"
return "success"
}

async function insertUploadHistoryRow(params: {
pool: ReturnType<typeof getPool>
baseInsertParams: readonly [
string,
string,
string,
string,
number,
number,
number,
"failed" | "partial" | "success",
]
reportJson: UploadHistoryStoredReport
}): Promise<number> {
const { pool, baseInsertParams, reportJson } = params
try {
const insertedRow = await pool.query(
`INSERT INTO upload_history (user_id, user_email, filename, file_type, rows_inserted, rows_skipped, error_count, status, validation_report)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) RETURNING id`,
[...baseInsertParams, JSON.stringify(reportJson)]
)
return Number(insertedRow.rows[0].id)
} catch (err: unknown) {
if (!isUndefinedColumnPgError(err)) throw err
}

const insertedRow = await pool.query(
`INSERT INTO upload_history (user_id, user_email, filename, file_type, rows_inserted, rows_skipped, error_count, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[...baseInsertParams]
)
return Number(insertedRow.rows[0].id)
}

export async function POST(request: NextRequest) {
const userId = request.headers.get("x-user-id") ?? ""
Expand Down Expand Up @@ -49,25 +130,75 @@ export async function POST(request: NextRequest) {
const result = await upsertRows(rows, columnMapping, schema)

const pool = getPool()
const status =
result.errors.length > 0 && result.inserted === 0
? "failed"
: result.errors.length > 0
? "partial"
: "success"

const { rows: historyRows } = await pool.query(
`INSERT INTO upload_history (user_id, user_email, filename, file_type, rows_inserted, rows_skipped, error_count, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[userId, userEmail, file.name, schemaId, result.inserted, result.skipped, result.errors.length, status]
const status = uploadStatus(result.errors.length, result.inserted)

const prevRes = await pool.query<UploadHistoryRow>(
`SELECT id, filename, rows_inserted, rows_skipped, error_count, uploaded_at
FROM upload_history
WHERE file_type = $1
ORDER BY uploaded_at DESC
LIMIT 1`,
[schemaId]
)

return NextResponse.json({
const previousUpload: PreviousUploadSnapshot | null = prevRes.rows[0]
? previousUploadFromRow(prevRes.rows[0])
: null

const reportGeneratedAt = new Date().toISOString()
const currentMetrics: UploadCurrentMetrics = {
inserted: result.inserted,
skipped: result.skipped,
errorCount: result.errors.length,
}
const diff = computeUploadDiff(currentMetrics, previousUpload)

const errorsForStorage = result.errors.slice(0, MAX_ERRORS_IN_REPORT)
const reportJson: UploadHistoryStoredReport = {
version: 1,
schemaId,
totalRowsInFile: rows.length,
inserted: result.inserted,
skipped: result.skipped,
errors: result.errors.slice(0, 50),
uploadId: historyRows[0].id,
errors: errorsForStorage,
errorsTotal: result.errors.length,
errorsTruncated: result.errors.length > errorsForStorage.length,
previousUpload,
diff,
generatedAt: reportGeneratedAt,
}

const baseInsertParams = [
userId,
userEmail,
file.name,
schemaId,
result.inserted,
result.skipped,
result.errors.length,
status,
] as const

const historyId = await insertUploadHistoryRow({
pool,
baseInsertParams,
reportJson,
})

const responseBody: UploadCommitApiResponse = {
inserted: result.inserted,
skipped: result.skipped,
errors: result.errors.slice(0, MAX_ERRORS_IN_RESPONSE),
errorsTotal: result.errors.length,
errorsTruncated: result.errors.length > MAX_ERRORS_IN_RESPONSE,
uploadId: historyId,
totalRowsInFile: rows.length,
previousUpload,
diff,
reportGeneratedAt,
}

return NextResponse.json(responseBody)
} catch (err) {
console.error("Upload commit error:", err)
return NextResponse.json(
Expand All @@ -80,7 +211,7 @@ export async function POST(request: NextRequest) {
interface UpsertResult {
inserted: number
skipped: number
errors: Array<{ row: number; column?: string; message: string }>
errors: UploadRowError[]
}

async function upsertRows(
Expand Down Expand Up @@ -176,7 +307,7 @@ async function upsertRows(

const result = await pool.query(batchSql, batchParams)
inserted += result.rowCount ?? 0
} catch (err) {
} catch {
// If batch fails, fall back to per-row to identify the bad row(s)
for (let j = 0; j < batch.length; j++) {
const row = batch[j]
Expand Down
Loading
Loading