Skip to content

feat(teams): reusable email-invitation module#139

Merged
vutuanlinh2k2 merged 5 commits into
mainfrom
feat/teams-invitations-module
Jun 23, 2026
Merged

feat(teams): reusable email-invitation module#139
vutuanlinh2k2 merged 5 commits into
mainfrom
feat/teams-invitations-module

Conversation

@vutuanlinh2k2

Copy link
Copy Markdown
Contributor

What

Extracts creative-agent's validated email-invitation system into agent-app as a reusable, seam-driven module under the existing teams capability, so creative/gtm/future apps plug it in fast. Server lifecycle + schema factory + pure email renderer + React UI. Additive only — no breaking changes to existing exports.

Closes #138.

Why

Invitations were diverged across apps: creative-agent had a production-ready dedicated workspace_invitation lifecycle (status/expiry/resend/revoke/preview + email), while agent-app's members-api only models an invite as a pending workspaceMember row and never sends email. This lifts the rich, validated flow into the shared lib behind injected seams, mirroring the members-api factory shape.

What's in it

  • teams/invitations.ts (pure leaf) — token/expiry/normalize/parse helpers + transport-free renderInvitationEmail(input, { fromAddress }). Ships under @tangle-network/agent-app/teams.
  • teams/drizzle/invitations-schema.tscreateWorkspaceInvitationTable(...), a separate opt-in factory (not folded into createTeamTables, so other teams adopters get no surprise migration). Under …/teams/drizzle.
  • teams/invitations-api.tscreateInvitationsApi(...) (create/list/resend/revoke/preview/accept) behind the new subpath …/teams/invitations-api. Seams: sendInvitationEmail (required, typed outcome, fail-soft), enforceSeat (optional, reused from members-api — fires only for a genuinely new seat), memberSyncSeam.add (optional, fired on accept).
  • teams-react — new InvitationsPanel (invite form + history with resend/revoke/copy) + InviteAcceptPage extended for expired/revoked and an optional verify-email seam; MembersPanel gains showInviteForm. Contracts widened additively.

Seam design

The only external-boundary call (mail send) stays in the consuming app's sendInvitationEmail seam and returns a typed { succeeded } outcome; agent-app ships only the deterministic template (renderInvitationEmail). Secrets/branding (RESEND_API_KEY, from-address) never enter the library. Billing and sandbox sync are optional seams, so an app wires only what it needs.

Verification

  • tsc --noEmit: clean across all new files (pre-existing, unrelated eval-campaign/sandbox peer-drift errors are untouched by this PR).
  • tsup: the new teams/invitations-api entry builds to dist.
  • vitest: 82 teams tests green, including 2 new suites (pure helpers + full lifecycle over the in-memory SQLite harness) and the 5 existing suites (the additive emailVerified column in the shared db-helper changes nothing for them).

Follow-ups (out of scope)

  • gtm-agent adoption (bind the factory, routes, settings UI, migration). Critical there: count pending workspace_invitation rows toward billable seats.
  • Migrate creative-agent off its hand-rolled module to this factory (identical column shape → zero-data migration).

The pure leaf of the email-invitation feature, shipped under the existing
`@tangle-network/agent-app/teams` subpath (no drizzle/env/react/IO):

- Token/expiry/validation helpers lifted from creative-agent's validated flow:
  `generateInvitationToken` (`inv_`-prefixed, base64url(32B), self-identifying so
  it never collides with members-api tokens), `getInvitationExpiresAt` (7d),
  `normalizeInvitationEmail`, `parseInvitationPermission` (assignable roles only),
  `inviteUrlForToken`, and the status/email-status types.
- `renderInvitationEmail(input, { fromAddress })` — a transport-free template
  returning `{ from, subject, html, text }` with html-escaping. The secret + the
  Resend/SES call stay in the consuming app's seam; agent-app ships only the
  deterministic body, so the template is reused fleet-wide without drift.

`InvitationPermission` aliases `AssignableWorkspaceRole` to keep one role vocab.
`createWorkspaceInvitationTable({ userTable, workspaceTable, organizationTable })`
under `@tangle-network/agent-app/teams/drizzle`. A SEPARATE, opt-in factory — NOT
folded into `createTeamTables` — so existing teams adopters (creative/legal/tax)
get no surprise `workspace_invitation` diff on their next drizzle-kit generate, and
an app already running that table adopts the factory with no row rewrite.

