The "Import a first file" step counted `imported_files` rows in status `completed`. `executeImport` writes that row before its first insert, so an import that added nothing (a file holding only its header, or one whose rows were all in error in the preview) ticked the step: the Import card then said "Done." at rest and sent the user to /transactions, which had nothing to categorize and led back to the import. The counter now keeps a completed file only when a transaction points at it (`EXISTS` on `transactions.file_id`, covered by `idx_transactions_file`). Replayed with sqlite3 on `consolidated_schema.sql`: 0 after an import without a valid row, 0 before a successful import's first insert and 1 after, 0 once the file turns to `error`. Duplicates only change nothing: their transactions came with an earlier completed file, and a restore writes its own. The tests run without SQLite, so the rule is pinned on the query, and the Import page is pinned at rest after such an import: step 4 again, on the first source card, without "Done.". Review of #398 (PR #404), option A chosen by Max. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
import { getDb } from "./db";
|
|
import { notifyDataChanged } from "./dataEvents";
|
|
import { isLinkedTransactionFkError } from "./balance.service";
|
|
import { TransactionLinkedToBalanceError } from "./transactionService";
|
|
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
|
|
|
|
export async function getFilesBySourceId(
|
|
sourceId: number
|
|
): Promise<ImportedFile[]> {
|
|
const db = await getDb();
|
|
return db.select<ImportedFile[]>(
|
|
"SELECT * FROM imported_files WHERE source_id = $1 ORDER BY import_date DESC",
|
|
[sourceId]
|
|
);
|
|
}
|
|
|
|
export async function existsByHash(
|
|
fileHash: string
|
|
): Promise<ImportedFile | null> {
|
|
const db = await getDb();
|
|
const rows = await db.select<ImportedFile[]>(
|
|
"SELECT * FROM imported_files WHERE file_hash = $1",
|
|
[fileHash]
|
|
);
|
|
return rows.length > 0 ? rows[0] : null;
|
|
}
|
|
|
|
export async function createImportedFile(file: {
|
|
source_id: number;
|
|
filename: string;
|
|
file_hash: string;
|
|
row_count: number;
|
|
status: string;
|
|
notes?: string;
|
|
}): Promise<number> {
|
|
const db = await getDb();
|
|
// Check if file already exists by filename (e.g. re-import of same file)
|
|
const existing = await db.select<ImportedFile[]>(
|
|
"SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
|
|
[file.source_id, file.filename]
|
|
);
|
|
// Both branches write the status that the "import a first file" step reads
|
|
// (`status = 'completed'`, counted once the file brought a transaction), so
|
|
// each one is announced (ADR 0022).
|
|
if (existing.length > 0) {
|
|
await db.execute(
|
|
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,
|
|
[file.file_hash, file.row_count, file.status, file.notes || null, existing[0].id]
|
|
);
|
|
notifyDataChanged();
|
|
return existing[0].id;
|
|
}
|
|
|
|
const result = await db.execute(
|
|
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[
|
|
file.source_id,
|
|
file.filename,
|
|
file.file_hash,
|
|
file.row_count,
|
|
file.status,
|
|
file.notes || null,
|
|
]
|
|
);
|
|
notifyDataChanged();
|
|
return result.lastInsertId as number;
|
|
}
|
|
|
|
export async function updateFileStatus(
|
|
id: number,
|
|
status: string,
|
|
rowCount?: number,
|
|
notes?: string
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.execute(
|
|
`UPDATE imported_files SET status = $1, row_count = COALESCE($2, row_count), notes = COALESCE($3, notes) WHERE id = $4`,
|
|
[status, rowCount ?? null, notes ?? null, id]
|
|
);
|
|
notifyDataChanged();
|
|
}
|
|
|
|
export async function getAllImportedFiles(): Promise<ImportedFileWithSource[]> {
|
|
const db = await getDb();
|
|
return db.select<ImportedFileWithSource[]>(
|
|
`SELECT f.*, s.name AS source_name
|
|
FROM imported_files f
|
|
JOIN import_sources s ON s.id = f.source_id
|
|
ORDER BY f.import_date DESC`
|
|
);
|
|
}
|
|
|
|
export async function deleteImportWithTransactions(
|
|
fileId: number
|
|
): Promise<number> {
|
|
const db = await getDb();
|
|
|
|
// Look up the source_id before deleting
|
|
const files = await db.select<ImportedFile[]>(
|
|
"SELECT source_id FROM imported_files WHERE id = $1",
|
|
[fileId]
|
|
);
|
|
const sourceId = files.length > 0 ? files[0].source_id : null;
|
|
|
|
// Pre-flight: if any transaction in this file is linked to a balance
|
|
// account via `balance_account_transfers`, the FK RESTRICT will fire on
|
|
// the bulk DELETE. Surface a typed error BEFORE touching the row so the
|
|
// UI can prompt the user to unlink first (Issue #142).
|
|
const linked = await db.select<
|
|
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
|
|
>(
|
|
`SELECT bat.transaction_id AS transaction_id,
|
|
bat.account_id AS account_id,
|
|
a.name AS account_name,
|
|
bat.direction AS direction
|
|
FROM balance_account_transfers bat
|
|
JOIN transactions t ON t.id = bat.transaction_id
|
|
JOIN balance_accounts a ON a.id = bat.account_id
|
|
WHERE t.file_id = $1`,
|
|
[fileId]
|
|
);
|
|
if (linked.length > 0) {
|
|
throw new TransactionLinkedToBalanceError(null, linked);
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await db.execute(
|
|
"DELETE FROM transactions WHERE file_id = $1",
|
|
[fileId]
|
|
);
|
|
} catch (err) {
|
|
if (isLinkedTransactionFkError(err)) {
|
|
throw new TransactionLinkedToBalanceError(null, []);
|
|
}
|
|
throw err;
|
|
}
|
|
await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
|
|
|
|
// Clean up orphaned source if no files remain
|
|
if (sourceId) {
|
|
const remaining = await db.select<Array<{ cnt: number }>>(
|
|
"SELECT COUNT(*) AS cnt FROM imported_files WHERE source_id = $1",
|
|
[sourceId]
|
|
);
|
|
if (remaining[0]?.cnt === 0) {
|
|
await db.execute("DELETE FROM import_sources WHERE id = $1", [sourceId]);
|
|
}
|
|
}
|
|
|
|
// Announced once every delete went through; a refused or failed delete
|
|
// throws above and announces nothing (ADR 0022).
|
|
notifyDataChanged();
|
|
return result.rowsAffected;
|
|
}
|
|
|
|
export async function deleteAllImportsWithTransactions(): Promise<number> {
|
|
const db = await getDb();
|
|
// Same pre-flight as the per-file path: if ANY transaction is linked to
|
|
// a balance account, the bulk wipe would explode on FK RESTRICT — surface
|
|
// a typed error so the UI can prompt the user to unlink first.
|
|
const linked = await db.select<
|
|
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
|
|
>(
|
|
`SELECT bat.transaction_id AS transaction_id,
|
|
bat.account_id AS account_id,
|
|
a.name AS account_name,
|
|
bat.direction AS direction
|
|
FROM balance_account_transfers bat
|
|
JOIN balance_accounts a ON a.id = bat.account_id
|
|
LIMIT 50`
|
|
);
|
|
if (linked.length > 0) {
|
|
throw new TransactionLinkedToBalanceError(null, linked);
|
|
}
|
|
let result;
|
|
try {
|
|
result = await db.execute("DELETE FROM transactions");
|
|
} catch (err) {
|
|
if (isLinkedTransactionFkError(err)) {
|
|
throw new TransactionLinkedToBalanceError(null, []);
|
|
}
|
|
throw err;
|
|
}
|
|
await db.execute("DELETE FROM imported_files");
|
|
await db.execute("DELETE FROM import_sources");
|
|
notifyDataChanged();
|
|
return result.rowsAffected;
|
|
}
|