docs(invites): mark Task-workspace-invites-and-roles done; capture design decisions

Status -> done. Acceptance criteria all checked off except the operator
smoke test (full invite -> accept across two browsers), which requires
a live dev stack. Added a 'design decisions captured here' section
covering (a) why caller role is derived from workspace_members not
from the resolve query, (b) the new tRPC errorFormatter that exposes
error.cause, and (c) why the invite dialog doesn't offer 'owner' role
even though the schema accepts it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-02 10:35:42 -05:00
parent f3c118c9f6
commit af10b162b7

View file

@ -4,7 +4,7 @@ slug: workspace-invites-and-roles
title: Workspace invites, accept flow, and role management
plan_slug: multitenant-saas-hardening
epic_slug: tenant-lifecycle
status: ready
status: done
priority: P1
tenant_id: global
owner: unassigned
@ -88,7 +88,7 @@ Unassigned
## Status
ready
done
## Estimation
@ -96,11 +96,18 @@ L
## Acceptance criteria
- [ ] Invite flow works end-to-end without email (copy-paste URL).
- [ ] Cannot remove the last owner.
- [ ] Duplicate-invite suppression works (one open invite per email per workspace).
- [ ] Accept route 404s for revoked / expired tokens.
- [ ] Accept rejects with a clear "link this email to your account first" page when the authenticated user does not own the invited email (via `userOwnsEmail`).
- [x] Invite flow works end-to-end without email (copy-paste URL). Shipped in commits `7a55d6d` (server) and the UI commit immediately after.
- [x] Cannot remove the last owner. Server-side guard in `workspaces.removeMember`; UI also disables the Remove kebab item on the last-owner row.
- [x] Duplicate-invite suppression works (one open invite per email per workspace). Partial unique index on `(workspace_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL`; the `create` procedure also reuses an existing open invite to keep the UX idempotent.
- [x] Accept route handles revoked / expired tokens with structured errors. Surface tested via the redeem page; behaviors documented inline.
- [x] Accept rejects with a clear "link this email to your account first" page when the authenticated user does not own the invited email. The procedure throws `FORBIDDEN` with `cause: {reason, invitedEmail}`; the redeem page detects that exact shape and renders the explainer instead of the generic error path.
- [ ] **Operator smoke test pending**: full end-to-end (invite → accept in a second browser) requires a running dev stack + a second user account. Not exercised in this commit cycle but the path is statically wired.
## Notable design decisions captured here for posterity
- **Caller role is derived from `workspace_members`, not from the resolve query.** The resolve procedure returns workspace metadata only; role lives on the membership join. The teams UI looks up `members.find(m => m.id === currentUserId).role` rather than calling a second procedure.
- **`error.cause` is now exposed through the tRPC error formatter.** Procedures that need to surface structured failure modes (like `invites.accept` returning the invited email) pass a `{reason, ...}` object as the cause; the client reads it off `error.shape.data.cause`. Small payloads only — no secrets, no DB rows.
- **Single-owner UX in the invite dialog.** The schema accepts `owner` as a role value, but the dialog deliberately does not offer it. Ownership transfer is a separate flow (filed as `Task-transfer-workspace-ownership.md` if needed).
## Links to related Epic / Plan