Skip to main content

Resource: Organizations

Tag: Organizations · Module: organization (shrunk — space CRUD and invitations were carved out)

The organization itself. After the space (task #4) and invitation (task #5) extractions, this module keeps only org create/update. WorkOS is the source of truth for organizations.

Grounded: organization/api/{createorganization,updateorganization}.go, internal/domain/database/organization.go.


Operations

OperationIDMethod + PathRBACNotes
create-organizationPOST /organizations/create— (any authed user)collection action; creates an org; caller becomes admin
update-organizationPOST /organizations/{id}/updateEditOrganizationid = WorkOS org id (the caller's current org, from User.currentOrganizationId); RBAC verifies membership
organization-logo-upload-ticketPOST /organizations/{id}/logo/upload-ticketEditOrganizationpresigned R2 upload (step 1)
organization-logo-finalizePOST /organizations/{id}/logo/finalizeEditOrganizationvalidate + set logoUrl (step 2)
organization-logo-removePOST /organizations/{id}/logo/removeEditOrganizationclear the logo
  • switch-organization is not here — it's a session operation (SessionApiHandler, reissues tokens) and lives in the Auth/Session resource (task #7).
  • preview-organization (a public join-page org lookup) is deferred — not in the contract (no consumer today; the join route ignores the org slug). See the Deferred section below.
  • delete-organization is disabled in the current codebase (commented out) — out of scope until needed.

C10 / addressing note. The org is modeled as a collection item addressed by {id}, not a singleton — create-organization already makes /organizations a real collection, the client always holds its current org id (User.currentOrganizationId), and RBAC verifies membership. We do not use a singular /organization singleton root.


Identifier — WorkOS org id

The org has an internal Id int64 and a WorkosOrgId (uniqueIndex). Since WorkOS is the source of truth for orgs (and the auth context already carries the WorkOS org id), the wire id is the WorkOS org id — opaque, the external identity. The internal int64 stays internal. All item ops (update, logo/*) key on this {id} (the caller holds it via User.currentOrganizationId).


DTOs

Organization

{
"id": "string", // WorkOS org id
"slug": "string", // stable URL handle (e.g. "acme") — feeds the invite-link path
"name": "string",
"logoUrl": "string | null", // org icon; set via the avatar-style upload flow (read-only here)
"domains": [ /* Domain */ ] // C6 — our own type, replaces the WorkOS-SDK OrganizationDomainData
}
// Not exposed: internal int64 id, inviteId, workosOrgId-as-separate-field, initialized (all internal).

Domain

{
"domain": "string", // e.g. "acme.com"
"state": "pending | verified | failed" // C6 — verification lifecycle, from WorkOS domain_data
}

Domains are email domains for auto-join / SSO routing: a user signing up with a matching, verified domain is auto-added to the org. State is WorkOS-managed (you add a domain → pending → DNS-verify → verified).

create-organization

// Request
{
"name": "string", // required
"slug?": "string", // C8 — optional; auto-derived (slugified) from name if omitted
"domains?": ["string"] // C8 — optional; domain names to add (verification starts pending)
}
// Response — the authenticated User/session (creating an org logs you into it).
// Shape = the shared SignInAPIResponse { user: ... } — see the Auth/Session resource for the User DTO.

update-organization ({id} in path)

// Request — patches the org named by {id} (logo is changed via the upload flow, not here)
{
"name?": "string", // C8 — all fields optional (partial patch)
"slug?": "string", // rename the handle
"domains?": ["string"] // desired domain set (names; state is WorkOS-managed)
}
// Response — the updated Organization (today it leaks the raw DB row; return the clean Organization DTO)
{ /* Organization */ }

Input/output asymmetry on domains is intentional: you set domains by name (string[]); you read them back as Domain objects with verification state (you can't set state — WorkOS verifies ownership).

Organization logo (two-step direct-to-R2 upload — mirrors the user-avatar flow)

// organization-logo-upload-ticket — Request
{ "contentType": "image/jpeg | image/png | image/webp" }
// Response
{ "uploadUrl": "string", "tmpKey": "string", "expiresInSeconds": 0 }

// organization-logo-finalize — after the client PUTs the file to uploadUrl — Request
{ "tmpKey": "string" }
// Response
{ "logoUrl": "string" }

// organization-logo-remove — Request body empty {} → Response 204 No Content

Same presigned-upload pattern as user avatars (the shared image-upload flow is defined with User/Avatar, task #7); image validation is offloaded to Cloudflare Images. name/slug/domains change via update-organization; the logo changes only through these three ops.


C6 fix — drop the WorkOS-SDK type leak

CreateOrganizationRequest/UpdateOrganizationRequest currently embed *organizations.OrganizationDomainData (workos-go SDK) directly in the wire contract (organization/models/types.go:10,19). Replace with our own types: input domains: string[], output Domain[] ({domain, state}). The backend maps to/from WorkOS domain_data internally; a WorkOS SDK upgrade can no longer mutate our published contract.


Module (backend)

  • organization keeps create + update only (room CRUD → space; invitations → invitation).
  • At the wire boundary: input domains []string / output Domain{domain,state} (mapped from WorkOS domain_data); update-organization returns the clean Organization DTO, not the raw GORM model.
  • New fields (net-new backend work):
    • slug — add a column (slugify from name on create; globally unique, since it's the invite-link URL segment). buildInviteLink switches from orgNameslug, producing {baseURL}/join/{slug}/{inviteId} to match the FE member-invite route /join/$orgSlug/$token (app-shared/src/common/constants.ts). This fixes current drift (backend builds /{orgName}/join/{id} today, which that route doesn't match). Cross-refs invitations.md. (A public join-page org preview that would consume the slug is deferred — see below.)
    • logoUrl — org icon stored in R2 via the avatar-style upload-ticket → finalize flow (mirror the user-avatar pattern; not a writable URL string). Read-only in the Organization DTO.

Deferred

  • preview-organization — a public, unauthenticated org lookup (slug → name + logo) for the join page. Not in the contract. Nothing consumes it today: the member-invite route /join/$orgSlug/$token ignores the slug (memberInviteHandler reads only the token), the join screen (Auth.tsx) shows no org branding, and it's the only op not grounded in current code. Add it when a real consumer exists — a branded join page, or the future guest-access feature (earmarked as a separate /guest-passes resource in invitations.md). Open questions to settle then: keyed by {slug} vs the invite token vs a space-scoped key; org-preview vs space-preview for guests; whether the join page needs a separate public lookup at all or can resolve org info from the invite token. If kept, it must declare Security: [] explicitly and carry an unauthenticated-reachability test (a lone public op on an otherwise-authenticated resource is easy to regress).