krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/store/orgs.go · raw
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7)
8
9type Org struct {
10 ID int64
11 Name string
12}
13
14type OrgMember struct {
15 Username string
16 Role string // member | admin
17}
18
19// ownerNameTaken reports whether name is claimed by any user or org. Users
20// and orgs share one namespace: /<owner>/<repo> must be unambiguous.
21func ownerNameTaken(q interface {
22 QueryRow(string, ...any) *sql.Row
23}, name string) (bool, error) {
24 var n int
25 err := q.QueryRow(
26 "SELECT (SELECT COUNT(*) FROM users WHERE username = ?) + (SELECT COUNT(*) FROM orgs WHERE name = ?)",
27 name, name).Scan(&n)
28 return n > 0, err
29}
30
31// CreateOrg makes an organization with the creator as its first admin.
32func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) {
33 tx, err := s.DB.Begin()
34 if err != nil {
35 return 0, err
36 }
37 defer tx.Rollback()
38 taken, err := ownerNameTaken(tx, name)
39 if err != nil {
40 return 0, err
41 }
42 if taken {
43 return 0, fmt.Errorf("the name %q is taken", name)
44 }
45 res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name)
46 if err != nil {
47 return 0, err
48 }
49 id, err := res.LastInsertId()
50 if err != nil {
51 return 0, err
52 }
53 if _, err := tx.Exec(
54 "INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, 'admin')", id, creatorID); err != nil {
55 return 0, err
56 }
57 return id, tx.Commit()
58}
59
60func (s *Store) OrgByName(name string) (Org, error) {
61 var o Org
62 err := s.DB.QueryRow("SELECT id, name FROM orgs WHERE name = ?", name).Scan(&o.ID, &o.Name)
63 if errors.Is(err, sql.ErrNoRows) {
64 return o, ErrNotFound
65 }
66 return o, err
67}
68
69// OrgRole returns the user's role in the org ("" for non-members).
70func (s *Store) OrgRole(orgID, userID int64) (string, error) {
71 var role string
72 err := s.DB.QueryRow(
73 "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID).Scan(&role)
74 if errors.Is(err, sql.ErrNoRows) {
75 return "", nil
76 }
77 return role, err
78}
79
80func (s *Store) OrgMembers(orgID int64) ([]OrgMember, error) {
81 rows, err := s.DB.Query(`
82 SELECT u.username, m.role FROM org_members m
83 JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY u.username`, orgID)
84 if err != nil {
85 return nil, err
86 }
87 defer rows.Close()
88 var out []OrgMember
89 for rows.Next() {
90 var m OrgMember
91 if err := rows.Scan(&m.Username, &m.Role); err != nil {
92 return nil, err
93 }
94 out = append(out, m)
95 }
96 return out, rows.Err()
97}
98
99// ListOrgsForUser returns the orgs the user belongs to, with their role.
100func (s *Store) ListOrgsForUser(userID int64) ([]OrgMember, error) {
101 rows, err := s.DB.Query(`
102 SELECT o.name, m.role FROM org_members m
103 JOIN orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name`, userID)
104 if err != nil {
105 return nil, err
106 }
107 defer rows.Close()
108 var out []OrgMember
109 for rows.Next() {
110 var m OrgMember
111 if err := rows.Scan(&m.Username, &m.Role); err != nil {
112 return nil, err
113 }
114 out = append(out, m)
115 }
116 return out, rows.Err()
117}
118
119// SetOrgMember adds a member or updates their role. Demoting the last admin
120// is refused: an org must always have one.
121func (s *Store) SetOrgMember(orgID, userID int64, role string) error {
122 tx, err := s.DB.Begin()
123 if err != nil {
124 return err
125 }
126 defer tx.Rollback()
127 if role == "member" {
128 ok, err := wouldKeepAdmin(tx, orgID, userID)
129 if err != nil {
130 return err
131 }
132 if !ok {
133 return errors.New("an organization needs at least one admin")
134 }
135 }
136 if _, err := tx.Exec(`
137 INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?)
138 ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`,
139 orgID, userID, role); err != nil {
140 return err
141 }
142 return tx.Commit()
143}
144
145// RemoveOrgMember drops a member, refusing to remove the last admin.
146func (s *Store) RemoveOrgMember(orgID, userID int64) error {
147 tx, err := s.DB.Begin()
148 if err != nil {
149 return err
150 }
151 defer tx.Rollback()
152 ok, err := wouldKeepAdmin(tx, orgID, userID)
153 if err != nil {
154 return err
155 }
156 if !ok {
157 return errors.New("an organization needs at least one admin")
158 }
159 res, err := tx.Exec("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID)
160 if err != nil {
161 return err
162 }
163 if n, _ := res.RowsAffected(); n == 0 {
164 return ErrNotFound
165 }
166 return tx.Commit()
167}
168
169// wouldKeepAdmin reports whether the org keeps at least one admin after
170// userID stops being one.
171func wouldKeepAdmin(tx *sql.Tx, orgID, userID int64) (bool, error) {
172 var n int
173 err := tx.QueryRow(
174 "SELECT COUNT(*) FROM org_members WHERE org_id = ? AND role = 'admin' AND user_id <> ?",
175 orgID, userID).Scan(&n)
176 return n > 0, err
177}
178
179// DeleteOrg removes an empty organization; orgs still owning repositories
180// are refused.
181func (s *Store) DeleteOrg(orgID int64) error {
182 var n int
183 if err := s.DB.QueryRow(
184 "SELECT COUNT(*) FROM repos WHERE owner_kind = 'org' AND owner_id = ?", orgID).Scan(&n); err != nil {
185 return err
186 }
187 if n > 0 {
188 return fmt.Errorf("the organization still owns %d repositories; delete or transfer them first", n)
189 }
190 _, err := s.DB.Exec("DELETE FROM orgs WHERE id = ?", orgID)
191 return err
192}
193
194// RenameOrg changes an org's name, holding the shared owner-namespace
195// invariant. The caller moves the on-disk repos directory afterward.
196func (s *Store) RenameOrg(orgID int64, newName string) error {
197 tx, err := s.DB.Begin()
198 if err != nil {
199 return err
200 }
201 defer tx.Rollback()
202 taken, err := ownerNameTaken(tx, newName)
203 if err != nil {
204 return err
205 }
206 if taken {
207 return fmt.Errorf("the name %q is taken", newName)
208 }
209 res, err := tx.Exec("UPDATE orgs SET name = ? WHERE id = ?", newName, orgID)
210 if err != nil {
211 return err
212 }
213 if n, _ := res.RowsAffected(); n == 0 {
214 return ErrNotFound
215 }
216 return tx.Commit()
217}