ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-invite-recipient-autocomplete.md
Randall Stillwell 820dae6510 docs(plans): split workspace-invites convoy into identity + invites + autocomplete
User pushed back on "strict email match in v1" — the right architectural
answer is multi-email identity (one users row owning multiple verified
emails), not a stopgap. Scaling the convoy accordingly:

1. Task-multi-email-identity (NEW, P1, foundation)
   - user_email_identities table (user_id, email lowercased, verified_at,
     source: primary | oauth:<provider> | manual)
   - Refactor ensureUserIdByEmail -> ensureUserIdByVerifiedEmail against
     the new table.
   - OAuth callback writes a source='oauth:<provider>' identity when the
     provider returns email_verified=true. Cross-user conflict rejects.
   - Profile UI: "Linked emails" section, read-only in v1.
   - Exports userOwnsEmail(userId, emailLower) for invite accept to call.

2. Task-workspace-invites-and-roles (existing, narrowed)
   - All the original spec.
   - Accept procedure calls userOwnsEmail() instead of comparing
     users.email directly. Mismatch renders an explainer page, not a
     silent accept.

3. Task-invite-recipient-autocomplete (NEW, P1, polish)
   - invites.suggestRecipient returns typed suggestions across four
     kinds: member / pending_invite / known_user / new_email.
   - Tenancy fence on known_user is the security-relevant assertion;
     test for it explicitly.
   - Combobox UI renders each kind with its own affordance.

Three follow-ups filed explicitly to keep this convoy PR-sized:
- Task-manual-email-verification (add an email outside OAuth)
- Task-disconnect-linked-email (destructive, needs last-verified guard)
- Task-account-merge (handle the legacy duplicate-users case)

Epic file refreshed with the new task table, follow-up table, and a
phase ordering note. Identity lands first because it touches the
sign-in path; invites and autocomplete can ship in their own PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:02:38 -05:00

5.3 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 ready 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

  • Add invites.suggestRecipient procedure to apps/web/server/routers/invites.ts.
  • Build the combobox UI in apps/web/components/teams/invite-dialog.tsx (or wherever Task 2 placed it).
  • Render each kind with its own row styling and action.
  • Wire the "scroll to existing member" interaction when a member suggestion is selected.
  • Vitest: per-kind unit tests + tenancy fence test + ordering test.
  • Run pnpm lint && pnpm type-check && pnpm test clean.

Owner or assignee

Unassigned

Status

ready

Estimation

M

Acceptance criteria

  • Typing a member's name or email surfaces their existing-member row, not a "send invite" CTA.
  • Typing the email of a pending invite surfaces the existing-invite row with copy/revoke.
  • Typing the email of someone in another shared workspace surfaces a known_user row.
  • Typing a brand-new email surfaces a new_email row last.
  • Tenancy fence holds: a user with no shared workspace overlap with the inviter does not appear under any kind, even by exact email match.
  • 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