| 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 |