481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
5.0 KiB
Go
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
|
|
}
|