From ebcdcf2cea4b8f0e252368b04665be0010d2a267 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 1 Jan 2026 23:21:35 +0000 Subject: [PATCH] feat(backend): add organization slugs --- .../database/migrations/001_initial.up.sql | 11 ++- .../internal/organization/organization.go | 2 +- apps/backend/internal/organization/slug.go | 34 ++++++++ .../internal/organization/slug_test.go | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 apps/backend/internal/organization/slug.go create mode 100644 apps/backend/internal/organization/slug_test.go diff --git a/apps/backend/internal/database/migrations/001_initial.up.sql b/apps/backend/internal/database/migrations/001_initial.up.sql index 7b834a7..aa85b49 100644 --- a/apps/backend/internal/database/migrations/001_initial.up.sql +++ b/apps/backend/internal/database/migrations/001_initial.up.sql @@ -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 ( diff --git a/apps/backend/internal/organization/organization.go b/apps/backend/internal/organization/organization.go index bcfca95..f3a7a42 100644 --- a/apps/backend/internal/organization/organization.go +++ b/apps/backend/internal/organization/organization.go @@ -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() } - diff --git a/apps/backend/internal/organization/slug.go b/apps/backend/internal/organization/slug.go new file mode 100644 index 0000000..9d0a099 --- /dev/null +++ b/apps/backend/internal/organization/slug.go @@ -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 +} diff --git a/apps/backend/internal/organization/slug_test.go b/apps/backend/internal/organization/slug_test.go new file mode 100644 index 0000000..c289dcc --- /dev/null +++ b/apps/backend/internal/organization/slug_test.go @@ -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) + } + }) + } +}