fix(budget): stop receipts deleting files, and share the upload path

Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
This commit is contained in:
Maurice
2026-09-08 21:37:49 +02:00
committed by Maurice
parent 06ecc5135c
commit 6a3d4b1281
34 changed files with 525 additions and 198 deletions
+8 -1
View File
@@ -4425,12 +4425,19 @@ function runMigrations(db: Database.Database): void {
* SQLite treats NULLs as distinct, so the rows that exist today, which all
* carry a NULL here, do not collide with each other.
*
* SET NULL rather than CASCADE, unlike the three columns beside it, because
* those predate the shared row: one row can carry a place link AND a receipt
* link for the same file, and a cascade would delete the whole row when the
* expense goes, silently detaching the file from the place as well. Deleting
* the expense drops the receipt link and nothing else; the budget service
* removes the row afterwards when it carries no other link.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const flCols = db.prepare("SELECT name FROM pragma_table_info('file_links')").all() as Array<{ name: string }>;
if (!flCols.some((c) => c.name === 'budget_item_id')) {
db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE CASCADE');
db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)');
+48 -58
View File
@@ -204,34 +204,36 @@ export class BudgetService {
}
/**
* Move a receipt file to trash if it is only linked to this budget item and
* not referenced by any other reservation, place, or day assignment.
* Drop this item's receipt links, leaving the files themselves alone.
*
* The files are deliberately NOT trashed. `budget_edit` and `file_delete` are
* separate, separately configurable permissions, and a receipt id is any file
* on the trip, so trashing here would let anyone who may edit an expense
* delete a document they may not delete, from three surfaces that never see a
* file permission at all: the REST route, the MCP tool and the plugin RPC.
* Cleaning up a file nobody references is what the Files trash is for.
*
* A row is only removed when the receipt link was all it carried. The same
* row can also tie the file to a place or a booking, and those links have
* nothing to do with the expense. A link whose file already sits in the trash
* is left in place, so restoring that file brings it back attached.
*/
private trashOrphanReceipt(fileId: number | string, budgetItemId: number | string) {
const file = this.db.get<{ id: number; place_id: number | null; reservation_id: number | null }>(
'SELECT id, place_id, reservation_id FROM trip_files WHERE id = ? AND deleted_at IS NULL',
fileId,
private unlinkReceipts(budgetItemId: number | string, keep: ReadonlySet<number> = new Set()) {
const rows = this.db.all<{ id: number; file_id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null }>(
`SELECT fl.id, fl.file_id, fl.reservation_id, fl.assignment_id, fl.place_id
FROM file_links fl
JOIN trip_files f ON f.id = fl.file_id
WHERE fl.budget_item_id = ? AND f.deleted_at IS NULL`,
budgetItemId,
);
if (!file) return;
// Check if file is tied directly to another entity via legacy columns
if (file.place_id || file.reservation_id) return;
// Check if file is linked via file_links to another reservation, place, assignment, or other budget item
const otherLinks = this.db.get<{ count: number }>(`
SELECT COUNT(*) as count FROM file_links
WHERE file_id = ? AND (
reservation_id IS NOT NULL OR
place_id IS NOT NULL OR
assignment_id IS NOT NULL OR
(budget_item_id IS NOT NULL AND budget_item_id != ?)
)
`, fileId, budgetItemId);
if (otherLinks && otherLinks.count > 0) return;
// It is an orphan receipt solely belonging to this budget item: move to trash
this.db.run('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?', fileId);
for (const row of rows) {
if (keep.has(row.file_id)) continue;
if (row.reservation_id || row.assignment_id || row.place_id) {
this.db.run('UPDATE file_links SET budget_item_id = NULL WHERE id = ?', row.id);
} else {
this.db.run('DELETE FROM file_links WHERE id = ?', row.id);
}
}
}
// -------------------------------------------------------------------------
@@ -628,30 +630,24 @@ export class BudgetService {
}
if (data.receipt_file_ids !== undefined) {
// Find existing receipt file IDs for this budget item
const oldReceiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Sync receipts: delete previous links and add new ones
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
// The ids that survive are left linked rather than dropped and re-added,
// so a receipt the request keeps never loses its row for an instant.
const keep = new Set(data.receipt_file_ids.map(Number));
this.unlinkReceipts(id, keep);
if (data.receipt_file_ids.length > 0) {
const insertLink = this.db.prepare('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)');
const adopt = this.db.prepare('UPDATE file_links SET budget_item_id = ? WHERE id = ?');
for (const fid of data.receipt_file_ids) {
const belongs = this.db.get('SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL', fid, tripId);
if (belongs) {
insertLink.run(fid, id);
}
}
}
// Any previously attached receipt that was unlinked now moves to trash if orphan
const newSet = new Set(data.receipt_file_ids);
for (const oldFid of oldReceiptFiles) {
if (!newSet.has(oldFid)) {
this.trashOrphanReceipt(oldFid, id);
if (!belongs) continue;
// A file already tied to a place or a booking gets the receipt link
// written onto that row, because the unique index is per file and
// item and a second row for the same pair would be refused anyway.
const spare = this.db.get<{ id: number }>(
'SELECT id FROM file_links WHERE file_id = ? AND budget_item_id IS NULL LIMIT 1', fid,
);
if (spare) adopt.run(id, spare.id);
else insertLink.run(fid, id);
}
}
}
@@ -687,18 +683,12 @@ export class BudgetService {
if (!item) return false;
return this.db.transaction(() => {
// Find all receipts attached to this item before deleting it
const receiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Move exclusively attached receipts to trash
for (const fid of receiptFiles) {
this.trashOrphanReceipt(fid, id);
}
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
// The receipts stay; only their tie to this expense goes. Rows that
// carry nothing else are removed, rows that also point at a place or a
// booking keep those. A link on a file already in the trash is left for
// the SET NULL on the foreign key, so restoring the file does not bring
// back a pointer to an expense that no longer exists.
this.unlinkReceipts(id);
this.db.run('DELETE FROM budget_items WHERE id = ?', id);
// The booking keeps a copy of this expense's total in its metadata, and