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)
slugis unique, URL-safe (lowercase alphanumerics + hyphens, 2-64 chars), but routes use{partner_id}(UUID) rather than slug: the slug is for display.statusisactiveorsuspended(PartnerStatus), defaulting toactivevia the model'sBeforeCreatehook. 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.
RemoveMemberandUpdateMemberRolereturnpartner.ErrLastOwnerwhen the change would leave the partner without an owner. The handler translates this to409 Conflict. - Creator becomes the first owner.
Service.Createinserts the partner, then a membership withrole=owner, and rolls back the partner row (best-effortDelete) 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.