Files
MeshTender/internal/store/orgs.go
T
2026-07-30 10:57:24 -04:00

506 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// Org is an organization.
type Org struct {
ID int64
// Slug is the admin-chosen, non-enumerable identifier used in URLs.
Slug string
Name string
Description string
Region string
// RootAllowFlood is the flood policy for the org's root region (*): whether
// flooding is allowed everywhere by default. See RegionDefCommands.
RootAllowFlood bool
CreatedBy *int64
CreatedAt time.Time
}
// OrgSummary is a public directory entry for an organization.
type OrgSummary struct {
ID int64
Slug string
Name string
Description string
Region string
MemberCount int
RepeaterCount int
CreatedAt time.Time
}
// OrgMembership pairs an org with the querying user's role in it.
type OrgMembership struct {
Org *Org
Role string
}
// OrgMemberInfo describes a member for the org page.
type OrgMemberInfo struct {
UserID int64
Username string
DisplayName *string
Role string
}
// Name returns the member's display name if set, else username.
func (m OrgMemberInfo) Name() string {
if m.DisplayName != nil && *m.DisplayName != "" {
return *m.DisplayName
}
return m.Username
}
// reservedSlugs are slugs that would collide with static /orgs routes or are
// otherwise not allowed as org identifiers.
var reservedSlugs = map[string]bool{"new": true}
var slugCharRE = regexp.MustCompile(`[^a-z0-9]+`)
var validSlugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// slugify converts an arbitrary name into a slug candidate (lowercase, hyphen-
// separated alphanumerics). Returns "" if the name has no usable characters.
func slugify(name string) string {
return strings.Trim(slugCharRE.ReplaceAllString(strings.ToLower(name), "-"), "-")
}
// ValidOrgSlug reports whether s is an acceptable org slug: 340 chars, lowercase
// alphanumerics with single internal hyphens, and not reserved.
func ValidOrgSlug(s string) bool {
if len(s) < 3 || len(s) > 40 || reservedSlugs[s] {
return false
}
return validSlugRE.MatchString(s)
}
// rowQuerier is satisfied by both *pgxpool.Pool and pgx.Tx.
type rowQuerier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// uniqueOrgSlug returns base if free, else base-2, base-3, … finding the first
// unused slug. base is sanitized and falls back to "org" when empty.
func uniqueOrgSlug(ctx context.Context, q rowQuerier, base string) (string, error) {
if base == "" {
base = "org"
}
candidate := base
for n := 2; ; n++ {
var exists bool
if err := q.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM organizations WHERE slug = $1)`, candidate).Scan(&exists); err != nil {
return "", fmt.Errorf("check slug: %w", err)
}
if !exists {
return candidate, nil
}
candidate = fmt.Sprintf("%s-%d", base, n)
}
}
// CreateOrg creates an organization, makes the creator an admin, and seeds a v1
// permission policy from the catalog org defaults — all atomically.
func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*Org, error) {
var o Org
err := s.inTx(ctx, func(tx pgx.Tx) error {
slug, err := uniqueOrgSlug(ctx, tx, slugify(name))
if err != nil {
return err
}
if err := tx.QueryRow(ctx,
`INSERT INTO organizations (slug, name, created_by) VALUES ($1, $2, $3)
RETURNING id, slug, name, description, region, root_allow_flood, created_by, created_at`,
slug, name, creatorID).Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.RootAllowFlood, &o.CreatedBy, &o.CreatedAt); err != nil {
return fmt.Errorf("insert org: %w", err)
}
if _, err := tx.Exec(ctx,
`INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'admin')`,
o.ID, creatorID); err != nil {
return fmt.Errorf("add creator: %w", err)
}
// No permission policy to seed: what an org may run is the site-wide
// catalog ceiling, and owners restrict per org via org_command_optin.
return nil
})
if err != nil {
return nil, err
}
return &o, nil
}
// GetOrg returns an org by id.
func (s *Store) GetOrg(ctx context.Context, id int64) (*Org, error) {
var o Org
err := s.pool.QueryRow(ctx,
`SELECT id, slug, name, description, region, root_allow_flood, created_by, created_at FROM organizations WHERE id = $1`, id).
Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.RootAllowFlood, &o.CreatedBy, &o.CreatedAt)
if err != nil {
return nil, notFoundOr(err, "get org")
}
return &o, nil
}
// OrgIDBySlug resolves a URL slug to the internal int64 primary key, or
// ErrNotFound. Membership/role checks are enforced separately.
func (s *Store) OrgIDBySlug(ctx context.Context, slug string) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `SELECT id FROM organizations WHERE slug = $1`, slug).Scan(&id)
if err != nil {
return 0, notFoundOr(err, "org by slug")
}
return id, nil
}
// UpdateOrg updates an org's slug, name, description, and region. Returns
// ErrDuplicate if the slug is already taken by another org.
func (s *Store) UpdateOrg(ctx context.Context, orgID int64, slug, name, description, region string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE organizations SET slug = $2, name = $3, description = $4, region = $5 WHERE id = $1`,
orgID, slug, name, description, region)
if isUniqueViolation(err) {
return ErrDuplicate
}
if err != nil {
return fmt.Errorf("update org: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// OrgsPageSize is the number of organizations returned per directory page.
const OrgsPageSize = 50
// OrgSort names the orderings the public directory can be browsed in. Each maps
// to a deterministic, tie-broken-by-id sort so keyset paging stays consistent.
type OrgSort string
const (
// OrgSortMembers orders by member count, most first — the default.
OrgSortMembers OrgSort = "members"
// OrgSortName orders alphabetically (AZ).
OrgSortName OrgSort = "name"
// OrgSortRepeaters orders by contributed repeater count, most first.
OrgSortRepeaters OrgSort = "repeaters"
// OrgSortNewest orders by creation time, newest first.
OrgSortNewest OrgSort = "newest"
)
// NormalizeOrgSort coerces an untrusted sort string to a known OrgSort,
// defaulting to OrgSortMembers.
func NormalizeOrgSort(s string) OrgSort {
switch OrgSort(s) {
case OrgSortName, OrgSortRepeaters, OrgSortNewest:
return OrgSort(s)
default:
return OrgSortMembers
}
}
// OrgListParams describes a single requested page of the public directory: the
// ordering, an optional case-insensitive search term, and the keyset position
// to seek past. HasCursor distinguishes "first page" from a cursor whose fields
// all happen to be zero.
type OrgListParams struct {
Sort OrgSort
Query string
HasCursor bool
AfterName string
AfterCount int
AfterTime time.Time
AfterID int64
}
// escapeLikePattern escapes the ILIKE wildcards in user input so the search
// term is matched literally. Backslash is the default ILIKE escape character.
func escapeLikePattern(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// ListPublicOrgsPage returns one keyset page of the public org directory in the
// requested order, optionally filtered by a search term over name/description/
// region, seeking strictly past p's cursor (or starting at the beginning when
// HasCursor is false). It returns the page (capped at OrgsPageSize) and whether
// more rows follow.
//
// Keyset (seek) paging keeps every page cheap regardless of depth — the
// (key, id) comparison rides the sort order. member_count/repeater_count are
// denormalized columns on organizations (trigger-maintained; see migration
// 0033), so every ordering sorts and seeks on an indexed column rather than a
// correlated count subquery computed per org on each page load.
// orgSearchFilter builds the directory's substring-search predicate over
// name/description/region (trigram-indexed, migration 0033), registering its argument
// through add. Returns "" when no search is active.
//
// Shared by ListPublicOrgsPage and CountPublicOrgs so the two can never disagree about
// what "matching" means — a divergence would show a count that contradicts the rows
// right beneath it.
func orgSearchFilter(query string, add func(any) string) string {
q := strings.TrimSpace(query)
if q == "" {
return ""
}
ph := add("%" + escapeLikePattern(q) + "%")
return fmt.Sprintf("(o.name ILIKE %[1]s OR o.description ILIKE %[1]s OR o.region ILIKE %[1]s)", ph)
}
// CountPublicOrgs returns how many organizations match the directory's search filter,
// ignoring any keyset position. That's deliberate: the directory loads further pages by
// appending rows via htmx, so a "shown so far" number rendered outside the swapped
// fragment would go stale on the first "Show more". A filter-wide total stays correct
// however many pages are on screen.
func (s *Store) CountPublicOrgs(ctx context.Context, query string) (int, error) {
var args []any
add := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) }
where := ""
if f := orgSearchFilter(query, add); f != "" {
where = "WHERE " + f
}
var n int
err := s.pool.QueryRow(ctx,
fmt.Sprintf(`SELECT count(*) FROM organizations o %s`, where), args...).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count public orgs: %w", err)
}
return n, nil
}
func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgSummary, bool, error) {
var args []any
add := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) }
var where []string
if f := orgSearchFilter(p.Query, add); f != "" {
where = append(where, f)
}
// Ordering + keyset seek. Each seek tuple mirrors its ORDER BY exactly.
var order string
switch p.Sort {
case OrgSortName:
order = "name ASC, id ASC"
if p.HasCursor {
where = append(where, fmt.Sprintf("(name, id) > (%s, %s)", add(p.AfterName), add(p.AfterID)))
}
case OrgSortRepeaters:
order = "repeater_count DESC, id DESC"
if p.HasCursor {
where = append(where, fmt.Sprintf("(repeater_count, id) < (%s, %s)", add(p.AfterCount), add(p.AfterID)))
}
case OrgSortNewest:
order = "created_at DESC, id DESC"
if p.HasCursor {
where = append(where, fmt.Sprintf("(created_at, id) < (%s, %s)", add(p.AfterTime), add(p.AfterID)))
}
default: // OrgSortMembers
order = "member_count DESC, id DESC"
if p.HasCursor {
where = append(where, fmt.Sprintf("(member_count, id) < (%s, %s)", add(p.AfterCount), add(p.AfterID)))
}
}
whereClause := ""
if len(where) > 0 {
whereClause = "WHERE " + strings.Join(where, " AND ")
}
// Fetch one extra row to detect whether a further page exists.
limit := add(OrgsPageSize + 1)
query := fmt.Sprintf(`
SELECT o.id, o.slug, o.name, o.description, o.region, o.member_count, o.repeater_count, o.created_at
FROM organizations o
%s
ORDER BY %s
LIMIT %s`, whereClause, order, limit)
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, false, fmt.Errorf("list public orgs: %w", err)
}
out, err := collectRows(rows, func(r pgx.Row) (OrgSummary, error) {
var o OrgSummary
err := r.Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.MemberCount, &o.RepeaterCount, &o.CreatedAt)
return o, err
})
if err != nil {
return nil, false, fmt.Errorf("scan org summary: %w", err)
}
hasMore := len(out) > OrgsPageSize
if hasMore {
out = out[:OrgsPageSize]
}
return out, hasMore, nil
}
// OrgCounts returns the member and contributed-repeater counts for an org.
func (s *Store) OrgCounts(ctx context.Context, orgID int64) (members, repeaters int, err error) {
err = s.pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM org_members WHERE org_id = $1),
(SELECT count(*) FROM repeaters r
JOIN org_members om ON om.org_id = $1 AND om.user_id = r.owner_id
WHERE NOT EXISTS (SELECT 1 FROM org_repeater_excludes e
WHERE e.org_id = $1 AND e.repeater_id = r.id))`, orgID).
Scan(&members, &repeaters)
if err != nil {
return 0, 0, fmt.Errorf("org counts: %w", err)
}
return members, repeaters, nil
}
// ListOrgsForUser returns the orgs a user belongs to with their role.
func (s *Store) ListOrgsForUser(ctx context.Context, userID int64) ([]OrgMembership, error) {
rows, err := s.pool.Query(ctx, `
SELECT o.id, o.slug, o.name, o.description, o.created_by, o.created_at, m.role
FROM org_members m JOIN organizations o ON o.id = m.org_id
WHERE m.user_id = $1 ORDER BY lower(o.name), o.id`, userID)
if err != nil {
return nil, fmt.Errorf("list orgs: %w", err)
}
return collectRows(rows, func(r pgx.Row) (OrgMembership, error) {
var o Org
var role string
err := r.Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.CreatedBy, &o.CreatedAt, &role)
return OrgMembership{Org: &o, Role: role}, err
})
}
// OrgRole returns the user's role in an org and whether they're a member.
func (s *Store) OrgRole(ctx context.Context, orgID, userID int64) (string, bool, error) {
var role string
err := s.pool.QueryRow(ctx,
`SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2`, orgID, userID).Scan(&role)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("org role: %w", err)
}
return role, true, nil
}
// IsOrgAdmin reports whether the user is an admin of the org.
func (s *Store) IsOrgAdmin(ctx context.Context, orgID, userID int64) (bool, error) {
role, ok, err := s.OrgRole(ctx, orgID, userID)
return ok && role == "admin", err
}
// AddOrgMember adds a user to an org (idempotent — keeps an existing role).
func (s *Store) AddOrgMember(ctx context.Context, orgID, userID int64, role string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (org_id, user_id) DO NOTHING`, orgID, userID, role)
if err != nil {
return fmt.Errorf("add org member: %w", err)
}
return nil
}
// ListOrgMembers returns an org's members ordered admins-first then by name.
func (s *Store) ListOrgMembers(ctx context.Context, orgID int64) ([]OrgMemberInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT u.id, u.username, u.display_name, m.role
FROM org_members m JOIN users u ON u.id = m.user_id
WHERE m.org_id = $1
ORDER BY (m.role = 'admin') DESC, COALESCE(NULLIF(u.display_name, ''), u.username)`, orgID)
if err != nil {
return nil, fmt.Errorf("list members: %w", err)
}
return collectRows(rows, func(r pgx.Row) (OrgMemberInfo, error) {
var m OrgMemberInfo
err := r.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role)
return m, err
})
}
// ListOrgAdmins returns an org's admins (id, username, display name) ordered for
// display. The public org page links each admin to their public profile, so it
// needs the username alongside the display name. It avoids loading every member
// row via ListOrgMembers.
func (s *Store) ListOrgAdmins(ctx context.Context, orgID int64) ([]OrgMemberInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT u.id, u.username, u.display_name, m.role
FROM org_members m JOIN users u ON u.id = m.user_id
WHERE m.org_id = $1 AND m.role = 'admin'
ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username)`, orgID)
if err != nil {
return nil, fmt.Errorf("list org admins: %w", err)
}
return collectRows(rows, func(r pgx.Row) (OrgMemberInfo, error) {
var m OrgMemberInfo
err := r.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role)
return m, err
})
}
// ErrLastAdmin is returned when an action would leave an org with no admins.
var ErrLastAdmin = errors.New("store: cannot remove the last org admin")
// SetOrgMemberRole changes a member's role, refusing to demote the last admin.
// The guard and the update run in one transaction (see guardLastAdminTx) so two
// concurrent demotions can't both pass the check and leave the org with no admin.
func (s *Store) SetOrgMemberRole(ctx context.Context, orgID, userID int64, role string) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
if role != "admin" {
if err := guardLastAdminTx(ctx, tx, orgID, userID); err != nil {
return err
}
}
if _, err := tx.Exec(ctx,
`UPDATE org_members SET role = $3 WHERE org_id = $1 AND user_id = $2`, orgID, userID, role); err != nil {
return fmt.Errorf("set role: %w", err)
}
return nil
})
}
// RemoveOrgMember removes a member, refusing to remove the last admin. Guard and
// delete share one transaction so the last-admin check can't be raced.
func (s *Store) RemoveOrgMember(ctx context.Context, orgID, userID int64) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
if err := guardLastAdminTx(ctx, tx, orgID, userID); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`, orgID, userID); err != nil {
return fmt.Errorf("remove member: %w", err)
}
return nil
})
}
// guardLastAdminTx returns ErrLastAdmin if demoting/removing userID would leave
// the org with no admins. It must run inside a transaction: it takes a row lock
// (SELECT … FOR UPDATE) on the org's admin rows, so concurrent demotions/removals
// serialize — the second one blocks until the first commits and then re-reads the
// now-smaller admin set, instead of both seeing the pre-change count and racing
// the org down to zero admins.
func guardLastAdminTx(ctx context.Context, tx pgx.Tx, orgID, userID int64) error {
rows, err := tx.Query(ctx,
`SELECT user_id FROM org_members WHERE org_id = $1 AND role = 'admin' FOR UPDATE`, orgID)
if err != nil {
return fmt.Errorf("lock admins: %w", err)
}
admins, err := collectRows(rows, scanID)
if err != nil {
return fmt.Errorf("lock admins: %w", err)
}
// Only a problem when userID is currently the org's sole admin; demoting a
// non-admin, or an admin with peers, is fine.
if slices.Contains(admins, userID) && len(admins) <= 1 {
return ErrLastAdmin
}
return nil
}