Documentation
¶
Overview ¶
Package orgs owns the organization domain: create, members, invitations, suspend/restore, soft-delete, and the principals resolver that unifies /{slug} routing across users + orgs.
The package is the single entry point for the rest of the runtime — web handlers, the policy engine, and the worker pool import here rather than poking the sqlc surface directly.
Index ¶
- Constants
- Variables
- func AcceptInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation, acceptorUserID int64) error
- func AddMember(ctx context.Context, deps Deps, orgID, userID, invitedByUserID int64, ...) error
- func AddTeamMember(ctx context.Context, deps Deps, teamID, userID, addedByUserID int64, ...) error
- func CancelInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation) error
- func ChangeRole(ctx context.Context, deps Deps, orgID, userID int64, role string) error
- func Create(ctx context.Context, deps Deps, p CreateParams) (orgsdb.Org, error)
- func CreateTeam(ctx context.Context, deps Deps, p CreateTeamParams) (orgsdb.Team, error)
- func DeclineInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation, declinerUserID int64) error
- func GrantTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID, addedByUserID int64, ...) error
- func HardDelete(ctx context.Context, hd HardDeleteDeps, orgID int64) error
- func IsMember(ctx context.Context, deps Deps, orgID, userID int64) (bool, error)
- func IsOwner(ctx context.Context, deps Deps, orgID, userID int64) (bool, error)
- func ListPastGraceOrgIDs(ctx context.Context, deps Deps) ([]int64, error)
- func LookupInvitationByToken(ctx context.Context, deps Deps, encodedToken string) (orgsdb.OrgInvitation, error)
- func RemoveMember(ctx context.Context, deps Deps, orgID, userID int64) error
- func RemoveTeamMember(ctx context.Context, deps Deps, teamID, userID int64) error
- func Restore(ctx context.Context, deps Deps, orgID, actorUserID int64) error
- func RevokeTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID int64) error
- func SetTeamParent(ctx context.Context, deps Deps, teamID, parentTeamID int64) error
- func SoftDelete(ctx context.Context, deps Deps, orgID, actorUserID int64) error
- type CreateParams
- type CreateTeamParams
- type Deps
- type HardDeleteDeps
- type InviteParams
- type InviteResult
- type Principal
- type PrincipalKind
Constants ¶
const InvitationLifetime = 7 * 24 * 60 * 60 // seconds; converted to time.Duration at call sites
InvitationLifetime is how long a fresh invitation is honored before the recipient must request a new one. 7 days matches the spec.
Variables ¶
var ( ErrEmptySlug = errors.New("orgs: slug is required") ErrSlugTooLong = errors.New("orgs: slug too long (max 39)") ErrSlugInvalid = errors.New("orgs: slug must be lowercase letters, digits, and hyphens") ErrSlugReserved = errors.New("orgs: slug is reserved") ErrSlugTaken = errors.New("orgs: slug is already in use") ErrOrgNotFound = errors.New("orgs: org not found") ErrUserNotFound = errors.New("orgs: user not found") ErrNotAMember = errors.New("orgs: user is not a member of this org") ErrAlreadyMember = errors.New("orgs: user is already a member of this org") ErrLastOwner = errors.New("orgs: cannot remove or demote the only owner; transfer ownership first") ErrInvitationNotFound = errors.New("orgs: invitation not found") ErrInvitationExpired = errors.New("orgs: invitation has expired") ErrInvitationConsumed = errors.New("orgs: invitation already accepted, declined, or canceled") ErrInvitationDuplicate = errors.New("orgs: a pending invitation for this target already exists") ErrInvalidInvitationKind = errors.New("orgs: invitation must target either a user or an email") ErrSuspended = errors.New("orgs: org is suspended") ErrDeleted = errors.New("orgs: org is soft-deleted") )
Errors surfaced by the orchestrator. Web handlers map these to status codes + friendly messages.
var ( ErrTeamNotFound = errors.New("orgs: team not found") ErrTeamSlugInvalid = errors.New("orgs: team slug must be lowercase letters, digits, and hyphens (1-50)") ErrTeamSlugTaken = errors.New("orgs: team slug already in use in this org") ErrTeamNestingTooDeep = errors.New("orgs: team nesting limited to one level (parent already has a parent)") ErrTeamSelfParent = errors.New("orgs: a team cannot be its own parent") ErrTeamCrossOrgParent = errors.New("orgs: parent team must belong to the same org") ErrTeamMissingActor = errors.New("orgs: actor required for team mutation") ErrTeamRoleInvalid = errors.New("orgs: invalid team role") ErrTeamRepoRoleInvalid = errors.New("orgs: invalid team repo-access role") )
Errors surfaced by the team orchestrator.
var ErrNoPrincipal = errors.New("orgs: no principal for slug")
ErrNoPrincipal is returned when /{slug} doesn't match a known principal. Web handlers translate this to 404.
ErrUnauthorizedAcceptor is returned when an acceptor doesn't own the email the invite was issued to (or doesn't match the invited user).
Functions ¶
func AcceptInvitation ¶
func AcceptInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation, acceptorUserID int64) error
AcceptInvitation marks an invitation accepted and adds the target as a member. The acceptor is the currently-logged-in user; for email-based invites the acceptor's verified email must match the invite's target_email.
func AddMember ¶
func AddMember(ctx context.Context, deps Deps, orgID, userID, invitedByUserID int64, role string) error
AddMember inserts an (org, user) pair with the supplied role. Idempotent on the pair: a re-add for an existing member is a no-op at the DB layer (matches the sqlc query's ON CONFLICT DO NOTHING). Caller is responsible for the policy check that the actor is allowed to manage members.
func AddTeamMember ¶
func AddTeamMember(ctx context.Context, deps Deps, teamID, userID, addedByUserID int64, role string) error
AddTeamMember inserts (team, user) at the given role. Idempotent on the pair (the sqlc query uses ON CONFLICT DO NOTHING). Caller resolves the policy gate; this orchestrator just shapes the row.
func CancelInvitation ¶
CancelInvitation marks an invitation canceled. Caller is the org owner / inviter; policy is checked before this is invoked.
func ChangeRole ¶
ChangeRole updates a member's role with last-owner protection: the only owner cannot be demoted (refuse with ErrLastOwner). Caller has already verified the actor's policy.
func Create ¶
Create validates the slug, opens a tx, inserts the org, and adds the creator as the sole owner. Slug uniqueness against the principals table is enforced by the DB-level PK on principals — a concurrent insert with the same slug fails with a unique-violation from the trigger, which we translate to ErrSlugTaken.
func CreateTeam ¶
CreateTeam inserts a team row. Validates slug format, checks parent-team org match, and translates the trigger's nesting-violation SQLSTATE into a friendly error.
func DeclineInvitation ¶
func DeclineInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation, declinerUserID int64) error
DeclineInvitation marks an invitation declined. Same target-binding rules as AcceptInvitation apply.
func GrantTeamRepoAccess ¶
func GrantTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID, addedByUserID int64, role string) error
GrantRepoAccess upserts the team's role on a repo. ON CONFLICT DO UPDATE so re-granting at a new role is one call.
func HardDelete ¶
func HardDelete(ctx context.Context, hd HardDeleteDeps, orgID int64) error
HardDelete is the cascade run by the past-grace sweep. For each org-owned repo (including already-soft-deleted ones) it runs the existing lifecycle.HardDelete tear-down (bare-repo on disk + DB rows). Then drops org_invitations + org_members + the orgs row. The principals trigger removes the slug entry as part of the orgs DELETE.
Best-effort: a per-repo failure is logged but doesn't stop the sweep — leaving zombie repos in the DB is preferable to leaving the org row in a half-deleted state where its slug is permanently pinned. Operators can retry via the manual sweep job.
func IsMember ¶
IsMember reports whether the user is a member of the org. Used by policy + the org-owner repo-create gate.
func ListPastGraceOrgIDs ¶
ListPastGraceOrgIDs is a thin wrapper exposing the sqlc query so the worker bundle doesn't need to import orgsdb directly.
func LookupInvitationByToken ¶
func LookupInvitationByToken(ctx context.Context, deps Deps, encodedToken string) (orgsdb.OrgInvitation, error)
LookupInvitationByToken fetches the invitation matching the supplied bearer token. Returns ErrInvitationNotFound when the token doesn't match a row.
func RemoveMember ¶
RemoveMember deletes the (org, user) row. Last-owner protection applies — we refuse to drop the only owner. Removing oneself is fine when there are ≥2 owners.
func RemoveTeamMember ¶
RemoveTeamMember drops the (team, user) pair. No last-maintainer protection here — teams without maintainers are fine; org owners can always still mutate the team via the org-owner policy bypass.
func Restore ¶
Restore clears `deleted_at` for an org still inside its 14-day grace window. Caller (web handler) checks that the actor is an org owner BEFORE calling. Returns ErrOrgNotFound when the org's past-grace + already-purged or never existed.
func RevokeTeamRepoAccess ¶
RevokeTeamRepoAccess drops the team's grant. Effective immediately (next request denies; per-request policy cache means in-flight requests can still pass — same staleness as collaborator changes).
func SetTeamParent ¶
SetParent changes a team's parent. Both the no-self-parent CHECK and the one-level-nesting trigger fire here; we translate to the orchestrator-level errors.
func SoftDelete ¶
SoftDelete sets `orgs.deleted_at = now()`, kicking off the 14-day grace window. The principals trigger drops the slug row so the name becomes available to a new claimant immediately — restoring before grace re-creates the row. Pre-flight check: the org must not already be soft-deleted.
Types ¶
type CreateParams ¶
type CreateParams struct {
Slug string
DisplayName string
Description string
BillingEmail string
CreatedByUserID int64
}
CreateParams describes a create-org request.
type CreateTeamParams ¶
type CreateTeamParams struct {
OrgID int64
Slug string
DisplayName string
Description string
ParentTeamID int64 // 0 → top-level
Privacy string // "visible" | "secret"
CreatedByUserID int64
}
CreateTeamParams describes a new-team request.
type Deps ¶
type Deps struct {
Pool *pgxpool.Pool
Logger *slog.Logger
Audit *audit.Recorder
// Email side: only used by the invitation flow today; the
// org-suspend / org-deletion notification kinds are deferred
// to S29's lifecycle email work.
EmailSender email.Sender
EmailFrom string
SiteName string
BaseURL string
}
Deps wires this package against the runtime. Pool is required; EmailSender is optional — when nil, invitation + role-change notifications are skipped (matches the "headless test" pattern the rest of the auth surface uses).
type HardDeleteDeps ¶
HardDeleteDeps is the augmented Deps shape the org hard-delete orchestrator needs. RepoFS lets us tear down the bare repos on disk; the lifecycle.HardDelete cascade reuses that same path so org-owned repos go through the same teardown user-owned ones do.
type InviteParams ¶
type InviteParams struct {
OrgID int64
InvitedByUserID int64
TargetUsername string
TargetEmail string
Role string // "owner" | "member"
}
InviteParams describes a new invitation. Exactly one of TargetUsername / TargetEmail must be non-empty; the resolver below converts a username to a user_id via the users table.
type InviteResult ¶
type InviteResult struct {
Invitation orgsdb.OrgInvitation
Token string
}
InviteResult bundles the freshly-created invitation row + the plaintext token. Callers typically render the token into a URL and drop it into an email; the token IS the credential — never persist it after the email send.
func Invite ¶
func Invite(ctx context.Context, deps Deps, p InviteParams) (InviteResult, error)
Invite creates a pending invitation. Resolves a username to a user_id when provided; otherwise treats the input as an email address (recipients without an account claim it on signup).
type Principal ¶
type Principal struct {
Slug string
Kind PrincipalKind
ID int64
}
Principal is the resolution result. ID is the user_id when Kind is "user", org_id when "org".
type PrincipalKind ¶
type PrincipalKind string
PrincipalKind discriminates the resolution result.
const ( PrincipalUser PrincipalKind = "user" PrincipalOrg PrincipalKind = "org" )