Skip to content

Organizations

Pitchbox supports multi-tenant orgs. On a fresh install a default org is seeded and the first user joins as owner. Every project, campaign, draft, run, account, and blocklist entry is scoped to an org through projects.organization_id. Nobody who arrives after that first user ever lands in default automatically: an invited user joins whichever org sent the invite, and a self-registered user (/register with no token, only reachable when registration is open) gets a brand-new single-owner org of their own instead.

Roles

RoleCan inviteCan change rolesCan remove membersNotes
owneryesyesyesFirst user
adminyesyesyesSame as owner today
membernononoDefault invite role

Tenant scoping

The hook handle in web/src/hooks.server.ts runs loadOrganizationForUser on every authenticated request and either:

  1. Sets event.locals.org = { id, slug, role } and continues, or
  2. Returns 404 if the user has no membership (not 403 - we don't leak existence).

/invite/* and /api/orgs/* are exempt because a newly registered user with no membership still needs to accept an invite.

Server queries either filter projects.organization_id directly (e.g. listProjects({ organizationId })) or call one of the helpers in @pitchbox/shared/orgs:

  • projectBelongsToOrg(db, projectId, orgId)
  • campaignBelongsToOrg(db, campaignId, orgId)
  • draftBelongsToOrg(db, draftId, orgId)
  • runBelongsToOrg(db, runId, orgId)

Each returns false for cross-tenant access; the route then returns 404.

Invite flow

  1. Admin generates a link

    http
    POST /api/orgs/<slug>/invites
    Content-Type: application/json
    
    { "role": "member", "email": "alice@example.com" }

    Response:

    json
    {
      "token": "…48 hex chars…",
      "url": "https://.../invite/<token>",
      "expiresAt": "…ISO8601…",
      "emailSent": true
    }

    The invite is valid for 7 days and is single-use (the row is marked accepted_at once consumed). When the request carries email, the invite is also sent through whatever mail transport is configured, using the same link; emailSent reports whether a real transport actually took it, since the null default (nothing configured) logs it instead of delivering it. The url in the response is the fallback either way - /settings/organization's Invite member dialog always shows a Copy link action next to a pending invite, sent or not.

  2. Invitee visits /invite/<token>

    • Not logged in? Redirected to /register?next=/invite/<token> (#504) - an invite is an account nobody has yet, not a login form. /register prefills the email field when the invite carried one and offers a "Sign in instead" link back to /login for someone who already has an account. Registering with the invite's token accepts it in the same transaction that creates the account.
    • Logged in? The page server calls acceptInvite, creates a membership, and redirects to /.
  3. Programmatic accept

    http
    POST /api/orgs/<slug>/invites/<token>/accept

    Same effect as visiting the page. Returns { organizationId, role } or 404 if the token is invalid/expired/consumed.

Organization management

/settings/organization (reached from the org switcher) is the org home: rename the org (admin+), a roles reference, the member list with role change and removal (owner/admin, owner-protected), pending invites with revoke, and a danger zone to leave the org (blocked for the sole owner) or delete it (owner, non-default, typed confirm). Roles are enforced server-side; see permissions.md.

On the cloud edition, /settings/billing and the two routes it calls (POST /api/billing/checkout, POST /api/billing/portal) are admin-and-owner only, the same requireRole(event, 'admin') gate as organization management above - a member can see the plan but cannot buy, change or cancel one. See Billing and permissions.md for the full route-to-role table.

Database

users                 id, username (unique), password_hash, email (nullable, unique), is_instance_admin
organizations         id, slug (unique), name
memberships           id, organization_id, user_id, role, created_at  (unique org+user)
org_invites           id, organization_id, token (unique), email, role,
                      expires_at, created_at, accepted_at, created_by_user_id
projects              … organization_id → organizations.id

Every other tenant-scoped table reaches the org through projects.organization_id.

AGPL-3.0-or-later · Pitchbox