ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-invite-recipient-autocomplete.md
Randall Stillwell 29e69e964b feat(invites): smart recipient autocomplete combobox (Task 3, done)
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>
2026-06-02 10:39:09 -05:00

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):

  1. member — search workspace_members JOIN users where workspace = current and (users.name ilike '%q%' OR email ilike 'q%'). Identity emails included via the new identities table from Task 1.

  2. pending_invite — open invites on this workspace whose lowercased email starts with q.

  3. 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 sharedWorkspaceCount so 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.

  4. new_email — if query parses 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_email order.
  • One ranking test: prefix matches outrank substring matches for email.

Subtasks

  • Added invites.suggestRecipient procedure (owner/admin only, workspaceProcedure-scoped). Returns the typed InviteSuggestion[] 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; onSelectSuggestion for member closes 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-up Task-teams-page-focus-existing-member; current behavior just closes the dialog cleanly.)
  • Added 9 vitest cases in packages/shared covering mergeInviteSuggestions ranker — 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 skippedapps/web has no vitest config yet (filed as Task-bootstrap-vitest-for-apps-web P2). 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 test all 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 = inviter resolves the inviter's workspace pool. Then the candidate query uses inArray(workspaceMembers.workspaceId, inviterWorkspaceIds) plus ne(users.id, inviter), joined with users for name/email matching. A separate grouped count(distinct workspace_id) GROUP BY user_id computes sharedWorkspaceCount. 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 of inviterWorkspaceIds — the fence is architectural, not policy.
  • ILIKE safety: every user-typed pattern funnels through escapeIlike (\, %, _) with the ESCAPE '\\' clause, mirroring the existing apps/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 member or whose email appears as a pending_invite). The pure mergeInviteSuggestions function in @tasks/shared is the belt-and-braces — it dedupes known_user vs member by userId, known_user vs pending_invite by lowercased email, suppresses new_email if 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 InviteSuggestion union lives in @tasks/shared (not in the router) so the React client can import it directly without going through tRPC inference. Renamed from the generic Suggestion to 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; the member row 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_user row with sharedWorkspaceCount.
  • Typing a brand-new email surfaces a new_email row 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.
  • Epic: ./Epic-tenant-lifecycle.md
  • Plan: ../Plan-multitenant-saas-hardening.md
  • Depends on: ./Task-multi-email-identity.md, ./Task-workspace-invites-and-roles.md