mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 16:11:17 +00:00
feat(backend): add organization slugs
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
apps/backend/internal/organization/slug.go
Normal file
34
apps/backend/internal/organization/slug.go
Normal 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
|
||||||
|
}
|
||||||
79
apps/backend/internal/organization/slug_test.go
Normal file
79
apps/backend/internal/organization/slug_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user