fix(projects): address review — cross-customer guards + email/queue hardening

Resolves the two blockers and the actionable concerns/nits from review.

Blockers (cross-customer leak):
- linkDealToProject: collect the deal's customer + events BEFORE any write,
  then reject a cross-customer link with PROJECT_CUSTOMER_MISMATCH (422) before
  re-pointing events/quotes/contracts or adopting a customer. The editors set
  project_id via quoteService/contractService → linkDealToProject (not
  assignDocument), so the guard lives at that chokepoint. Null-project adoption
  ("first deal wins") preserved as intended.
- assignDocument: boundary guard mirroring customerHoursService, defense-in-depth
  ahead of the cascade.
- Frontend: translated PROJECT_CUSTOMER_MISMATCH (projects.error.customerMismatch,
  de+en) wired into HoursSection + quote/contract editor onError (concern 5).

Concerns:
- 1: processEmailQueue gains an onlyId option; cockpit "send now" scopes the
  flush to the single row so it can't force-retry other dead-lettered emails.
- 2: resendEmail re-stringifies email_data when PG returns a parsed object,
  matching the canonical enqueue — no jsonb double-encode.
- 3: cockpit email feed scoped to the project's own document numbers (event_id
  for gallery mails; email_data doc-number match for CRM mails) instead of the
  recipient string — a shared inbox no longer leaks another customer's mail.
- 4: migration 117 backfill wrapped in a transaction (adds atomicity on SQLite,
  where the runner does not wrap; PG already wraps the whole migration).
- 6: resend/cancel/retry/sendNow now logActivity uniformly (project_email_*),
  adminId threaded from the route.
- 8: validator optional({ values: 'null' }) → optional({ nullable: true }).
- 9: pre-121 list valuation falls back to customer-scoped quotes so the list
  isn't all-zero during the upgrade window.

Nits:
- milestone selection uses Array.at(-1); removed redundant in-loop require in
  emailProcessor; clarifying comments for the list/detail perms split and the
  count-vs-value (0 vs em-dash) convention.
This commit is contained in:
Luca
2026-06-13 11:57:32 +02:00
parent a702f33004
commit 9d13880f2b
10 changed files with 240 additions and 93 deletions
+25 -20
View File
@@ -47,29 +47,34 @@ exports.up = async function (knex) {
});
}
// 3. Backfill one auto-project per still-unassigned event.
// 3. Backfill one auto-project per still-unassigned event. Wrapped in a
// single transaction so a heavy install (10k+ events) can't be left
// half-assigned if the loop dies mid-way — it's all-or-nothing, and a
// re-run still no-ops (gated on whereNull('project_id')).
if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) {
const events = await knex('events').whereNull('project_id').select('id', 'event_name');
const hasAssignments = await knex.schema.hasTable('event_customer_assignments');
for (const ev of events) {
let customerId = null;
if (hasAssignments) {
const rows = await knex('event_customer_assignments')
.where({ event_id: ev.id })
.select('customer_account_id');
if (rows.length === 1) customerId = rows[0].customer_account_id;
await knex.transaction(async (trx) => {
const events = await trx('events').whereNull('project_id').select('id', 'event_name');
for (const ev of events) {
let customerId = null;
if (hasAssignments) {
const rows = await trx('event_customer_assignments')
.where({ event_id: ev.id })
.select('customer_account_id');
if (rows.length === 1) customerId = rows[0].customer_account_id;
}
const name = (ev.event_name && String(ev.event_name).trim()) || `Event ${ev.id}`;
const inserted = await trx('projects').insert({
name,
customer_account_id: customerId,
status: 'active',
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
}).returning('id');
const projectId = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
await trx('events').where({ id: ev.id }).update({ project_id: projectId });
}
const name = (ev.event_name && String(ev.event_name).trim()) || `Event ${ev.id}`;
const inserted = await knex('projects').insert({
name,
customer_account_id: customerId,
status: 'active',
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
}).returning('id');
const projectId = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
await knex('events').where({ id: ev.id }).update({ project_id: projectId });
}
});
}
};