Go · 3346 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package orgs
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "regexp"
10 "strings"
11
12 "github.com/jackc/pgx/v5/pgtype"
13
14 "github.com/tenseleyFlow/shithub/internal/auth"
15 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
16 )
17
18 // CreateParams describes a create-org request.
19 type CreateParams struct {
20 Slug string
21 DisplayName string
22 Description string
23 BillingEmail string
24 CreatedByUserID int64
25 }
26
27 // slugRE mirrors the username pattern (lowercase letters, digits,
28 // hyphens; cannot start or end with a hyphen). Same shape as
29 // users.username so the namespace unification is genuinely uniform.
30 var slugRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`)
31
32 // Create validates the slug, opens a tx, inserts the org, and adds
33 // the creator as the sole owner. Slug uniqueness against the
34 // principals table is enforced by the DB-level PK on principals — a
35 // concurrent insert with the same slug fails with a unique-violation
36 // from the trigger, which we translate to ErrSlugTaken.
37 func Create(ctx context.Context, deps Deps, p CreateParams) (orgsdb.Org, error) {
38 slug := strings.ToLower(strings.TrimSpace(p.Slug))
39 if slug == "" {
40 return orgsdb.Org{}, ErrEmptySlug
41 }
42 if len(slug) > 39 {
43 return orgsdb.Org{}, ErrSlugTooLong
44 }
45 if !slugRE.MatchString(slug) {
46 return orgsdb.Org{}, ErrSlugInvalid
47 }
48 if auth.IsReserved(slug) {
49 return orgsdb.Org{}, ErrSlugReserved
50 }
51 if p.CreatedByUserID == 0 {
52 return orgsdb.Org{}, errors.New("orgs: CreatedByUserID is required")
53 }
54
55 tx, err := deps.Pool.Begin(ctx)
56 if err != nil {
57 return orgsdb.Org{}, err
58 }
59 committed := false
60 defer func() {
61 if !committed {
62 _ = tx.Rollback(ctx)
63 }
64 }()
65
66 q := orgsdb.New()
67 displayName := strings.TrimSpace(p.DisplayName)
68 if displayName == "" {
69 displayName = slug
70 }
71 row, err := q.CreateOrg(ctx, tx, orgsdb.CreateOrgParams{
72 Slug: slug,
73 DisplayName: displayName,
74 Description: strings.TrimSpace(p.Description),
75 BillingEmail: strings.TrimSpace(p.BillingEmail),
76 CreatedByUserID: pgtype.Int8{Int64: p.CreatedByUserID, Valid: true},
77 })
78 if err != nil {
79 if isUniqueViolation(err) {
80 // Either the orgs.slug UNIQUE index OR the principals PK
81 // fired. Either way, the slug is taken (by another org or
82 // by an existing user/redirect).
83 return orgsdb.Org{}, ErrSlugTaken
84 }
85 return orgsdb.Org{}, fmt.Errorf("create org: %w", err)
86 }
87
88 if err := q.AddOrgMember(ctx, tx, orgsdb.AddOrgMemberParams{
89 OrgID: row.ID,
90 UserID: p.CreatedByUserID,
91 Role: orgsdb.OrgRoleOwner,
92 InvitedByUserID: pgtype.Int8{Valid: false},
93 }); err != nil {
94 return orgsdb.Org{}, fmt.Errorf("seed owner: %w", err)
95 }
96 if err := enqueueBillingSeatSync(ctx, tx, deps, row.ID); err != nil {
97 return orgsdb.Org{}, fmt.Errorf("enqueue billing seat sync: %w", err)
98 }
99
100 if err := tx.Commit(ctx); err != nil {
101 return orgsdb.Org{}, fmt.Errorf("commit: %w", err)
102 }
103 committed = true
104 return row, nil
105 }
106
107 // isUniqueViolation reports whether err is a Postgres unique-key
108 // violation (SQLSTATE 23505). The orgs.slug UNIQUE index, the
109 // principals PK, and any concurrent-insert race surface as 23505.
110 func isUniqueViolation(err error) bool {
111 var pgErr *pgconnError
112 return errors.As(err, &pgErr) && pgErr.Code == "23505"
113 }
114