Skip to content

Partners — Domain Spec

A partner is a partners row plus a set of partner_memberships rows that grant individual users a scoped role within it. This page is the rules the rest of the product trusts about partner authority, scoping, and membership lifecycle.

Mental model

Three shapes are load-bearing:

  1. A partner is not a user. Partner is its own entity with its own id. Nothing in the consumer graph (friendship, chat, moments, presence) references a partner. The only thing that does reference a partner is the membership table and (in the future) partner-owned resources like events or listings.
  2. Authority is membership-based. A user has authority over a partner only if a PartnerMembership row exists for the (partner_id, user_id) pair. The user's global account_type and role are irrelevant — a standard user can be a partner owner, and an account_type = partner user can have no memberships at all.
  3. URL is the source of truth for scope. Every partner-bound action is at /api/v1/partner/:partner_id/.... There is no "current partner" in the JWT or session — switching partners is navigation, not state.

Statuses and roles

Partner-level status:

Status Meaning Set by
active Operational Default on Partner.Create
suspended Members can still log in (account untouched), but partner-scoped endpoints return 403 Admin action (TBD)

Per-membership role:

Role May
owner Manage billing (future), delete the partner, manage other owners/admins, plus everything below
admin Manage staff, manage all partner resources, update the partner
staff Manage partner resources only — no member management, no billing

Sign-up / membership lifecycle

A partner is created by any authenticated user. The creator is automatically inserted as the first owner.

Transition Trigger
(none) → membership POST /api/v1/partner/:partner_id/members by an existing admin/owner
membership → (none) DELETE /api/v1/partner/:partner_id/members/:user_id by an admin/owner, OR self-removal
role change PATCH /api/v1/partner/:partner_id/members/:user_id by an admin/owner

The "≥1 owner per partner" invariant blocks two specific operations:

  • Removing the last owner returns 409 Conflict (ErrLastOwner).
  • Demoting the last owner to admin or staff returns 409 Conflict (ErrLastOwner).

To transfer ownership, promote someone else to owner first, then remove or demote the original owner.

Invariants

Invariant Why it matters
Slug is unique, lowercase alphanumeric + hyphen, 2–64 chars URLs and display references are stable and predictable
One membership per (partner_id, user_id) Enforced by composite unique index idx_partner_user
≥1 owner per partner Enforced transactionally on remove / demote; blocks orphaning the tenant
Partner creation makes the caller owner PartnerService.Create issues partner + membership atomically; rolls back partner if membership insert fails
Account-type partner doesn't grant access to any partner Authority is membership-only; the flag only hides the account from consumer surfaces

Authorization

Per-request authorization on partner-scoped routes goes through one of three middlewares in backend/internal/access:

Middleware Allowed roles Used for
RequirePartnerAnyMember owner / admin / staff Read-only views (get partner, list members)
RequirePartnerAdmin owner / admin Updating partner, adding/removing/promoting members
RequirePartnerOwner owner Deleting the partner, future billing

Each middleware reads :partner_id from the route and does one DB lookup against partner_memberships. There is no claim-side cache today; partner permission changes take effect on the next request.

Cross-domain interactions

Other domain Assumption
Identity A user can belong to N partners; their account_type is independent of any membership. Standard accounts can be partner members.
Friends Partner accounts (the rare case where a partner has its own login) are filtered out of friend search and friend suggestions.
Discovery Partner accounts don't surface in user search. Partner-owned resources (events, moments, etc.) are not yet modelled — added per-feature when the first partner-owned thing ships.

Privacy / visibility

Field Default Notes
Partner name + slug Public to authenticated users Anyone can GET /api/v1/partner/:partner_id if they know the ID
Member list Members only Returned by GET /api/v1/partner/:partner_id/members to any member
Membership role Members only Same surface

See also