From 01fd2e201e4aa35f1e0e9ae67d6acd9318d77522 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:30:42 +0100 Subject: [PATCH] feat(services): org create+slug and user service with bcrypt --- server/internal/services/orgs.go | 92 ++++++++++++++++++++++ server/internal/services/stepscan.go | 9 --- server/internal/services/users.go | 111 +++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 server/internal/services/orgs.go create mode 100644 server/internal/services/users.go diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go new file mode 100644 index 0000000..dadbb40 --- /dev/null +++ b/server/internal/services/orgs.go @@ -0,0 +1,92 @@ +package services + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +var reservedSlugs = map[string]bool{ + "www": true, "api": true, "app": true, "admin": true, "auth": true, + "install": true, "static": true, "_next": true, "default": true, +} + +var slugStrip = regexp.MustCompile(`[^a-z0-9-]+`) +var slugDashes = regexp.MustCompile(`-+`) + +func Slugify(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, " ", "-") + s = slugStrip.ReplaceAllString(s, "") + s = slugDashes.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +func GetOrg(orgID string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func GetOrgBySlug(slug string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"slug": slug}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func CreateOrg(name string) (*models.Org, error) { + base := Slugify(name) + if len(base) < 3 { + return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)") + } + if len(base) > 40 { + base = base[:40] + } + if reservedSlugs[base] { + return nil, fmt.Errorf("organization name is reserved") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Resolve slug collision by suffixing -2, -3, ... + slug := base + for i := 2; ; i++ { + n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n == 0 { + break + } + slug = fmt.Sprintf("%s-%d", base, i) + } + + o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()} + if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("organization slug already taken") + } + return nil, err + } + return o, nil +} diff --git a/server/internal/services/stepscan.go b/server/internal/services/stepscan.go index df0d281..db3868b 100644 --- a/server/internal/services/stepscan.go +++ b/server/internal/services/stepscan.go @@ -33,12 +33,3 @@ func DeriveOutputs(script string) []string { } return out } - -var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) - -// Slugify converts a step name into a stable kebab-case slug. -func Slugify(name string) string { - s := strings.ToLower(name) - s = slugStrip.ReplaceAllString(s, "-") - return strings.Trim(s, "-") -} diff --git a/server/internal/services/users.go b/server/internal/services/users.go new file mode 100644 index 0000000..2a18491 --- /dev/null +++ b/server/internal/services/users.go @@ -0,0 +1,111 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +func CountUsers() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("users").CountDocuments(ctx, bson.M{}) +} + +func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + u := &models.User{ + UserID: uuid.NewString(), + OrgID: orgID, + Email: email, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now(), + } + if password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return nil, err + } + u.PasswordHash = string(hash) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := db.Col("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("email already registered") + } + return nil, err + } + return u, nil +} + +func GetUserByEmail(email string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var u models.User + err := db.Col("users").FindOne(ctx, bson.M{"email": email}).Decode(&u) + if err != nil { + return nil, err + } + return &u, nil +} + +func VerifyPassword(u *models.User, password string) bool { + if u == nil || u.PasswordHash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil +} + +func TouchLastLogin(userID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now() + _, err := db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID}, + bson.M{"$set": bson.M{"last_login": now}}) + return err +} + +func ListUsers(orgID string) ([]models.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cursor, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var users []models.User + if err := cursor.All(ctx, &users); err != nil { + return nil, err + } + return users, nil +} + +func UpdateUserRole(orgID, userID, role string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("users").UpdateOne(ctx, + bson.M{"user_id": userID, "org_id": orgID}, + bson.M{"$set": bson.M{"role": role}}) + return err +} + +func DeleteUser(orgID, userID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "org_id": orgID}) + return err +}