Help us shape the future of Go! Take the 2026 Go Developer Survey

orgs

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 10, 2026 License: AGPL-3.0 Imports: 19 Imported by: 0

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

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
var ErrUnauthorizedAcceptor = errors.New("orgs: invitation does not match this user")

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

func CancelInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation) error

CancelInvitation marks an invitation canceled. Caller is the org owner / inviter; policy is checked before this is invoked.

func ChangeRole

func ChangeRole(ctx context.Context, deps Deps, orgID, userID int64, role string) error

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

func Create(ctx context.Context, deps Deps, p CreateParams) (orgsdb.Org, error)

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

func CreateTeam(ctx context.Context, deps Deps, p CreateTeamParams) (orgsdb.Team, error)

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

func IsMember(ctx context.Context, deps Deps, orgID, userID int64) (bool, error)

IsMember reports whether the user is a member of the org. Used by policy + the org-owner repo-create gate.

func IsOwner

func IsOwner(ctx context.Context, deps Deps, orgID, userID int64) (bool, error)

IsOwner reports whether the user is an owner of the org.

func ListPastGraceOrgIDs

func ListPastGraceOrgIDs(ctx context.Context, deps Deps) ([]int64, error)

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

func RemoveMember(ctx context.Context, deps Deps, orgID, userID int64) error

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

func RemoveTeamMember(ctx context.Context, deps Deps, teamID, userID int64) error

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

func Restore(ctx context.Context, deps Deps, orgID, actorUserID int64) error

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

func RevokeTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID int64) error

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

func SetTeamParent(ctx context.Context, deps Deps, teamID, parentTeamID int64) error

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

func SoftDelete(ctx context.Context, deps Deps, orgID, actorUserID int64) error

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

type HardDeleteDeps struct {
	Deps
	RepoFS *storage.RepoFS
	Audit  *audit.Recorder
}

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".

func Resolve

func Resolve(ctx context.Context, pool *pgxpool.Pool, slug string) (Principal, error)

Resolve answers the central /{slug} routing question with one indexed lookup. Returns ErrNoPrincipal for unknown slugs (handler 404s); other errors are surfaced as-is.

type PrincipalKind

type PrincipalKind string

PrincipalKind discriminates the resolution result.

const (
	PrincipalUser PrincipalKind = "user"
	PrincipalOrg  PrincipalKind = "org"
)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL