Files
kandev/apps/backend/internal/org/store.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

187 lines
5.0 KiB
Go

package org
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/db/dialect"
)
// ErrOrgNotFound reports an unknown organization. Callers that reached the
// lookup through a caller-supplied ID surface it as 404.
var ErrOrgNotFound = errors.New("organization not found")
// ErrSlugTaken reports a slug collision.
var ErrSlugTaken = errors.New("that organization slug is already in use")
// Store persists organizations.
type Store struct {
db *sqlx.DB
ro *sqlx.DB
}
// NewStore builds the store and creates its schema.
func NewStore(pool *db.Pool) (*Store, error) {
s := &Store{db: pool.Writer(), ro: pool.Reader()}
if err := s.initSchema(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) initSchema() error {
timestamp := "DATETIME"
if dialect.IsPostgres(s.db.DriverName()) {
timestamp = "TIMESTAMPTZ"
}
statements := []string{
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS orgs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
is_default INTEGER NOT NULL DEFAULT 0,
created_at %s NOT NULL,
updated_at %s NOT NULL
)`, timestamp, timestamp),
`CREATE INDEX IF NOT EXISTS idx_orgs_status ON orgs(status)`,
}
for _, stmt := range statements {
if _, err := s.db.Exec(stmt); err != nil {
return fmt.Errorf("org schema: %w", err)
}
}
return nil
}
const orgColumns = `id, name, slug, status, is_default, created_at, updated_at`
func scanOrg(row interface{ Scan(...any) error }) (*Org, error) {
o := &Org{}
var isDefault int
if err := row.Scan(&o.ID, &o.Name, &o.Slug, &o.Status, &isDefault, &o.CreatedAt, &o.UpdatedAt); err != nil {
return nil, err
}
o.IsDefault = isDefault != 0
return o, nil
}
// Create inserts an organization, deriving a unique slug when needed.
func (s *Store) Create(ctx context.Context, name, slug string, isDefault bool) (*Org, error) {
if slug == "" {
slug = Slugify(name)
}
now := time.Now().UTC()
o := &Org{
ID: uuid.New().String(), Name: name, Slug: slug,
Status: string(StatusActive), IsDefault: isDefault, CreatedAt: now, UpdatedAt: now,
}
flag := 0
if isDefault {
flag = 1
}
_, err := s.db.ExecContext(ctx, s.db.Rebind(
`INSERT INTO orgs (`+orgColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?)`,
), o.ID, o.Name, o.Slug, o.Status, flag, o.CreatedAt, o.UpdatedAt)
if err != nil {
if db.IsAlreadyExistsError(err) || isUniqueViolation(err) {
return nil, ErrSlugTaken
}
return nil, err
}
return o, nil
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "UNIQUE constraint failed") ||
strings.Contains(msg, "duplicate key value")
}
// Get returns one organization by ID.
func (s *Store) Get(ctx context.Context, id string) (*Org, error) {
row := s.ro.QueryRowContext(ctx, s.ro.Rebind(`SELECT `+orgColumns+` FROM orgs WHERE id = ?`), id)
o, err := scanOrg(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrOrgNotFound
}
return o, err
}
// List returns every organization, default first then by name.
func (s *Store) List(ctx context.Context) ([]*Org, error) {
rows, err := s.ro.QueryContext(ctx, `SELECT `+orgColumns+` FROM orgs ORDER BY is_default DESC, name`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
orgs := make([]*Org, 0)
for rows.Next() {
o, err := scanOrg(rows)
if err != nil {
return nil, err
}
orgs = append(orgs, o)
}
return orgs, rows.Err()
}
// Default returns the default organization, or ErrOrgNotFound when the
// instance has not been migrated yet.
func (s *Store) Default(ctx context.Context) (*Org, error) {
row := s.ro.QueryRowContext(ctx, `SELECT `+orgColumns+` FROM orgs WHERE is_default = 1 LIMIT 1`)
o, err := scanOrg(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrOrgNotFound
}
return o, err
}
// Count returns how many organizations exist.
func (s *Store) Count(ctx context.Context) (int, error) {
var n int
err := s.ro.QueryRowContext(ctx, `SELECT COUNT(*) FROM orgs`).Scan(&n)
return n, err
}
// UpdateNameStatus changes an org's display name and/or status.
func (s *Store) UpdateNameStatus(ctx context.Context, id, name, status string) (*Org, error) {
existing, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
if name == "" {
name = existing.Name
}
if status == "" {
status = existing.Status
}
status = string(NormalizeStatus(status))
now := time.Now().UTC()
if _, err := s.db.ExecContext(ctx, s.db.Rebind(
`UPDATE orgs SET name = ?, status = ?, updated_at = ? WHERE id = ?`,
), name, status, now, id); err != nil {
return nil, err
}
existing.Name, existing.Status, existing.UpdatedAt = name, status, now
return existing, nil
}
// Delete removes the organization row. Callers are responsible for removing
// the org's data first; this is the last step.
func (s *Store) Delete(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM orgs WHERE id = ?`), id)
return err
}