- Columns/enums/4 indexes mirror creative-agent's validated hand-rolled table.
- FKs wire into the product's user + workspace tables and the `organizations`
  table from createTeamTables; call after createTeamTables so it's in scope (lazy
  `.references()` closures make the ordering safe).
- Exports `WorkspaceInvitationRow`.
`createInvitationsApi(...)` under the new subpath
`@tangle-network/agent-app/teams/invitations-api` (wired into tsup entries +
package.json exports). The rich email-invitation lifecycle — create / list /
resend / revoke / preview / accept over the dedicated `workspace_invitation`
table — lifted out of any one app's route file, returning a discriminated
`InvitationOutcome` the route adapter maps to its framework's response.

Mirrors the members-api factory shape; app-specific I/O is injected as seams:

- `sendInvitationEmail` (required) — the app's mail transport, returning a typed
  outcome. A failed send never blocks creation (emailStatus = 'failed', link still
  returned).
- `enforceSeat` (optional, reused from members-api) — billing gate fired at create
  time only for a genuinely NEW seat (skips existing org members), so pending
  invites are counted at invite time, not surfaced as a wrong-time 402 on accept.
- `memberSyncSeam.add` (optional) — fire-and-forget propagation on accept (creative
  had no sandbox sync; gtm needs it). Fires only when a new workspaceMembers row is
  inserted.

Accept materializes organizationMembers + workspaceMembers (so members-api.listMembers
still surfaces accepted members), keeps email-verified + same-email-collision guards,
and keeps the originating token on the materialized row (inv_ tokens never collide).
…surface

The React surface for the rich invitation flow, callback-driven and styled with
the shipped `var(--*)` tokens (no app router/fetch/toast/UI-lib import):

- `InvitationsPanel` — invite by email + role and an invitation history
  (pending/accepted/expired/revoked) with email-delivery status; pending rows
  expose copy-link / resend / revoke. Backed by `./teams/invitations-api` over fetch.
- `InviteAcceptPage` extended for `expired` / `revoked` states, an optional
  verify-email branch (gated by `needsEmailVerification` / `onResendVerification`
  seams — apps without email verification omit them), and expiry display.
- `MembersPanel` gains `showInviteForm` (default true) so an app driving invites
  through InvitationsPanel mounts the members panel list-only, avoiding two
  competing invite UIs.

Contracts widened additively, so existing MembersPanel/InviteAcceptPage consumers
are unaffected. Lazy entry added for InvitationsPanel.
- `invitations.test.ts` — pure helpers (token shape/uniqueness, expiry, email
  normalize, permission parsing) and `renderInvitationEmail` (brand from-address,
  subject, html-escaping).
- `invitations-api.test.ts` — full lifecycle over the real in-memory SQLite
  harness: create (normalize, invalid-role 400, non-admin 404, duplicate 409,
  fail-soft email), enforceSeat (402 for a new seat, skipped for an existing org
  member), list (admin-only), resend/revoke (+ resend-after-revoke 409), preview,
  and accept (email-mismatch 403, unverified 403, happy path materializes
  membership + fires add sync + reachable via access, idempotent re-accept, and
  post-expiry 409).
- Adds `emailVerified` to the shared teams db-helper users table (additive,
  defaulted; the other teams suites are unaffected).

82 teams tests green.

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Auto-approved PR — da3207e4

Blanket team auto-approval is enabled for this reviewer service.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: blanket_auto_approve · 2026-06-23T07:09:30Z

@tangletools

Copy link
Copy Markdown

⚠️ Review Interrupted — da3207e4

The review runner stopped before publishing a final verdict: webhook_restarted.

State Detail
Interrupted webhook restarted

No review verdict was produced for this run. Trigger a fresh review on the current PR head if the PR is still open.

tangletools · #139 · model: kimi-for-coding · updated 2026-06-23T07:25:02Z

@vutuanlinh2k2 vutuanlinh2k2 merged commit 4887a4e into main Jun 23, 2026
1 check passed
@vutuanlinh2k2 vutuanlinh2k2 deleted the feat/teams-invitations-module branch June 23, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Reusable email-invitation module in the teams capability

2 participants