Skip to content

feat: implement team endpoints with WorkOS invitation API#2003

Open
adaam2 wants to merge 19 commits intomainfrom
feat/team-endpoints
Open

feat: implement team endpoints with WorkOS invitation API#2003
adaam2 wants to merge 19 commits intomainfrom
feat/team-endpoints

Conversation

@adaam2
Copy link
Copy Markdown
Member

@adaam2 adaam2 commented Mar 26, 2026

Summary

  • Implement all 7 team service endpoints using WorkOS invitation & user management APIs
  • ListMembers: Lists org members via WorkOS, resolves to Gram-internal user IDs
  • InviteMember: Sends WorkOS invitations with org scoping and inviter attribution
  • ListInvites: Lists pending invitations with inviter name resolution
  • CancelInvite: Revokes invitations with org-scoped authorization (IDOR-safe)
  • ResendInvite: Resends invitation emails with org-scoped authorization
  • GetInviteInfo: Public endpoint to display invite details before accepting (token-based lookup)
  • RemoveMember: Deletes WorkOS org membership + soft-deletes local relationship
  • Add WorkOS wrapper methods for the full invitation lifecycle (send, list, revoke, resend, get, find-by-token)
  • Add GetUserByWorkosID SQL query for reverse-mapping WorkOS users to Gram users
  • Add GetOrganizationMetadataByWorkosID SQL query for org name resolution
  • Comprehensive httptest-based tests for all WorkOS client methods (22 test cases)
  • Regenerated Speakeasy TypeScript SDK with react-query hooks

Test plan

  • All WorkOS wrapper methods tested with httptest mock server
  • Nil WorkOS client returns proper service errors
  • Pagination verified for ListUsersInOrg and ListInvitations
  • Server lint passes (golangci-lint)
  • Client SDK builds and lints
  • Manual testing against WorkOS dev environment

🤖 Generated with Claude Code

@adaam2 adaam2 requested a review from a team as a code owner March 26, 2026 15:48
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Mar 31, 2026 10:28am

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 26, 2026

🦋 Changeset detected

Latest commit: ebdc0f9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
server Minor
dashboard Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@adaam2 adaam2 changed the title feat: add team service endpoints (stubbed for WorkOS) feat: implement team endpoints with WorkOS invitation API Mar 26, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Mar 26, 2026

Choose a reason for hiding this comment

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

📝 Info: Orphaned servechatattachmentsignedform.ts uses zod/v3 while rest of SDK uses zod/v4-mini

The file client/sdk/src/models/components/servechatattachmentsignedform.ts was added in this PR but imports from zod/v3 while every other file in the SDK uses zod/v4-mini. Additionally, this file is not exported from the components index (client/sdk/src/models/components/index.ts) and is not imported by any other file. It appears to be an orphaned artifact from code generation. Since the file is unreferenced, it has no runtime impact, but the inconsistent zod version could cause issues if it's exported in the future.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@blacksmith-sh

This comment has been minimized.

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +431 to +440
// Also soft-delete the local relationship
if err := s.orgRepo.DeleteOrganizationUserRelationship(ctx, orgRepo.DeleteOrganizationUserRelationshipParams{
OrganizationID: authCtx.ActiveOrganizationID,
UserID: payload.UserID,
}); err != nil {
s.logger.ErrorContext(ctx, "failed to delete local org-user relationship after WorkOS removal",
attr.SlogError(err),
attr.SlogOrganizationID(authCtx.ActiveOrganizationID),
)
}
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Mar 30, 2026

Choose a reason for hiding this comment

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

🚩 RemoveMember performs WorkOS deletion before local DB cleanup — partial failure possible

In RemoveMember at server/internal/teams/impl.go:418-433, the WorkOS membership is deleted first, then the local organization_user_relationships row is soft-deleted. If the local DB delete fails (line 425-433), the error is only logged — the function returns nil (success). This means the WorkOS state and local DB can become inconsistent: the user is removed from WorkOS but still appears as a member locally. The current approach (log-and-continue) is a pragmatic choice since the WorkOS deletion already succeeded and can't be rolled back, but this inconsistency could cause issues if ListMembers relies solely on the local DB (which it does, at line 171). Consider adding a background reconciliation mechanism or at minimum returning an error that indicates partial success.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

adaam2 and others added 18 commits March 31, 2026 11:14
Add Goa design + generated code for 7 team endpoints:
ListMembers, InviteMember, ListInvites, CancelInvite,
ResendInvite, GetInviteInfo, RemoveMember.

All implementations return "not implemented" — ready for
WorkOS API calls to be wired in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wire up all 7 stubbed team service endpoints to WorkOS user management:
ListMembers, InviteMember, ListInvites, CancelInvite, ResendInvite,
GetInviteInfo, and RemoveMember. Add WorkOS wrapper methods for the
invitation lifecycle and org membership deletion, with pagination
support for list operations. Include httptest-based tests for all
WorkOS client methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pass db to auth.New() to prevent latent nil-pointer dereference
- Add org-scoped authorization to CancelInvite and ResendInvite (IDOR fix)
- Return Gram-internal user IDs from ListMembers via GetUserByWorkosID lookup
- Validate payload.OrganizationID matches active session org
- Add requireWorkOS() helper for proper service error on nil WorkOS client
- Add GetInvitation and GetUserByWorkosID methods for authorization checks
- Fix exhaustruct, paralleltest, and testifylint lint violations
- Regenerate Speakeasy SDK for new team endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- ResendInvite now calls resolveInviterName instead of returning empty string
- Add integration tests for all team service endpoints covering:
  - Authorization flow (org access validation, IDOR protection)
  - Self-removal prevention
  - WorkOS-not-configured error handling
  - Inviter name resolution
