fix(projects): enforce single-customer projects (guard event attach + re-label)

A project must stay tied to one customer. The quote/contract/hours attach
paths already rejected a foreign customer (equality on project.customer_account_id);
the two remaining holes are closed here:

- assignEvent: an event may only join a project that shares its customer. The
  event's customer(s) come from event_customer_assignments; a customer-assigned
  project rejects an event for a different customer (PROJECT_CUSTOMER_MISMATCH),
  and an empty project ADOPTS a single-customer event's customer. This is why
  a foreign-customer event could previously be attached.

- updateProject: re-labelling a project to a customer that conflicts with the
  events/quotes/contracts it already holds is rejected (clearing to null is
  still allowed), so the customer can't be swapped out from under existing
  content.

Frontend: the cockpit attach-event action surfaces the translated mismatch
message; projects.error.customerMismatch reworded to read for both documents
and events (de + en).
This commit is contained in:
Luca
2026-06-13 13:09:55 +02:00
parent 9d13880f2b
commit 4b1e85c855
4 changed files with 71 additions and 5 deletions
+1 -1
View File
@@ -3453,7 +3453,7 @@
"previewFailed": "Vorschau konnte nicht geladen werden"
},
"error": {
"customerMismatch": "Dieses Projekt gehört zu einem anderen Kunden als dieser Eintrag."
"customerMismatch": "Das gehört zu einem anderen Kunden als dieses Projekt."
}
},
"calendar": {
+1 -1
View File
@@ -3453,7 +3453,7 @@
"previewFailed": "Could not load preview"
},
"error": {
"customerMismatch": "That project belongs to a different customer than this entry."
"customerMismatch": "That belongs to a different customer than this project."
}
},
"calendar": {
@@ -162,7 +162,13 @@ export const ProjectCockpitPage: React.FC = () => {
setEventSearch('');
toast.success(t('projects.events.attached', 'Event attached') as string);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.events.attachFailed', 'Could not attach event') as string)),
onError: (err: any) => {
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch', 'That belongs to a different customer than this project.') as string);
return;
}
toast.error(err?.response?.data?.error || (t('projects.events.attachFailed', 'Could not attach event') as string));
},
});
const openPreview = async (emailId: number) => {