mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
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:
@@ -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)');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user