- Add NewTestManagerWithWorkOS helper for injecting WorkOS in tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…est routing

- InviteMember now uses resolveInviterName() instead of raw email for
  the InvitedBy field, consistent with ListInvites and ResendInvite
- Fix test ServeMux routing: register /resend sub-path as separate
  handler since Go's ServeMux requires exact path matches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Aligns with codebase convention of including org ID, user ID, and
invite ID in .Log() calls for better production observability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- ListMembers now uses ListOrgMemberships instead of ListUsersInOrg,
  so JoinedAt reflects when the user joined the org, not when their
  account was created
- Restore SDK version to 0.32.12 (was regressed to 0.32.8 during rebase)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ListMembers now queries organization_user_relationships joined with
users table, eliminating the WorkOS API call. This gives correct
JoinedAt timestamps, avoids the unsynced-user skip problem, and
reduces external API dependencies.

Also seeds org-user relationship in InitAuthContext test helper to
match the production login flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Change status enum from "cancelled" to "revoked" to match WorkOS
InvitationState values, avoiding the need for a mapping layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Collect unique inviter user IDs and resolve each once, avoiding
N+1 WorkOS API calls when multiple invites share the same inviter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…haustruct lint

The FE labels the section "Pending Invites" and doesn't filter client-side,
so the API should only return pending invites. Also adds missing struct fields
to satisfy the exhaustruct linter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The inviter is always a Gram user (they logged in to send the invite),
so we can look up their display_name via GetUserByWorkosID instead of
making an external WorkOS GetUser API call per inviter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests that don't exercise WorkOS code paths (ListMembers, org mismatch
checks, self-removal) now pass nil instead of spinning up a mock server.
Remove unused workos field from testInstance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use consistent invite IDs (inv_01, inv_02) instead of random names
  from iterative churn (inv_abc, inv_other, inv_foreign, inv_resend)
- Extract testWorkOSInviterID constant for inviter WorkOS user IDs
- Remove unused sessionManager field from testInstance
- Consolidate WorkOS-not-configured test into TestInviteMember subtests
- Add inviter name assertion to InviteMember test
- Add org name assertion to GetInviteInfo test
- Add revoked invite to ListInvites test data for better filtering coverage
- Reuse single userRepo.Queries instance in seed helpers
- Add comment explaining why seedWorkOSIDs is needed in GetInviteInfo
- Use consistent org ID "org_different" in IDOR tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

// NewForTest creates a WorkOS client backed by a custom usermanagement.Client.
// This is intended for use in tests where the endpoint and HTTP client are overridden.
func NewForTest(logger *slog.Logger, client *usermanagement.Client) *WorkOS {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would you consider using the other WorkOS client we created in roles.go? My goal is to merge these 2 eventually, and if you use that one you can pass a custom client with opts instead of having this extra method:

type RoleClientOpts struct {
	// Endpoint overrides the WorkOS base URL for both raw HTTP and SDK calls.
	Endpoint string
	// HTTPClient overrides the default retryable HTTP client.
	HTTPClient *http.Client
}

return &user.Data[0], nil
}

func (w *WorkOS) ListUsersInOrg(ctx context.Context, workOSOrgID string) ([]usermanagement.User, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As per a comment in a previous PR, we shouldn't expose WorkOS types in our client - we should create our own types to wrap around it.

It may seem like a pain to manage these types, but they prevent leaky abstractions, and let you define the data model that's relevant for your app, instead of having to rely on external ones.

}

func (w *WorkOS) ResendInvitation(ctx context.Context, invitationID string) (usermanagement.Invitation, error) {
if w == nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FWIW we shouldn't be spreading these checks through every method we have. I've created a stub.go that creates a stub workos client when we don't provide an API key to avoid creating nil clients.

If you want you can use that and start getting rid of these checks - if not I'm going to refactor this workos stuff a bit later anyway.

sessions *sessions.Manager
auth *auth.Auth
orgRepo *orgRepo.Queries
userRepo *userRepo.Queries
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Don't store queries in service struct (we need to add this in our skills), store the db connection and init queries when needed

// getAuthContext extracts and validates the auth context from the request.
func (s *Service) getAuthContext(ctx context.Context) (*contextvalues.AuthContext, error) {
authCtx, ok := contextvalues.GetAuthContext(ctx)
if !ok || authCtx == nil || authCtx.ActiveOrganizationID == "" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Got the same feedback - at this point ActiveOrganizationID should always be present


// requireWorkOS returns the WorkOS client or a proper service error if not configured.
func (s *Service) requireWorkOS() (*workos.WorkOS, error) {
w := s.sessions.WorkOS()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we storing workOS inside a session instead of injecting it in the service struct as a dependency?

Comment on lines +189 to +190
TeamInviteIDKey = attribute.Key("gram.team.invite.id")
TeamInviteStatusKey = attribute.Key("gram.team.invite.status")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
TeamInviteIDKey = attribute.Key("gram.team.invite.id")
TeamInviteStatusKey = attribute.Key("gram.team.invite.status")
TeamInviteIDKey = attribute.Key("gram.team_invite.id")
TeamInviteStatusKey = attribute.Key("gram.team_invite.status")

return s.Authenticate(ctx, "")
}

func (s *Manager) WorkOS() *workos.WorkOS {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this should not be here. likewise for the billing method below. session manager is not to be treated as a dependency provider.

}

// getAuthContext extracts and validates the auth context from the request.
func (s *Service) getAuthContext(ctx context.Context) (*contextvalues.AuthContext, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not worth DRYing up. inline it for now and i'll come up with a better check as part of current work.

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.

3 participants