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
@@ -140,6 +140,11 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.'));
return;
}
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch',
"That project belongs to a different customer than this entry."));
return;
}
const msg = err?.response?.data?.error || err?.message
|| t('customers.hours.error.createFailed', 'Failed to log entry');
toast.error(msg);
+3
View File
@@ -3451,6 +3451,9 @@
"emailAction": "Erledigt",
"emailActionFailed": "Aktion fehlgeschlagen",
"previewFailed": "Vorschau konnte nicht geladen werden"
},
"error": {
"customerMismatch": "Dieses Projekt gehört zu einem anderen Kunden als dieser Eintrag."
}
},
"calendar": {
+3
View File
@@ -3451,6 +3451,9 @@
"emailAction": "Done",
"emailActionFailed": "Action failed",
"previewFailed": "Could not load preview"
},
"error": {
"customerMismatch": "That project belongs to a different customer than this entry."
}
},
"calendar": {
@@ -206,6 +206,10 @@ export const ContractEditorPage: React.FC = () => {
navigate(`/admin/clients/contracts/${created.id}`);
},
onError: (err: any) => {
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch', 'That project belongs to a different customer than this entry.') as string);
return;
}
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
},
});
@@ -235,6 +239,10 @@ export const ContractEditorPage: React.FC = () => {
navigate(`/admin/clients/contracts/${numericId}`);
},
onError: (err: any) => {
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch', 'That project belongs to a different customer than this entry.') as string);
return;
}
toast.error(err?.response?.data?.error || err?.message || t('contracts.editor.saveError', 'Save failed') as string);
},
});
@@ -18,7 +18,10 @@ import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { formatMoneyMinor } from '../../../utils/money';
/** Render a project's rolled-up value (newest stage per deal, cumulative),
* one entry per currency. Empty → em dash. */
* one entry per currency. Convention (deliberately differs from the Events
* column): a zero *count* is a real number → "0"; a zero *value* means "nothing
* billed/quoted yet" → em dash, since "CHF 0.00" would wrongly imply a real
* zero-value deal. */
function formatValuation(p: ProjectSummary): string {
const buckets = p.valuation?.byCurrency?.filter((b) => b.totalMinor !== 0) || [];
if (buckets.length === 0) return '—';
@@ -396,6 +396,9 @@ export const QuoteEditorPage: React.FC = () => {
if (err?.response?.data?.code === 'CUSTOMER_FEATURE_DISABLED') {
toast.error(t('quotes.errors.customerFeatureDisabled',
'This customer has Quotes disabled. Enable "Quotes" on the customer detail page first.'));
} else if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch',
'That project belongs to a different customer than this entry.'));
} else if (err?.response?.data?.code === 'VALIDATION_ERROR' && Array.isArray(err?.response?.data?.details)) {
// Show the first field that failed validation so the admin
// knows what to fix instead of just seeing "Validation failed".