Bulk invite collaborators#9987
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: BacLuc <lucius.bachmann@clubpage.ch>
As it was done in other components. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: BacLuc <lucius.bachmann@clubpage.ch>
Adds POST /camp_collaborations/bulk_invite to invite multiple people to a camp at once by providing a list of email addresses. - BulkInvite DTO entity with camp, inviteEmails, role fields - BulkInviteProcessor handles creating CampCollaborations for each email, skipping emails that are already collaborators - CollaboratorBulkCreate.vue frontend component with textarea for emails - Translations for de, en, fr, it, rm locales Fixes ecamp#8045 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: BacLuc <lucius.bachmann@clubpage.ch>
|
@coderabbitai review this pr |
✅ Feature branch deployment ready!
|
|
✅ Action performedReview finished.
|
📝 WalkthroughSummary by CodeRabbitRelease Notes
WalkthroughThis PR delivers end-to-end bulk camp collaboration invitations. The backend introduces a validated DTO and processor to handle multiple email invitations via ChangesBulk Invite Camp Collaborators
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (3)
frontend/src/components/collaborator/CollaboratorBulkCreate.vue (1)
29-35: ⚖️ Poor tradeoffConsider client-side email validation for better UX.
The textarea accepts freeform text with no format validation. Invalid email addresses will only be rejected by the API, requiring a round-trip. Consider adding vee-validate rules or inline hints to catch format errors earlier.
For example, you could add a computed property that validates parsed emails and displays warnings before submission.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/collaborator/CollaboratorBulkCreate.vue` around lines 29 - 35, The textarea bound to emailsText in CollaboratorBulkCreate.vue lacks client-side validation; add a computed property (e.g., validatedEmails and invalidEmails) that parses emailsText into individual addresses, validates each with a simple email regex or vee-validate rule, and expose invalidEmails to the template as inline warnings (VAlert, helper text or per-line error) and to disable the submit action; update the component's submit handler (e.g., onBulkCreate/createCollaborators) to check validatedEmails before calling the API so only well-formed addresses are sent.e2e/tests/9-behavior-tests/bulkInvite.spec.ts (1)
15-16: ⚡ Quick winUse stable selectors instead of localized button/field text in E2E.
These assertions are tightly coupled to German labels, so locale/default-language changes can break tests without behavioral regressions. Prefer
data-testid(or similarly stable selectors) for dialog open/submit/textbox/role controls.Also applies to: 20-20, 26-27, 40-40, 83-83, 89-90, 95-96, 104-105
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/tests/9-behavior-tests/bulkInvite.spec.ts` around lines 15 - 16, The test uses localized visible text (e.g., the string 'Mehrere Personen einladen' used in page.getByRole(...)) which is brittle; update the selectors across the spec (all places using page.getByRole or page.getByLabelText with localized German strings) to use stable attributes like data-testid or aria-testids instead (e.g., replace page.getByRole('button', { name: 'Mehrere Personen einladen' }) and similar selectors for dialog open/submit buttons, textboxes and role controls with page.locator('[data-testid="bulk-invite-button"]') / page.getByTestId('...') equivalents); ensure the app adds matching data-testid attributes for the dialog open, submit, textbox and role controls so the E2E references (open, submit, input, role selectors) are stable across locales.api/tests/Api/CampCollaborations/BulkInviteTest.php (1)
81-127: ⚡ Quick winAdd a same-request duplicate-email regression test.
Nothing here exercises a payload like
['dup@example.com', ' DUP@example.com ']. That case is easy to mis-handle, and a focused test asserting dedup-before-persist would lock down the API contract.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@api/tests/Api/CampCollaborations/BulkInviteTest.php` around lines 81 - 127, Add a new test method (e.g., testBulkInviteDedupsSameRequestDifferentCaseAndWhitespace) that posts to /camp_collaborations/bulk_invite via createClientWithCredentials() with JSON inviteEmails like ['dup@example.com', ' DUP@example.com '] and the same camp IRI from getIriFor('camp1'), then assert the response status is 201, response->toArray() shows only one item in createdCampCollaborations and zero items in alreadyInCamp (or that alreadyInCamp does not contain the normalized email); this ensures dedup-before-persist behavior for case/whitespace variants.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@api/src/State/BulkInviteProcessor.php`:
- Around line 63-64: The code currently flushes each created collaboration and
then calls sendInviteEmail, which allows partial commits if a later email send
fails; wrap the whole batch in a single DB transaction in BulkInviteProcessor
(the loop that calls createCollaboration and sendInviteEmail), stop calling
flush() per-invite and instead persist entities only, collect created
collaborations, commit (flush) once at the end, then (after successful commit)
resolve IRIs with iriConverter->getIriFromResource and call sendInviteEmail (or
enqueue emails asynchronously); ensure rollback on failure so no partial commits
occur.
- Around line 54-58: The loop over $data->inviteEmails normalizes each address
and then checks isAlreadyInCamp($camp, $email), which causes intra-request
duplicates to be treated as "already in camp"; to fix, deduplicate the
normalized invite list before any persistence or isAlreadyInCamp checks by
building a normalized set (e.g., lowercased/trimmed values) and iterating only
unique addresses, or track a local $seen array inside the processing logic to
skip duplicates; update references to $data->inviteEmails processing (the
foreach block that normalizes values and populates $data->alreadyInCamp) so only
unique normalized emails are checked and added.
In
`@api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml`:
- Around line 3940-3951: The snapshot shows inviteEmails array items as "null |
string" without an email format; update the DTO/property metadata for
inviteEmails so its elements are non-null email strings (e.g., replace nullable
item typing with a plain string type and add an email format
annotation/decorator that your OpenAPI generator recognizes), ensure the
property still enforces minItems/maxItems, then regenerate the OpenAPI
spec/snapshots so items appear as type: string with format: email instead of
allowing nulls.
In `@frontend/src/components/collaborator/CollaboratorBulkCreate.vue`:
- Line 10: The submit-enable check uses emailsText.length which counts
whitespace; change the condition to use the parsed/validated list instead—use
parsedEmails.length > 0 (or compute a trimmed non-empty check like
emailsText.trim() and/or reuse the existing parsedEmails computed property) so
the button only enables when there is at least one non-empty parsed email;
update the binding that references :submit-enabled to use parsedEmails (or a new
computed like hasValidEmails) and ensure parsedEmails is the same computed that
trims/splits/filters empty strings.
- Line 122: The debug console.log(err) call should be removed from the
bulk-create error handling; locate the console.log inside the
CollaboratorBulkCreate.vue method handling the bulk create (the catch/error
branch where console.log(err) is present) and either delete it or replace it
with the component's production logging/error-handling mechanism (e.g.,
this.$emit('error', err) or this.$logger.error(...)/processLogger.error(...)) so
errors are recorded appropriately instead of printed to the console.
In `@frontend/src/components/collaborator/CollaboratorRoleSelect.vue`:
- Around line 7-9: The select is using wrong prop names—change item-title="role"
to item-title="text" and item-value="key" to item-value="value" in the
CollaboratorRoleSelect.vue template (the component that renders the
select/dropdown) so it matches the items array shape (items use text and value),
and leave the existing vee-rules attribute as-is; update those attributes on the
select component element to restore correct display and stored values.
- Line 43: The prop definition for modelValue in CollaboratorRoleSelect.vue uses
an empty-string default which is invalid per backend role contract (allowed:
'guest'|'member'|'manager'); update the prop to either set a valid default
(e.g., 'member' to align with DEFAULT_BULK_INVITE_ROLE used in
CollaboratorBulkCreate) or make modelValue required so callers must supply one;
change the modelValue prop declaration accordingly and ensure any parent
components that rely on the previous empty default are updated to pass a valid
role.
---
Nitpick comments:
In `@api/tests/Api/CampCollaborations/BulkInviteTest.php`:
- Around line 81-127: Add a new test method (e.g.,
testBulkInviteDedupsSameRequestDifferentCaseAndWhitespace) that posts to
/camp_collaborations/bulk_invite via createClientWithCredentials() with JSON
inviteEmails like ['dup@example.com', ' DUP@example.com '] and the same camp IRI
from getIriFor('camp1'), then assert the response status is 201,
response->toArray() shows only one item in createdCampCollaborations and zero
items in alreadyInCamp (or that alreadyInCamp does not contain the normalized
email); this ensures dedup-before-persist behavior for case/whitespace variants.
In `@e2e/tests/9-behavior-tests/bulkInvite.spec.ts`:
- Around line 15-16: The test uses localized visible text (e.g., the string
'Mehrere Personen einladen' used in page.getByRole(...)) which is brittle;
update the selectors across the spec (all places using page.getByRole or
page.getByLabelText with localized German strings) to use stable attributes like
data-testid or aria-testids instead (e.g., replace page.getByRole('button', {
name: 'Mehrere Personen einladen' }) and similar selectors for dialog
open/submit buttons, textboxes and role controls with
page.locator('[data-testid="bulk-invite-button"]') / page.getByTestId('...')
equivalents); ensure the app adds matching data-testid attributes for the dialog
open, submit, textbox and role controls so the E2E references (open, submit,
input, role selectors) are stable across locales.
In `@frontend/src/components/collaborator/CollaboratorBulkCreate.vue`:
- Around line 29-35: The textarea bound to emailsText in
CollaboratorBulkCreate.vue lacks client-side validation; add a computed property
(e.g., validatedEmails and invalidEmails) that parses emailsText into individual
addresses, validates each with a simple email regex or vee-validate rule, and
expose invalidEmails to the template as inline warnings (VAlert, helper text or
per-line error) and to disable the submit action; update the component's submit
handler (e.g., onBulkCreate/createCollaborators) to check validatedEmails before
calling the API so only well-formed addresses are sent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: cc1725f0-429a-4c0c-8bed-567ca25bdeb6
📒 Files selected for processing (15)
api/src/DTO/BulkInvite.phpapi/src/State/BulkInviteProcessor.phpapi/tests/Api/CampCollaborations/BulkInviteTest.phpapi/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.ymle2e/tests/9-behavior-tests/bulkInvite.spec.tsfrontend/src/components/collaborator/CollaboratorBulkCreate.vuefrontend/src/components/collaborator/CollaboratorForm.vuefrontend/src/components/collaborator/CollaboratorRoleSelect.vuefrontend/src/components/form/base/ETextarea.vuefrontend/src/locales/de.jsonfrontend/src/locales/en.jsonfrontend/src/locales/fr.jsonfrontend/src/locales/it.jsonfrontend/src/locales/rm.jsonfrontend/src/views/camp/admin/Collaborators.vue
| foreach ($data->inviteEmails as $email) { | ||
| $email = strtolower(trim($email)); | ||
|
|
||
| if ($this->isAlreadyInCamp($camp, $email)) { | ||
| $data->alreadyInCamp[] = $email; |
There was a problem hiding this comment.
Deduplicate the normalized email list before hitting persistence.
A duplicate address in the same payload is currently pushed into alreadyInCamp on its second occurrence, because the first iteration has already created it. That makes the response say “already in camp” for addresses that only duplicated within this request.
♻️ Proposed fix
- foreach ($data->inviteEmails as $email) {
- $email = strtolower(trim($email));
+ $inviteEmails = array_values(array_unique(array_map(
+ static fn (string $email): string => strtolower(trim($email)),
+ $data->inviteEmails,
+ )));
+
+ foreach ($inviteEmails as $email) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| foreach ($data->inviteEmails as $email) { | |
| $email = strtolower(trim($email)); | |
| if ($this->isAlreadyInCamp($camp, $email)) { | |
| $data->alreadyInCamp[] = $email; | |
| $inviteEmails = array_values(array_unique(array_map( | |
| static fn (string $email): string => strtolower(trim($email)), | |
| $data->inviteEmails, | |
| ))); | |
| foreach ($inviteEmails as $email) { | |
| if ($this->isAlreadyInCamp($camp, $email)) { | |
| $data->alreadyInCamp[] = $email; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@api/src/State/BulkInviteProcessor.php` around lines 54 - 58, The loop over
$data->inviteEmails normalizes each address and then checks
isAlreadyInCamp($camp, $email), which causes intra-request duplicates to be
treated as "already in camp"; to fix, deduplicate the normalized invite list
before any persistence or isAlreadyInCamp checks by building a normalized set
(e.g., lowercased/trimmed values) and iterating only unique addresses, or track
a local $seen array inside the processing logic to skip duplicates; update
references to $data->inviteEmails processing (the foreach block that normalizes
values and populates $data->alreadyInCamp) so only unique normalized emails are
checked and added.
| $collaboration = $this->createCollaboration($camp, $email, $data->role); | ||
| $data->createdCampCollaborations[] = $this->iriConverter->getIriFromResource($collaboration); |
There was a problem hiding this comment.
This bulk endpoint can commit a partial batch and still fail.
Each invite is flush()ed before sendInviteEmail() runs. If one mail send throws, earlier collaborations stay committed and the request returns 500, so a retry turns a failed bulk invite into a partial success. Persist the whole batch in one transaction and send mail after commit or asynchronously.
Also applies to: 106-115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@api/src/State/BulkInviteProcessor.php` around lines 63 - 64, The code
currently flushes each created collaboration and then calls sendInviteEmail,
which allows partial commits if a later email send fails; wrap the whole batch
in a single DB transaction in BulkInviteProcessor (the loop that calls
createCollaboration and sendInviteEmail), stop calling flush() per-invite and
instead persist entities only, collect created collaborations, commit (flush)
once at the end, then (after successful commit) resolve IRIs with
iriConverter->getIriFromResource and call sendInviteEmail (or enqueue emails
asynchronously); ensure rollback on failure so no partial commits occur.
| inviteEmails: | ||
| description: 'List of email addresses to invite.' | ||
| example: | ||
| - another@example.com | ||
| - invited@example.com | ||
| items: | ||
| type: | ||
| - 'null' | ||
| - string | ||
| maxItems: 100 | ||
| minItems: 1 | ||
| type: array |
There was a problem hiding this comment.
Document inviteEmails as non-null email strings.
Line 3946 currently declares each entry as string | null and never marks it as an email, so generated clients will treat inputs like [null, "not-an-email"] as schema-valid. That weakens the published API contract for a field described as a list of email addresses and can drift from the server-side validation/tests. Please tighten the DTO metadata so the regenerated write schema emits non-null type: string items with format: email.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml`
around lines 3940 - 3951, The snapshot shows inviteEmails array items as "null |
string" without an email format; update the DTO/property metadata for
inviteEmails so its elements are non-null email strings (e.g., replace nullable
item typing with a plain string type and add an email format
annotation/decorator that your OpenAPI generator recognizes), ensure the
property still enforces minItems/maxItems, then regenerate the OpenAPI
spec/snapshots so items appear as type: string with format: email instead of
allowing nulls.
| :title="$t('components.collaborator.collaboratorBulkCreate.title')" | ||
| :submit-action="bulkInvite" | ||
| :submit-label="$t('components.collaborator.collaboratorBulkCreate.invite')" | ||
| :submit-enabled="emailsText.length > 0" |
There was a problem hiding this comment.
Submit button enables on whitespace-only input.
Line 10 checks emailsText.length > 0, so entering only whitespace/newlines will enable the button. But parsedEmails (lines 83-88) filters empty strings, so the submission would send an empty array and fail backend validation (min: 1).
🐛 Proposed fix
:submit-action="bulkInvite"
:submit-label="$t('components.collaborator.collaboratorBulkCreate.invite')"
- :submit-enabled="emailsText.length > 0"
+ :submit-enabled="parsedEmails.length > 0"
submit-icon="mdi-email-fast"
submit-color="success"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| :submit-enabled="emailsText.length > 0" | |
| :submit-action="bulkInvite" | |
| :submit-label="$t('components.collaborator.collaboratorBulkCreate.invite')" | |
| :submit-enabled="parsedEmails.length > 0" | |
| submit-icon="mdi-email-fast" | |
| submit-color="success" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/collaborator/CollaboratorBulkCreate.vue` at line 10,
The submit-enable check uses emailsText.length which counts whitespace; change
the condition to use the parsed/validated list instead—use parsedEmails.length >
0 (or compute a trimmed non-empty check like emailsText.trim() and/or reuse the
existing parsedEmails computed property) so the button only enables when there
is at least one non-empty parsed email; update the binding that references
:submit-enabled to use parsedEmails (or a new computed like hasValidEmails) and
ensure parsedEmails is the same computed that trims/splits/filters empty
strings.
| this.emailsText = '' | ||
| await this.api.reload(this.camp.campCollaborations()) | ||
| } catch (err) { | ||
| console.log(err) |
There was a problem hiding this comment.
Remove debug console.log before production.
The console.log(err) on line 122 is a debug artifact. Remove it or replace with proper error logging if needed for production diagnostics.
🧹 Proposed fix
await this.api.reload(this.camp.campCollaborations())
} catch (err) {
- console.log(err)
this.error = err
} finally {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| console.log(err) | |
| await this.api.reload(this.camp.campCollaborations()) | |
| } catch (err) { | |
| this.error = err | |
| } finally { |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/collaborator/CollaboratorBulkCreate.vue` at line 122,
The debug console.log(err) call should be removed from the bulk-create error
handling; locate the console.log inside the CollaboratorBulkCreate.vue method
handling the bulk create (the catch/error branch where console.log(err) is
present) and either delete it or replace it with the component's production
logging/error-handling mechanism (e.g., this.$emit('error', err) or
this.$logger.error(...)/processLogger.error(...)) so errors are recorded
appropriately instead of printed to the console.
| item-title="role" | ||
| item-value="key" | ||
| vee-rules="required" |
There was a problem hiding this comment.
Critical: Incorrect property names break role selection.
Lines 7-8 reference properties that don't exist in the items array:
- Line 7:
item-title="role"but items usetextproperty (lines 59, 65, 71) - Line 8:
item-value="key"but items usevalueproperty (lines 58, 64, 70)
This will cause the select to display and store incorrect values.
🐛 Proposed fix
path="role"
:items="items"
persistent-hint
- item-title="role"
- item-value="key"
+ item-title="text"
+ item-value="value"
vee-rules="required"
v-bind="$attrs"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| item-title="role" | |
| item-value="key" | |
| vee-rules="required" | |
| item-title="text" | |
| item-value="value" | |
| vee-rules="required" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/collaborator/CollaboratorRoleSelect.vue` around lines
7 - 9, The select is using wrong prop names—change item-title="role" to
item-title="text" and item-value="key" to item-value="value" in the
CollaboratorRoleSelect.vue template (the component that renders the
select/dropdown) so it matches the items array shape (items use text and value),
and leave the existing vee-rules attribute as-is; update those attributes on the
select component element to restore correct display and stored values.
| export default { | ||
| name: 'CollaboratorRoleSelect', | ||
| props: { | ||
| modelValue: { type: String, required: false, default: '' }, |
There was a problem hiding this comment.
Invalid default role value.
The modelValue default is an empty string, but the backend contract restricts role to guest, member, or manager. An empty string will fail API validation if submitted.
Consider defaulting to a valid role (e.g., 'member' to match DEFAULT_BULK_INVITE_ROLE in CollaboratorBulkCreate) or making the prop explicitly required.
🛡️ Proposed fix (option 1: sensible default)
props: {
- modelValue: { type: String, required: false, default: '' },
+ modelValue: { type: String, required: false, default: 'member' },
},🛡️ Proposed fix (option 2: require explicit value)
props: {
- modelValue: { type: String, required: false, default: '' },
+ modelValue: { type: String, required: true },
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| modelValue: { type: String, required: false, default: '' }, | |
| modelValue: { type: String, required: false, default: 'member' }, |
| modelValue: { type: String, required: false, default: '' }, | |
| modelValue: { type: String, required: true }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/collaborator/CollaboratorRoleSelect.vue` at line 43,
The prop definition for modelValue in CollaboratorRoleSelect.vue uses an
empty-string default which is invalid per backend role contract (allowed:
'guest'|'member'|'manager'); update the prop to either set a valid default
(e.g., 'member' to align with DEFAULT_BULK_INVITE_ROLE used in
CollaboratorBulkCreate) or make modelValue required so callers must supply one;
change the modelValue prop declaration accordingly and ensure any parent
components that rely on the previous empty default are updated to pass a valid
role.
carlobeltrame
left a comment
There was a problem hiding this comment.
I would prefer this to be implemented in the frontend.
- Sending emails can take some time, and this could require a job queue if done on the server side.
- Almost all of our endpoints are pure REST; this one is another RPC-style endpoint (evident by the necessity of a DTO).
- The whole operation feels like it is necessitated by a UI requirement, not a data requirement. We have previously rejected storing frontend configuration in the backend, in order to keep frontend and backend clearly separated.
- Doing it in the frontend, error reporting and handling of individual failures is easier.
- Doing it with AI, this shouldn't be too much effort to redo I hope.
feat: add bulk camp invitation endpoint and frontend component
Adds POST /camp_collaborations/bulk_invite to invite multiple people
to a camp at once by providing a list of email addresses.
skipping emails that are already collaborators
Fixes #8045