Files
kandev/apps/backend/internal/orgunit/models.go
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

79 lines
3.4 KiB
Go

// Package orgunit owns the tree an organization is arranged into.
//
// A unit holds child units, workspaces, and members. A department and a team
// are both units and differ only in depth, which is why there is one type here
// rather than one per level: an organization that grows a layer moves rows
// instead of migrating a schema.
//
// Membership on a unit reaches every workspace beneath it. Resolving that
// reach belongs to internal/authz; this package owns the tree and its
// invariants. See docs/specs/workspaces/requirements/org-units.md.
package orgunit
import (
"errors"
"time"
)
// Kind separates the units that carry structure from the two that the system
// creates and protects.
type Kind string
const (
// KindRoot is the single top unit of an organization. Belonging to the
// organization is membership of this unit, so a workspace parked here is
// reachable by everyone in the organization.
KindRoot Kind = "root"
// KindStandard is a department, a team, or any other level a person makes.
KindStandard Kind = "standard"
// KindPersonal belongs to exactly one user and takes no members. It is
// where work that is nobody else's business lives, and it replaces the
// private visibility flag rather than sitting beside it.
KindPersonal Kind = "personal"
)
// Unit is one node of an organization's tree.
type Unit struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
ParentID string `json:"parent_id" db:"parent_id"`
Kind Kind `json:"kind" db:"kind"`
OwnerUserID string `json:"owner_user_id,omitempty" db:"owner_user_id"`
Name string `json:"name" db:"name"`
// Path is the materialized ancestor chain, "/<id>/<id>/", ending with this
// unit. It is a denormalization whose only writer is this package, kept
// because a prefix match works identically on SQLite and Postgres while a
// recursive query does not.
Path string `json:"path" db:"path"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// IsProtected reports whether the unit is one the system owns. A protected
// unit cannot be moved or deleted while the thing it belongs to exists.
func (u *Unit) IsProtected() bool { return u.Kind == KindRoot || u.Kind == KindPersonal }
// Member is a person's standing on one unit, which they hold on every
// workspace beneath it.
type Member struct {
UnitID string `json:"unit_id" db:"unit_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
AddedBy string `json:"added_by" db:"added_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Tree errors. Each names its blocking condition so a caller can say what is
// in the way rather than that something went wrong.
var (
ErrUnitNotFound = errors.New("unit not found")
ErrNameRequired = errors.New("a unit needs a name")
ErrParentRequired = errors.New("a unit needs a parent")
ErrCrossOrgParent = errors.New("a unit's parent must be in the same organization")
ErrCycle = errors.New("a unit cannot be moved beneath itself")
ErrNotEmpty = errors.New("this unit still holds child units or workspaces")
ErrProtectedUnit = errors.New("the root and personal units cannot be moved or deleted")
ErrPersonalNoMember = errors.New("a personal unit takes no members")
ErrRootExists = errors.New("this organization already has a root unit")
)