@@ -0,0 +1,110 @@ |
| | 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 | + |
| | 97 | + if err := tx.Commit(ctx); err != nil { |
| | 98 | + return orgsdb.Org{}, fmt.Errorf("commit: %w", err) |
| | 99 | + } |
| | 100 | + committed = true |
| | 101 | + return row, nil |
| | 102 | +} |
| | 103 | + |
| | 104 | +// isUniqueViolation reports whether err is a Postgres unique-key |
| | 105 | +// violation (SQLSTATE 23505). The orgs.slug UNIQUE index, the |
| | 106 | +// principals PK, and any concurrent-insert race surface as 23505. |
| | 107 | +func isUniqueViolation(err error) bool { |
| | 108 | + var pgErr *pgconnError |
| | 109 | + return errors.As(err, &pgErr) && pgErr.Code == "23505" |
| | 110 | +} |