No description
Find a file
Randall Stillwell 7a55d6d1c6 feat(invites): workspace_invites schema + tRPC router + role management (Task 2, part 1/2)
Schema half of Task-workspace-invites-and-roles. Lands the table, the
invites router (create/list/revoke/accept), and the two new workspaces
procedures (updateMemberRole/removeMember). UI ships in part 2/2.

This is a stable checkpoint for Task 3 (invite-recipient-autocomplete)
to start building against — the procedure surface area is frozen and the
new identity helper from Task 1 is in the accept path.

Schema:
* workspace_invites: id, workspace_id, email (lowercased), role
  (owner/admin/member), invited_by_user_id, token (base64url 32B),
  expires_at (DEFAULT now() + 14d), accepted_at, revoked_at, created_at.
* Indexes: workspace_id, UNIQUE(token), and a PARTIAL UNIQUE on
  (workspace_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL.
  An open invite is unique per (workspace, email); closed invites
  (accepted or revoked) fall out of the constraint so re-invites work.
* Drizzle relations wired: workspaceInvites.workspace,
  workspaceInvites.invitedBy, workspaces.invites.
* Migration 0006_broad_lethal_legion applied to dev DB.

invites router:
* create({email, role}) on workspaceProcedure (owner/admin only).
  Generates a base64url token from 32 random bytes via node:crypto.
  Idempotent on (workspace, email) — if an open invite already exists,
  returns it instead of inserting (the partial unique would block it
  anyway). Refuses self-invite. Refuses if the email is already a
  member.
* list() returns pending (non-accepted, non-revoked) invites with
  inviter name/email joined for UI display.
* revoke({inviteId}) authorizes against the invite's workspace, not
  the caller's input (the inviteId carries its own tenant scope).
* accept({token}) is protectedProcedure (no workspace handle). Calls
  userOwnsEmail() from Task 1 — if the caller doesn't own the invited
  email under any of their verified identities, throws FORBIDDEN with
  a structured cause ({reason: "email_not_owned", invitedEmail}) so
  the redeem page can render the "link this email" explainer. Handles
  expiry, revoked, already-accepted states with clear messages.
  Idempotent on existing membership — if you've already been added by
  another flow, accept just closes the invite without re-inserting.

workspaces additions:
* updateMemberRole: admin/owner only. Three guards:
    1. Can't change your own role (avoids accidental lockout).
    2. Can't demote the only owner-role member (would leave the
       membership-level ownership empty even though workspaces.owner_user_id
       still points there — see ADR-pragmatic decision documented in the
       Task-multi-email-identity convoy discussion).
    3. Only owners can promote to owner; admins move people between
       admin/member but cannot create a new owner.
* removeMember: admin/owner OR self (the leave-workspace affordance).
  Same last-owner guard. Admins can't remove owners (only owners can,
  via demote-then-remove).

Wired both new routers into root.ts as `invites` and `identity`
(identity was landed in Task 1; this commit just keeps the registration
visible alongside invites).

All three CI gates green: 0 lint errors, 14 unchanged warnings, 6/6
type-check, 14/14 tests (no new tests yet — apps/web vitest harness is
filed as Task-bootstrap-vitest-for-apps-web P2).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:27:44 -05:00
.cursor/rules docs: add AGENTS.md and Cursor rules 2026-05-06 22:14:16 -05:00
.github/workflows test(ci): bootstrap vitest + GitHub Actions and lock in 3-gate quality bar 2026-06-02 09:23:55 -05:00
apps feat(invites): workspace_invites schema + tRPC router + role management (Task 2, part 1/2) 2026-06-02 10:27:44 -05:00
config feat: ECHODO app shell, Coolify deploy, Authentik + Umami 2026-04-26 14:34:34 -05:00
docker docker: raise web build V8 heap to 5 GB to match 8 GB host 2026-04-28 13:33:46 -05:00
docs feat: ECHODO app shell, Coolify deploy, Authentik + Umami 2026-04-26 14:34:34 -05:00
packages feat(invites): workspace_invites schema + tRPC router + role management (Task 2, part 1/2) 2026-06-02 10:27:44 -05:00
plans feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2) 2026-06-02 10:14:16 -05:00
.env.example feat: ECHODO app shell, Coolify deploy, Authentik + Umami 2026-04-26 14:34:34 -05:00
.gitignore feat: ECHODO app shell, Coolify deploy, Authentik + Umami 2026-04-26 14:34:34 -05:00
AGENTS.md test(ci): bootstrap vitest + GitHub Actions and lock in 3-gate quality bar 2026-06-02 09:23:55 -05:00
package.json test(ci): bootstrap vitest + GitHub Actions and lock in 3-gate quality bar 2026-06-02 09:23:55 -05:00
pnpm-lock.yaml test(ci): bootstrap vitest + GitHub Actions and lock in 3-gate quality bar 2026-06-02 09:23:55 -05:00
pnpm-workspace.yaml feat: Full project management application scaffold 2026-03-26 22:39:16 -05:00
tsconfig.json feat: Full project management application scaffold 2026-03-26 22:39:16 -05:00
turbo.json test(ci): bootstrap vitest + GitHub Actions and lock in 3-gate quality bar 2026-06-02 09:23:55 -05:00