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}