fix(hours): move logActivity out of the entry transactions (SQLite deadlock)

logActivity writes via the global db; called inside a db.transaction it
deadlocks against the held write lock on a SQLite-backed install (a second
write connection blocks). Stage the audit info inside each transaction and
fire it AFTER commit in createEntry / updateEntry / deleteEntry /
billUnbilledEntries — same fix already applied to expenseService. Return
shapes unchanged. (The monthly/billing paths still route through
createInvoice, whose own internal logActivity remains the shared root
limitation — tracked in feedback_sqlite_global_write_in_transaction.)
This commit is contained in:
Luca
2026-06-18 15:35:48 +02:00
parent 51837c3a88
commit 348955b261
+27 -30
View File
@@ -219,7 +219,14 @@ async function createEntry(customerId, payload, adminId) {
// if neither override, customer default, nor install default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer, installDefaultMinor);
return await db.transaction(async (trx) => {
// logActivity writes via the GLOBAL db; calling it inside the transaction
// below deadlocks against the held write lock on a SQLite-backed install (a
// second write connection blocks). Stage it here, fire it AFTER commit.
// (The monthly/billing paths additionally route through createInvoice, whose
// OWN internal logActivity still runs in-trx — that shared root limitation is
// tracked in feedback_sqlite_global_write_in_transaction.)
let logInfo = null;
const result = await db.transaction(async (trx) => {
const row = {
customer_account_id: customer.id,
entry_date: entryDate,
@@ -268,21 +275,15 @@ async function createEntry(customerId, payload, adminId) {
billed_at: new Date(),
updated_at: new Date(),
});
try {
await logActivity('hour_entry_logged_to_monthly_draft',
{ entryId, customerId: customer.id, invoiceId },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_logged_to_monthly_draft', meta: { entryId, customerId: customer.id, invoiceId } };
return { id: entryId, status: 'billed', invoiceId };
}
try {
await logActivity('hour_entry_logged',
{ entryId, customerId: customer.id },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } };
return { id: entryId, status: 'unbilled' };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**
@@ -293,7 +294,8 @@ async function createEntry(customerId, payload, adminId) {
* stay accurate.
*/
async function updateEntry(entryId, payload, adminId) {
return await db.transaction(async (trx) => {
let logInfo = null; // logged after commit — see createEntry note.
const result = await db.transaction(async (trx) => {
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
if (!entry) throw new AppError('Entry not found', 404);
const invoice = entry.invoice_id
@@ -375,13 +377,11 @@ async function updateEntry(entryId, payload, adminId) {
updated_at: next.updated_at,
});
try {
await logActivity('hour_entry_updated',
{ entryId, customerId: entry.customer_account_id },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } };
return { id: entryId };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**
@@ -390,7 +390,8 @@ async function updateEntry(entryId, payload, adminId) {
* recomputes invoice totals before deleting the entry row itself.
*/
async function deleteEntry(entryId, adminId) {
return await db.transaction(async (trx) => {
let logInfo = null; // logged after commit — see createEntry note.
const result = await db.transaction(async (trx) => {
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
if (!entry) throw new AppError('Entry not found', 404);
const invoice = entry.invoice_id
@@ -427,13 +428,11 @@ async function deleteEntry(entryId, adminId) {
await trx('customer_hour_entries').where({ id: entryId }).del();
try {
await logActivity('hour_entry_deleted',
{ entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } };
return { deleted: true };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**
@@ -454,7 +453,8 @@ async function billUnbilledEntries(customerId, adminId) {
);
}
return await db.transaction(async (trx) => {
let logInfo = null; // logged after commit — see createEntry note.
const result = await db.transaction(async (trx) => {
const unbilled = await trx('customer_hour_entries')
.where({ customer_account_id: customer.id, status: 'unbilled' })
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
@@ -500,14 +500,11 @@ async function billUnbilledEntries(customerId, adminId) {
});
}
try {
await logActivity('hour_entries_billed',
{ customerId: customer.id, invoiceId, entryCount: unbilled.length },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
return { invoiceId, entriesBilled: unbilled.length };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**