Simpl-Resultat/src/services/importedFileService.ts
le king fu 0e996a5aa1 feat(transactions): inline transfer icon + FK error message
Issue #142 / Bilan #4 — non-regressive transfer awareness in the
transactions table + clean error mapping on bulk delete.

- `TransactionTable.tsx`: optional new prop
  `linkedTransfersByTxId?: Map<txId, links[]>`. When supplied, a small
  `<Link2>` icon appears next to the description for every linked
  transaction; tooltip lists the account name(s) and direction(s).
  Without the prop, the table renders byte-for-byte identical to
  before — preserves the spec's non-regression invariant.
- `TransactionsPage.tsx`: loads the linked-transfers map once on mount
  via `listAllLinkedTransfersForTooltip()` (one batch SELECT) and
  threads it through to the table. Failure to load the map degrades
  gracefully to an empty map (icon simply doesn't appear).
- `transactionService.ts`: new `deleteTransaction(id)` helper +
  `TransactionLinkedToBalanceError` (typed FK guard). Pre-checks
  `balance_account_transfers` before attempting the DELETE so the
  error carries the offending account names; falls back to the FK
  pattern matcher if a race linked the transaction between the
  SELECT and the DELETE.
- `importedFileService.ts`: both bulk delete paths
  (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`)
  now pre-check for any linked transfer and surface the same typed
  error before they would explode on FK RESTRICT. The pre-check has
  a `LIMIT 50` safety cap on the global path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:46 -04:00

179 lines
5.6 KiB
TypeScript

import { getDb } from "./db";
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]
);
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]
);
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,
]
);
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]
);
}
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]);
}
}
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");
return result.rowsAffected;
}