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,
kind TEXT NOT NULL CHECK (kind IN ('personal', 'team')),
name TEXT NOT NULL,
slug TEXT,
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 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).
CREATE TABLE IF NOT EXISTS accounts (

View File

@@ -20,6 +20,7 @@ type Organization struct {
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
Kind Kind `bun:"kind,notnull" json:"kind" 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"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt"`
@@ -28,4 +29,3 @@ type Organization struct {
func newOrganizationID() (uuid.UUID, error) {
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)
}
})
}
}