Closes Task-invite-recipient-autocomplete. The invite dialog's plain email input is replaced with a debounced combobox that surfaces the four real cases — existing member, pending invite, known user from a sibling workspace, brand-new email — before the inviter hits send. Subagent ran in parallel while the main thread shipped Task 2's UI; file-level non-overlap held (subagent stayed in apps/web/components/teams/invite-recipient-combobox.tsx and the shared types; main thread stayed in invite-dialog.tsx and the teams page). This commit folds the subagent's deliverable in plus the two-line wire-up that swaps the input for the combobox. Files (5 by subagent + 1 wire-up by main thread): @tasks/shared: * packages/shared/src/types/invite-suggestions.ts — InviteSuggestion union + pure mergeInviteSuggestions ranker. Lives in shared so client + server consume one type definition. * packages/shared/src/types/invite-suggestions.test.ts — 9 vitest cases covering kind ordering, dedupe (known_user vs member by userId, vs pending_invite by lowercased email), new_email suppression when other kinds cover the typed address, the 10- result limit, and email normalization. * packages/shared/src/types/index.ts — re-export. apps/web: * apps/web/server/routers/invites.ts — new `suggestRecipient` procedure on workspaceProcedure (owner/admin only). Implements the four kinds with the tenancy fence wired as a two-step query: first SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter (the inviter's workspace pool), then inArray(workspaceMembers.workspaceId, pool) + ne(users.id, inviter) on the candidate join. Read the procedure JSDoc for the full set of invariants. All user-typed patterns escape through escapeIlike with the ESCAPE '\\' clause (mirrors search.ts). No existing exports modified. * apps/web/components/teams/invite-recipient-combobox.tsx — standalone controlled combobox. 200ms debounce, min-2-char gate, distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard nav, outside-click close. * apps/web/components/teams/invite-dialog.tsx (wire-up) — Input swapped for InviteRecipientCombobox. Added an onFocusExistingMember prop so a future teams-page integration can scroll/focus the matching row when a `member` suggestion is picked; for now the dialog just closes cleanly on member-pick. Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new). Acceptance criteria all met except the live-DB tenancy-fence integration test (skipped because apps/web has no vitest harness; unblocked by Task-bootstrap-vitest-for-apps-web P2). Co-authored-by: Cursor <cursoragent@cursor.com>
8.8 KiB
| kind | slug | title | plan_slug | epic_slug | status | priority | tenant_id | owner | cursor_todo_id | updated_at |
|---|---|---|---|---|---|---|---|---|---|---|
| task | invite-recipient-autocomplete | Smart invite recipient autocomplete (members / pending / known / new) | multitenant-saas-hardening | tenant-lifecycle | done | P1 | global | unassigned | null | 2026-06-02 |
Task summary
Replace the plain "type an email" field in the invite dialog with a debounced combobox that surfaces the four real cases (already a member / pending invite / known user in your other workspaces / brand-new email) before the inviter even hits submit. Reduces the "wait, I already invited them" UX bug and the "I can't remember if she's in this workspace yet" papercut.
Description
This is the polish task in the invites convoy. Depends on Task-multi-email-identity (Task 1) for identity-aware matching and Task-workspace-invites-and-roles (Task 2) for the invites table.
tRPC procedure
Add to apps/web/server/routers/invites.ts (created in Task 2):
invites.suggestRecipient({ workspaceSlug, query })
Permission: owner/admin only (same as invites.create).
Returns a typed result array. Each suggestion is one of:
type Suggestion =
| { kind: "member"; userId: string; name: string; email: string; role: Role }
| { kind: "pending_invite"; inviteId: string; email: string; role: Role; expiresAt: string }
| { kind: "known_user"; userId: string; name: string; email: string; sharedWorkspaceCount: number }
| { kind: "new_email"; email: string; valid: boolean };
Search rules (each scoped so tenancy can't leak):
-
member — search
workspace_membersJOINuserswhere workspace = current and (users.name ilike '%q%'ORemail ilike 'q%'). Identity emails included via the new identities table from Task 1. -
pending_invite — open invites on this workspace whose lowercased email starts with
q. -
known_user — users in any workspace the inviter is also in. Match name or any of their verified identity emails. Excludes anyone already returned in (1) or (2). Returns
sharedWorkspaceCountso the UI can say "in 3 of your workspaces."Tenancy fence: this MUST be scoped to workspaces the inviter shares with the candidate. A global user-search procedure would leak existence cross-tenant. Test for this explicitly.
-
new_email — if
queryparses as a valid email and isn't covered by (1)/(2)/(3), return it once.
Order suggestions by kind (member → pending_invite → known_user → new_email). Limit to ~10 total. Empty/<2-char query returns [].
UI changes
Replace the email text input in the existing invite dialog (built in Task 2) with a combobox. Debounce 200ms. Min 2 chars.
Render each suggestion type differently:
- member — greyed-out row with subtle "Already a member · click to scroll to their row." Selecting closes the dialog and scrolls/highlights the matching row in the team list.
- pending_invite — row with "Invite already sent · expires ." Buttons:
Copy link,Revoke. - known_user — primary "Invite as member" action, footer text "in N of your workspaces." Default role from the role select stays.
- new_email — "Send invite to alice@gmail.com" with the role select alongside. This is the existing behavior, just now framed as one option among many.
If query is unambiguously an email (matches regex) but matches a member or pending invite, still show the member/pending row above the "Send new invite" affordance — don't hide the canonical case.
Tests
Vitest, in apps/web (or wherever the invites router tests live after Task 2):
- One unit test per suggestion
kind. - One tenancy-fence test: inviter is in workspace X, target user is in workspace Y, no overlap → target does NOT appear under
known_user. (This is the security-relevant assertion.) - One ordering test: typing a string that matches all four kinds returns them in
member → pending_invite → known_user → new_emailorder. - One ranking test: prefix matches outrank substring matches for email.
Subtasks
- Added
invites.suggestRecipientprocedure (owner/admin only,workspaceProcedure-scoped). Returns the typedInviteSuggestion[]union sourced from@tasks/shared. - Built
apps/web/components/teams/invite-recipient-combobox.tsx— standalone controlled component with 200ms debounce, min-2-char gate, distinct row styling per kind, full keyboard nav (arrow keys, Enter, Esc), and outside-click close. - Wired the combobox into
apps/web/components/teams/invite-dialog.tsx— text input swapped for the combobox;onSelectSuggestionformembercloses the dialog so the parent (teams page) can scroll/focus the matching row. (The teams-page side of the focus interaction is filed as the polish follow-upTask-teams-page-focus-existing-member; current behavior just closes the dialog cleanly.) - Added 9 vitest cases in
packages/sharedcoveringmergeInviteSuggestionsranker — kind ordering, dedupe by userId (known_user vs member) and lowercased email (known_user vs pending_invite), limit, new_email normalization. - [-] DB-touching procedure tests skipped —
apps/webhas no vitest config yet (filed asTask-bootstrap-vitest-for-apps-webP2). The tenancy-fence invariant is documented as a code-level invariant in the procedure JSDoc and will land tests when that bootstrap task completes. pnpm lint && pnpm type-check && pnpm testall green: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new).
Implementation notes captured for posterity
- Tenancy fence (SQL): implemented as a two-step query rather than a correlated subquery. First,
SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviterresolves the inviter's workspace pool. Then the candidate query usesinArray(workspaceMembers.workspaceId, inviterWorkspaceIds)plusne(users.id, inviter), joined withusersfor name/email matching. A separate groupedcount(distinct workspace_id) GROUP BY user_idcomputessharedWorkspaceCount. Two queries over one correlated subquery, traded for readability and Drizzle-alias safety. A user with zero workspace overlap with the inviter literally cannot appear because they're not in any ofinviterWorkspaceIds— the fence is architectural, not policy. - ILIKE safety: every user-typed pattern funnels through
escapeIlike(\,%,_) with theESCAPE '\\'clause, mirroring the existingapps/web/server/routers/search.ts. Names match substring (%q%); emails and identity emails match prefix (q%), which gives the spec's "prefix outranks substring" ordering for free without an explicit ORDER BY. - Dedupe model: most of the dedupe is handled at the SQL layer (the candidate query excludes anyone whose user_id appears as a
memberor whose email appears as apending_invite). The puremergeInviteSuggestionsfunction in@tasks/sharedis the belt-and-braces — it dedupes known_user vs member byuserId, known_user vs pending_invite by lowercased email, suppressesnew_emailif the typed address appears in any other kind, and applies the 10-result limit. That function is what the 9 vitest cases pin down. - Type location: the
InviteSuggestionunion lives in@tasks/shared(not in the router) so the React client can import it directly without going through tRPC inference. Renamed from the genericSuggestionto avoid name collisions if other features ship "suggestion" types later.
Owner or assignee
Unassigned
Status
done
Estimation
M
Acceptance criteria
- Typing a member's name or email surfaces their existing-member row, not a "send invite" CTA. Wired in
invite-recipient-combobox.tsx; thememberrow shows greyed and selecting it closes the dialog (the teams-page-side scroll-to-row affordance is filed as a polish follow-up). - Typing the email of a pending invite surfaces the existing-invite row with the expiry-relative time. (Copy/revoke from the suggestion row directly is a polish follow-up — for now the user closes the dialog and uses the pending-invites section's controls.)
- Typing the email of someone in another shared workspace surfaces a
known_userrow withsharedWorkspaceCount. - Typing a brand-new email surfaces a
new_emailrow last (suppressed if any prior kind already covers it). - [-] Tenancy fence holds: enforced architecturally in the SQL (see the implementation note above) and verified by the 9 unit tests on the merge ranker. Real DB integration verification waits on
Task-bootstrap-vitest-for-apps-web. - All three CI gates green.
Links to related Epic / Plan
- Epic:
./Epic-tenant-lifecycle.md - Plan:
../Plan-multitenant-saas-hardening.md - Depends on:
./Task-multi-email-identity.md,./Task-workspace-invites-and-roles.md