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:
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user