Add guest management functionality to existing bookings
calcom/cal.com#14740Golden Comments (5/5 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | Case sensitivity bypass in email blacklist | HIGH | ✓ Found | "Email blacklist and existing-attendee checks are case-sensitive: blacklistedGuestEmails is lowercased but the submitted guest is compared raw, allowing the blacklist to be bypassed by case changes; case-variant duplicates of existing attendees also create duplicate attendee rows. Normalize emails to lowercase before comparison and before persisting" (addGuests.handler.ts L75) |
| #2 | The logic for checking team admin/owner permissions is incorrect. This condition uses AND (&&) which requires both isTeamAdmin AND isTeamOwner to be true, but it should use OR (||) since a user needs to be either an admin OR an owner to have permission |
HIGH | ✓ Found | "Team admin/owner permission check uses && instead of ||, effectively locking out team ADMINs (only OWNERs pass). isTeamAdmin already returns truthy for ADMIN or OWNER, so chaining with && degrades to isTeamOwner. This diverges from the project convention (role === OWNER || role === ADMIN) used in confirm.handler.ts and get.handler.ts; use ||" (addGuests.handler.ts L47) |
| #3 | This calls the email sender with the original guests, so existing attendees included in the input will be treated as new when sending notifications, leading to incorrect emails | MEDIUM | ✓ Found | "The attendee discriminator in sendAddGuestsEmails matches newGuests.includes(attendee.email) against raw, un-normalized input: the handler passes the unfiltered guests (existing attendees are filtered before persisting but not before this call), so re-typing the email of someone already on the booking makes that existing attendee receive a full AttendeeScheduledEmail confirmation instead of the 'new guests added' AttendeeAddGuestsEmail; case-variant emails also get the wrong template" (email-manager.ts L540) |
| #4 | uniqueGuests filters out existing attendees and blacklisted emails but does not deduplicate duplicates within the input; createMany can insert duplicate attendee rows if the client submits repeated emails |
MEDIUM | ✓ Found | "No de-duplication of duplicates within the submitted guests array at the API boundary: the Zod schema is just z.array(z.string().email()) with no .refine uniqueness (only the frontend enforces it). A direct API caller can send ["a@b.com", "a@b.com"] and createMany inserts identical attendees" (addGuests.handler.ts L73) |
| #5 | Starting with an array containing an empty string may cause validation issues. Consider starting with an empty array [] and handling the empty state in the MultiEmail component instead |
LOW | ✓ Found | "multiEmailValue is initialized to [''], so the if (multiEmailValue.length === 0) return; guard only fires after the user manually removes every field. Clicking 'Add' without typing falls through to ZAddGuestsInputSchema.safeParse, which rejects [''] and shows a misleading 'Emails must be unique and valid' error" (AddGuestsDialog.tsx L41) |
Supplementary Findings (12 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
Attendees are persisted to the DB (prisma.booking.update/createMany) before the calendar sync, which is not wrapped in try/catch (unlike the email step): if the sync throws, the mutation rejects while the DB is already mutated, and a retry then hits the emails_must_be_unique_valid BAD_REQUEST because those emails are now existing attendees |
addGuests.handler.ts:157 | HIGH | ✓ Real — partial-success, non-idempotent state on sync failure; arguably the PR's most damaging defect |
Calendar sync uses the caller's credentials (getUsersCredentials(ctx.user)) rather than the booking organizer's: since any attendee or a team admin (who may not be the organizer) can call this mutation, updateCalendarAttendees can write events to the wrong calendar |
addGuests.handler.ts:150 | MEDIUM | ✓ Real — non-organizer callers can trigger calendar writes with their own credentials |
The translation key you_do_not_have_permission is referenced by addGuests.handler.ts (line 55) in a TRPCError message rendered via t(err.message), but no such key exists in en/common.json nor the repo's locale files — an unauthorized caller sees the raw literal instead of a localized message |
common.json:1136 | MEDIUM | ✓ Real — raw key rendered to users; sibling keys (booking_not_found, emails_must_be_unique_valid) already exist |
prisma.user.findFirstOrThrow({ where: { id: booking.userId || 0 } }) throws an uncaught Prisma P2025 (500) for bookings without a userId (e.g. collective/managed bookings or a removed organizer account) instead of a handled TRPCError |
addGuests.handler.ts:60 | MEDIUM | ✓ Real — 500 on a legit booking shape; guard with a TRPCError is justified |
onSuccess resets multiEmailValue and closes the dialog but never resets isInvalidEmail: after an invalid submission, fixing and resubmitting, then reopening 'Add guests', the stale 'Emails must be unique and valid' error renders immediately over a clean input |
AddGuestsDialog.tsx:31 | MEDIUM | ✓ Real — stale error state leaks across dialog reopenings |
The from of the organizer-add-guests email uses APP_NAME instead of EMAIL_FROM_NAME, unlike every other organizer template: an instance customizing EMAIL_FROM_NAME is silently ignored by this new email |
organizer-add-guests-email.ts:23 | LOW | ✓ Real — breaks the org-wide from-name convention |
const message = `${err.data?.code}: ${t(err.message)}`; is always truthy (even 'undefined: <key>'), so the message || t('unable_to_add_guests') fallback never fires; non-TRPC/network errors render undefined: ... |
AddGuestsDialog.tsx:36 | LOW | ✓ Real — dead fallback with a raw undefined: prefix on network errors |
onOpenChange={setIsOpenDialog} only flips the flag, so closing via the overlay, Escape or the dialog's close button leaves multiEmailValue and isInvalidEmail untouched — the next open shows previously typed emails and/or a stale validation error |
AddGuestsDialog.tsx:25 | LOW | ✓ Real — only the explicit Cancel button resets state |
Reassigning the destructured value parameter (value = value || []) is a no-param-reassign anti-pattern and masks callers passing undefined |
MultiEmail.tsx:19 | LOW | ✓ Borderline: style nit; value ?? [] is cleaner but behavior is identical |
The remove <button> inside addOnSuffix has no onClick; removal only works because the Addon wrapper attaches onClickAddon to the whole addon — functional but brittle |
MultiEmail.tsx:45 | LOW | ✓ Borderline: works by accident of the wrapper; fragile to refactors |
<label htmlFor="emails"> doesn't associate with any input (each EmailField generates its own useId id) and repeats for every row |
MultiEmail.tsx:27 | LOW | ✓ Borderline: accessibility nit, non-functional |
Array index used as React key — acceptable here because each EmailField is fully controlled from value, but row removal re-indexes and any internal state would be lost |
MultiEmail.tsx:26 | LOW | ✓ Borderline: safe with the controlled-input design, latent otherwise |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌤️ Feat |
|---|---|
| 2 high, 8 medium, 7 low | 1 mitigated |
This MR adds the ability to add guests to a booking from the app.cal.com/bookings UI. The frontend flow (AddGuestsDialog, MultiEmail) is functionally wired and consistent with existing patterns, the new i18n keys are present and uniquely defined, and the new email templates closely follow the established location-change pattern, including correct iCal invite generation for newly added guests. Overall the feature works on the primary happy path (an owner adding a guest), but the backend handler contains several blocking defects that should be addressed before merge: the team admin/owner permission check uses && instead of || (locking out team ADMINS), attendees are persisted before the calendar sync without try/catch (causing partial-success, non-idempotent retries), email normalization is case-sensitive (enabling blacklist bypass and duplicate attendees), and calendar sync runs with the caller's credentials rather than the organizer's. There is also a missing translation key that will render raw to users. No security risk was identified in the client-side files, and the email/translation scopes are largely consistent, with only minor robustness/consistency nits.
These might need a close look
- 🔴 packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts (L47)
Team admin/owner permission check uses&&instead of||, effectively locking out team ADMINs (only OWNERs pass).isTeamAdminalready returns truthy for ADMIN or OWNER, so chaining with&°rades toisTeamOwner. This diverges from the project convention (role === OWNER || role === ADMIN) used inconfirm.handler.tsandget.handler.ts. Use||. - 🔴 packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts (L157)
Attendees are persisted to the DB (prisma.booking.update/createMany) before the calendar sync, which is not wrapped in try/catch (unlike the email step). If the calendar/event-management sync throws, the mutation rejects while the DB is already mutated; a retry then hits theemails_must_be_unique_validBAD_REQUEST because those emails are now existing attendees, leaving the user with a partial success they cannot complete. Wrap the sync in try/catch or reorder so failures don't leave inconsistent state, and make duplicate detection idempotent.
Worth checking
- 🟡 packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts (L75)
Email blacklist and existing-attendee checks are case-sensitive:blacklistedGuestEmailsis lowercased but the submittedguestis compared raw, allowing the blacklist to be bypassed by case changes; case-variant duplicates of existing attendees also create duplicate attendee rows. Normalize emails to lowercase before comparison and before persisting. - 🟡 packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts (L73)
No de-duplication of duplicates within the submittedguestsarray at the API boundary — the Zod schema is justz.array(z.string().email())with no.refineuniqueness (only the frontend enforces it). A direct API caller can send["a@b.com", "a@b.com"]andcreateManyinserts identical attendees. Enforce uniqueness in the backend schema or de-dupe in the handler. - 🟡 packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts (L150)
Calendar sync uses the caller's credentials (getUsersCredentials(ctx.user)) rather than the booking organizer's. Since the permission model lets any attendee or a team admin (who may not be the organizer) call this mutation,updateCalendarAttendeescan write events to the wrong calendar, and non-organizers can trigger calendar writes/emails to all attendees. Use the organizer's credentials (e.g.booking.user.credentials) and reconsider whether non-organizer attendees should trigger these side effects. - 🟡 apps/web/public/static/locales/en/common.json (L1136)
The translation keyyou_do_not_have_permissionis referenced byaddGuests.handler.ts(line 55) in a TRPCError message that the dialog renders viat(err.message), but no such key exists inen/common.json(nor the repo's locale files). An unauthorized caller sees the raw literal instead of a localized message. Sibling keysbooking_not_foundandemails_must_be_unique_validalready exist; add this one. - 🟡 packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts (L60)
prisma.user.findFirstOrThrow({ where: { id: booking.userId || 0 } })throws an uncaught Prisma P2025 (500) for bookings without auserId(e.g. collective/managed bookings or an organizer whose account was removed) instead of a handled TRPCError. Guard this case (e.g.if (!booking.userId) throw new TRPCError(...)). - 🟡 packages/emails/email-manager.ts (L540)
The attendee discriminator insendAddGuestsEmailsmatchesnewGuests.includes(attendee.email)against raw, un-normalized input. Because the handler passes the unfilteredguests(existing attendees are filtered before persisting but not before this call), re-typing the email of someone already on the booking makes that existing attendee receive a fullAttendeeScheduledEmailconfirmation instead of the 'new guests added'AttendeeAddGuestsEmail; case-variant emails also get the wrong template. Normalize both sides (.toLowerCase()) and/or pass the actually-persisted new-guest list. - 🟡 apps/web/components/dialog/AddGuestsDialog.tsx (L31)
onSuccessresetsmultiEmailValueand closes the dialog but never resetsisInvalidEmail. After (1) submitting an invalid email → error shown, (2) fixing and resubmitting → success and close, (3) reopening 'Add guests', the stale 'Emails must be unique and valid' error renders immediately over a clean/empty input. ResetisInvalidEmailinonSuccess(and ideally when the dialog opens). - 🟡 apps/web/components/dialog/AddGuestsDialog.tsx (L41)
multiEmailValueis initialized to[''], so theif (multiEmailValue.length === 0) return;guard only fires after the user manually removes every field. Clicking 'Add' without typing falls through toZAddGuestsInputSchema.safeParse, which rejects['']and shows a misleading 'Emails must be unique and valid' error. Trim/filter empty entries before validating (and decide whether an entirely-empty input should no-op instead of error).
Small things (take or leave)
- 🔵 packages/emails/templates/organizer-add-guests-email.ts (L23)
fromusesAPP_NAMEinstead ofEMAIL_FROM_NAME, unlike every other organizer template (organizer-scheduled-email.ts,organizer-location-change-email.ts, etc.). When an instance customizesEMAIL_FROM_NAME, this new email ignores it and uses the rawAPP_NAME, breaking the org-wide from-name convention. Import and useEMAIL_FROM_NAMEfrom@calcom/lib/constants. - 🔵 apps/web/components/dialog/AddGuestsDialog.tsx (L36)
const message = `${err.data?.code}: ${t(err.message)}`;is always truthy (even'undefined: <key>'), so themessage || t('unable_to_add_guests')fallback never fires. Non-TRPC/network errors renderundefined: ..., and TRPC errors expose the machine code prefix (e.g.BAD_REQUEST:). Derive the message fromerr.data?.message ?? err.messageand only prepend the code conditionally so the fallback works. - 🔵 apps/web/components/dialog/AddGuestsDialog.tsx (L25)
onOpenChange={setIsOpenDialog}only flips the flag, so closing via the overlay, Escape, or the dialog's close button leavesmultiEmailValueandisInvalidEmailuntouched — the next open shows previously typed emails and/or a stale validation error. Only the explicit Cancel button resets state. Reset form state when the dialog transitions to closed (or key the inner content offisOpenDialog). - 🔵 packages/ui/form/MultiEmail.tsx (L19)
Reassigning the destructuredvalueparameter (value = value || []) is an anti-pattern (no-param-reassign) and masks callers passingundefined. Preferconst normalizedValue = value ?? []and use it throughout. - 🔵 packages/ui/form/MultiEmail.tsx (L45)
The remove<button>insideaddOnSuffixhas noonClick; removal only works because theAddonwrapper inInputFieldattachesonClickAddonto the whole addon. Functional but brittle/fragmented — give the button its ownonClickthat calls the removal logic (keepingonClickAddonoff the field) or drop the inner button and rely on the wrapper. - 🔵 packages/ui/form/MultiEmail.tsx (L27)
<label htmlFor="emails">doesn't associate with any input (eachEmailFieldgenerates its ownuseIdid) and repeats for every row. Accessibility nit; consider omitting the standalone label or wiringhtmlForto a real input id. - 🔵 packages/ui/form/MultiEmail.tsx (L26)
Array index used as React key. Acceptable here because eachEmailFieldis fully controlled fromvalue, but when a row is removed the inputs re-index and any internal state would be lost. Low priority; a stable key isn't necessary given the controlled-input design.
✅ Feature-level checklist
Partially covered
- ⚠️ Ability to add guests to a booking via app.cal.com/bookings (fixes #14691, #15663).
The guest-add flow is functional on the primary happy path (owner adds a guest): frontend wiring, i18n keys, email templates, and iCal invites for new guests are in place and consistent with existing patterns. However, the requirement is only partially met due to caveats: the team admin/owner permission check excludes team ADMINs (only OWNERs pass), attendees are persisted before the calendar sync leading to partial-success/non-idempotent retries, email blacklist/duplicate checks are case-sensitive (security bypass possible), and calendar sync uses the caller's credentials rather than the organizer's. These should be fixed before the feature is considered fully compliant.
FriendlyReviewer found all five golden comments of this PR — recall 100%. Golden #2 (Critical in the dataset:
isTeamAdmin && isTeamOwnerinstead of||) is found as a red item with the correct degradation analysis —isTeamAdminalready returns truthy for ADMIN or OWNER, so the AND chain reduces toisTeamOwnerand locks team ADMINS out — and it cites the project convention (role === OWNER || role === ADMIN) used inconfirm.handler.tsandget.handler.ts. Golden #1 (case-sensitivity bypass of the blacklist) is found with the exact mechanism:blacklistedGuestEmailsis lowercased while the submittedguestis compared raw, so case changes bypass the block and create case-variant duplicates.A calibration gap deserves attention: golden #1 is a security bypass, yet the review files it under "Worth checking" (yellow) rather than the close-look section, while the review's executive summary asserts "no security risk was identified in the client-side files" — accurate for the frontend, but a reader could easily take it as a blanket statement. Golden #3 (existing attendees treated as new) is found with the correct mechanism (
newGuests.includesagainst raw input at email-manager.ts L540), golden #4 (no intra-input dedup) is found at the Zod-schema level with the["a@b.com", "a@b.com"]repro, and golden #5 (['']initial value) is found with the misleading validation error it produces.The 12 supplementary findings are the real value of this review. Its top red item — attendees persisted before the calendar sync without try/catch, leaving partial-success non-idempotent state on sync failure — is arguably the most damaging defect of the PR and is absent from the goldens. The other yellows (caller credentials instead of the organizer's, the missing
you_do_not_have_permissiontranslation key, thefindFirstOrThrow500 on bookings withoutuserId, the staleisInvalidEmailstate) are all legitimate. The seven blue items, however, are mostly style and accessibility nits (no-param-reassign, an unassociatedhtmlFor, array-index keys) — real but low-value, and they inflate the "2 high, 8 medium, 7 low" tally. Overall: perfect recall, strong extras on the backend, a severity-calibration question on golden #1, and a noisy low-severity tail.