feat(backend): add organization slugs

This commit is contained in:
2026-01-01 23:21:35 +00:00
parent 3953fa8232
commit ebcdcf2cea
4 changed files with 124 additions and 2 deletions

View File

@@ -17,11 +17,20 @@ CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('personal', 'team')), kind TEXT NOT NULL CHECK (kind IN ('personal', 'team')),
name TEXT NOT NULL, name TEXT NOT NULL,
slug TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT organizations_slug_format CHECK (
slug IS NULL OR (
char_length(slug) BETWEEN 1 AND 63
AND slug <> 'my'
AND slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'
)
)
); );
CREATE INDEX idx_organizations_kind ON organizations(kind); CREATE INDEX idx_organizations_kind ON organizations(kind);
CREATE UNIQUE INDEX idx_organizations_slug ON organizations(lower(slug)) WHERE slug IS NOT NULL;
-- Accounts represent a user's identity within an organization (membership / principal). -- Accounts represent a user's identity within an organization (membership / principal).
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (

View File

@@ -20,6 +20,7 @@ type Organization struct {
ID uuid.UUID `bun:",pk,type:uuid" json:"id"` ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
Kind Kind `bun:"kind,notnull" json:"kind" example:"personal"` Kind Kind `bun:"kind,notnull" json:"kind" example:"personal"`
Name string `bun:"name,notnull" json:"name" example:"Personal"` Name string `bun:"name,notnull" json:"name" example:"Personal"`
Slug *string `bun:"slug" json:"slug,omitempty" example:"test-org"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt"` CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt"` UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt"`
@@ -28,4 +29,3 @@ type Organization struct {
func newOrganizationID() (uuid.UUID, error) { func newOrganizationID() (uuid.UUID, error) {
return uuid.NewV7() return uuid.NewV7()
} }

View File

@@ -0,0 +1,34 @@
package organization
import (
"errors"
"regexp"
"strings"
)
const (
slugMinLength = 1
slugMaxLength = 63
)
var (
slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
reservedSlug = "my"
)
var ErrInvalidSlug = errors.New("invalid organization slug")
// NormalizeSlug lowercases and validates an organization slug.
func NormalizeSlug(input string) (string, error) {
slug := strings.ToLower(strings.TrimSpace(input))
if len(slug) < slugMinLength || len(slug) > slugMaxLength {
return "", ErrInvalidSlug
}
if slug == reservedSlug {
return "", ErrInvalidSlug
}
if !slugPattern.MatchString(slug) {
return "", ErrInvalidSlug
}
return slug, nil
}

View File

@@ -0,0 +1,79 @@
package organization
import (
"strings"
"testing"
)
func TestNormalizeSlug(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "lowercases and trims",
input: " Test-Org ",
want: "test-org",
},
{
name: "allows single char",
input: "a",
want: "a",
},
{
name: "allows max length",
input: strings.Repeat("a", 63),
want: strings.Repeat("a", 63),
},
{
name: "rejects empty",
input: "",
wantErr: true,
},
{
name: "rejects too long",
input: strings.Repeat("a", 64),
wantErr: true,
},
{
name: "rejects reserved",
input: "my",
wantErr: true,
},
{
name: "rejects invalid chars",
input: "bad_slug",
wantErr: true,
},
{
name: "rejects leading hyphen",
input: "-bad",
wantErr: true,
},
{
name: "rejects trailing hyphen",
input: "bad-",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeSlug(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got none (slug=%q)", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("unexpected slug: got %q want %q", got, tt.want)
}
})
}
}