Skip to content

Partners service

Partners are Tomoda's multi-tenant scoping anchor: merchants, advertisers, organizations, or any other non-user entity that needs its own data and its own group of operators. Access to a partner is granted via PartnerMembership rows, not via the global User.Role field — so a standard user can also be a partner staff member, and an account_type = partner account doesn't automatically have access to any specific partner until a membership is created.

Source file Purpose
backend/internal/models/partner.go Partner, PartnerMembership, PartnerStatus, PartnerMembershipRole, PartnerMemberView
backend/internal/services/partner/store.go CRUD + membership + "≥1 owner" invariant, partner.ErrLastOwner
backend/internal/services/partner/service.go Slug validation, creator-becomes-owner rule
backend/internal/services/partner/handler.go REST endpoints
backend/internal/services/partner/routes.go Route registration; gates injected as func(http.Handler) http.Handler middleware
backend/internal/access/partner.go RequirePartner* gate builders

Data model

Partner (id, slug, name, status, timestamps)
  └─ PartnerMembership (partner_id, user_id, role)
       role ∈ {owner, admin, staff}
       UNIQUE (partner_id, user_id)
  • slug is unique, URL-safe (lowercase alphanumerics + hyphens, 2-64 chars), but routes use {partner_id} (UUID) rather than slug: the slug is for display.
  • status is active or suspended (PartnerStatus), defaulting to active via the model's BeforeCreate hook. It records operational state; the access gates key off membership + role, not status.

See architecture/data-model.md → Partners for the cross-domain view.

Membership roles

Distinct from the global UserRole enum — these mean different things.

Role Allowed actions
owner Billing, delete partner, manage other owners/admins/staff. ≥1 required.
admin Manage staff and all partner resources.
staff Manage resources; cannot manage members or billing.

Access gates

All defined in backend/internal/access/partner.go. Each builds a func(http.Handler) http.Handler middleware that reads {partner_id} from the route and looks up the caller's membership. routes.go takes these as parameters, so the partner package stays free of the access dependency; internal/wiring/router.go wires them in.

Gate builder Allowed roles Use for
RequirePartnerAnyMember(store) owner, admin, staff Read-only partner views
RequirePartnerAdmin(store) owner, admin Write, member management
RequirePartnerOwner(store) owner Delete partner, billing, owner mgmt

One DB read per request. A membership rejection returns 403. Cache (e.g. Redis partner_membership:<p>:<u> with invalidation on add/remove/role-change) is a follow-up if this becomes a hot path.

On success the gate places partner_id and partner_role onto the request context (api.WithPartner), read downstream via api.PartnerID(r) and api.PartnerRole(r).

Routes

All routes require JWT auth via the standard JWTAuth middleware.

Unscoped

Method Path Auth Purpose
POST /api/v1/partners any authenticated Create a partner; caller becomes owner
GET /api/v1/me/partners any authenticated List partners I belong to

Scoped — /api/v1/partner/{partner_id}/...

The outer group mounts RequirePartnerAnyMember. Inner groups add stricter requirements.

Method Path Required role Purpose
GET /api/v1/partner/{partner_id} any member Get the partner
GET /api/v1/partner/{partner_id}/members any member List members
PATCH /api/v1/partner/{partner_id} admin Update name, status
POST /api/v1/partner/{partner_id}/members admin Add a member
PATCH /api/v1/partner/{partner_id}/members/{user_id} admin Change a member's role
DELETE /api/v1/partner/{partner_id}/members/{user_id} admin Remove a member
DELETE /api/v1/partner/{partner_id} owner Delete the partner

Invariants

The store (backend/internal/services/partner/store.go) enforces these at the storage layer inside transactions so concurrent writes can't violate them:

  • ≥1 owner per partner. RemoveMember and UpdateMemberRole return partner.ErrLastOwner when the change would leave the partner without an owner. The handler translates this to 409 Conflict.
  • Creator becomes the first owner. Service.Create inserts the partner, then a membership with role=owner, and rolls back the partner row (best-effort Delete) if the membership insert fails.
  • One membership per (partner, user). Enforced by the composite unique index on (partner_id, user_id).

Frontend wiring

File Role
frontend/services/partnerService.ts REST client matching the route table above
frontend/contexts/PartnerContext.tsx Fetches /me/partners on auth, exposes partners, membershipFor, roleFor, refresh
frontend/hooks/useAccess.ts inPartner, canAdminPartner, canOwnPartner helpers (alongside the Tomoda-team flags)
frontend/app/(partner)/index.tsx Partner picker — lists user's partners
frontend/app/(partner)/[partner_id]/_layout.tsx Per-partner gate; redirects to picker if the user is not a member

URL is the source of truth for "which partner am I looking at" — there is no "current partner" state in the context. The picker pushes /(partner)/[partner_id]; the scoped layout reads useLocalSearchParams().partner_id and verifies membership.