internal/store/orgs.go

fce24ae0024be6fd87d4a306c606035782577497
gitbay/internal/store/orgs.go history · blame · raw

281 lines · 7828 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8)
  9
 10type Org struct {
 11	ID   int64
 12	Name string
 13}
 14
 15type OrgMember struct {
 16	Username string
 17	Role     string // member | admin
 18}
 19
 20// ownerNameTaken reports whether name is claimed by any user or org. Users
 21// and orgs share one namespace: /<owner>/<repo> must be unambiguous.
 22func ownerNameTaken(q interface {
 23	QueryRow(string, ...any) *sql.Row
 24}, name string) (bool, error) {
 25	var n int
 26	err := q.QueryRow(
 27		"SELECT (SELECT COUNT(*) FROM users WHERE username = ?) + (SELECT COUNT(*) FROM orgs WHERE name = ?)",
 28		name, name).Scan(&n)
 29	return n > 0, err
 30}
 31
 32// CreateOrg makes an organization with the creator as its first admin.
 33func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) {
 34	tx, err := s.DB.Begin()
 35	if err != nil {
 36		return 0, err
 37	}
 38	defer tx.Rollback()
 39	taken, err := ownerNameTaken(tx, name)
 40	if err != nil {
 41		return 0, err
 42	}
 43	if taken {
 44		return 0, fmt.Errorf("the name %q is taken", name)
 45	}
 46	res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name)
 47	if err != nil {
 48		return 0, err
 49	}
 50	id, err := res.LastInsertId()
 51	if err != nil {
 52		return 0, err
 53	}
 54	if _, err := tx.Exec(
 55		"INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, 'admin')", id, creatorID); err != nil {
 56		return 0, err
 57	}
 58	return id, tx.Commit()
 59}
 60
 61func (s *Store) OrgByName(name string) (Org, error) {
 62	var o Org
 63	err := s.DB.QueryRow("SELECT id, name FROM orgs WHERE name = ?", name).Scan(&o.ID, &o.Name)
 64	if errors.Is(err, sql.ErrNoRows) {
 65		return o, ErrNotFound
 66	}
 67	return o, err
 68}
 69
 70// OrgRole returns the user's role in the org ("" for non-members).
 71func (s *Store) OrgRole(orgID, userID int64) (string, error) {
 72	var role string
 73	err := s.DB.QueryRow(
 74		"SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID).Scan(&role)
 75	if errors.Is(err, sql.ErrNoRows) {
 76		return "", nil
 77	}
 78	return role, err
 79}
 80
 81func (s *Store) OrgMembers(orgID int64) ([]OrgMember, error) {
 82	rows, err := s.DB.Query(`
 83		SELECT u.username, m.role FROM org_members m
 84		JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY u.username`, orgID)
 85	if err != nil {
 86		return nil, err
 87	}
 88	defer rows.Close()
 89	var out []OrgMember
 90	for rows.Next() {
 91		var m OrgMember
 92		if err := rows.Scan(&m.Username, &m.Role); err != nil {
 93			return nil, err
 94		}
 95		out = append(out, m)
 96	}
 97	return out, rows.Err()
 98}
 99
100// ListOrgsForUser returns the orgs the user belongs to, with their role.
101func (s *Store) ListOrgsForUser(userID int64) ([]OrgMember, error) {
102	rows, err := s.DB.Query(`
103		SELECT o.name, m.role FROM org_members m
104		JOIN orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name`, userID)
105	if err != nil {
106		return nil, err
107	}
108	defer rows.Close()
109	var out []OrgMember
110	for rows.Next() {
111		var m OrgMember
112		if err := rows.Scan(&m.Username, &m.Role); err != nil {
113			return nil, err
114		}
115		out = append(out, m)
116	}
117	return out, rows.Err()
118}
119
120// SetOrgMember adds a member or updates their role. Demoting the last admin
121// is refused: an org must always have one.
122func (s *Store) SetOrgMember(orgID, userID int64, role string) error {
123	tx, err := s.DB.Begin()
124	if err != nil {
125		return err
126	}
127	defer tx.Rollback()
128	if role == "member" {
129		ok, err := wouldKeepAdmin(tx, orgID, userID)
130		if err != nil {
131			return err
132		}
133		if !ok {
134			return errors.New("an organization needs at least one admin")
135		}
136	}
137	if _, err := tx.Exec(`
138		INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?)
139		ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`,
140		orgID, userID, role); err != nil {
141		return err
142	}
143	return tx.Commit()
144}
145
146// RemoveOrgMember drops a member, refusing to remove the last admin. The
147// account's team memberships in the org go with it: team add requires
148// membership, so a row left behind would grant access to a non-member.
149func (s *Store) RemoveOrgMember(orgID, userID int64) error {
150	tx, err := s.DB.Begin()
151	if err != nil {
152		return err
153	}
154	defer tx.Rollback()
155	ok, err := wouldKeepAdmin(tx, orgID, userID)
156	if err != nil {
157		return err
158	}
159	if !ok {
160		return errors.New("an organization needs at least one admin")
161	}
162	res, err := tx.Exec("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID)
163	if err != nil {
164		return err
165	}
166	if n, _ := res.RowsAffected(); n == 0 {
167		return ErrNotFound
168	}
169	if _, err := tx.Exec(
170		"DELETE FROM team_members WHERE user_id = ? AND team_id IN (SELECT id FROM teams WHERE org_id = ?)",
171		userID, orgID); err != nil {
172		return err
173	}
174	return tx.Commit()
175}
176
177// wouldKeepAdmin reports whether the org keeps at least one admin after
178// userID stops being one.
179func wouldKeepAdmin(tx *sql.Tx, orgID, userID int64) (bool, error) {
180	var n int
181	err := tx.QueryRow(
182		"SELECT COUNT(*) FROM org_members WHERE org_id = ? AND role = 'admin' AND user_id <> ?",
183		orgID, userID).Scan(&n)
184	return n > 0, err
185}
186
187// DeleteOrg removes an empty organization; orgs still owning repositories
188// are refused.
189func (s *Store) DeleteOrg(orgID int64) error {
190	var n int
191	if err := s.DB.QueryRow(
192		"SELECT COUNT(*) FROM repos WHERE owner_kind = 'org' AND owner_id = ?", orgID).Scan(&n); err != nil {
193		return err
194	}
195	if n > 0 {
196		return fmt.Errorf("the organization still owns %d repositories; delete or transfer them first", n)
197	}
198	_, err := s.DB.Exec("DELETE FROM orgs WHERE id = ?", orgID)
199	return err
200}
201
202// RenameOrg changes an org's name, holding the shared owner-namespace
203// invariant. The caller moves the on-disk repos directory afterward.
204func (s *Store) RenameOrg(orgID int64, newName string) error {
205	tx, err := s.DB.Begin()
206	if err != nil {
207		return err
208	}
209	defer tx.Rollback()
210	taken, err := ownerNameTaken(tx, newName)
211	if err != nil {
212		return err
213	}
214	if taken {
215		return fmt.Errorf("the name %q is taken", newName)
216	}
217	res, err := tx.Exec("UPDATE orgs SET name = ? WHERE id = ?", newName, orgID)
218	if err != nil {
219		return err
220	}
221	if n, _ := res.RowsAffected(); n == 0 {
222		return ErrNotFound
223	}
224	return tx.Commit()
225}
226
227// Profile is the presentational half of a user or org.
228type Profile struct {
229	Description string `json:"description,omitempty"`
230	Website     string `json:"website,omitempty"`
231	About       string `json:"about,omitempty"`
232	// AboutFormat is "md" or "org"; About has no filename to dispatch on.
233	AboutFormat string        `json:"about_format,omitempty"`
234	Links       []ProfileLink `json:"links,omitempty"`
235}
236
237// ProfileLink is one free-form link. The label is optional; a link
238// without one renders as its URL.
239type ProfileLink struct {
240	Label string `json:"label,omitempty"`
241	URL   string `json:"url"`
242}
243
244// OwnerProfile reads the profile for kind "user" or "org".
245func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
246	table := map[string]string{"user": "users", "org": "orgs"}[kind]
247	var p Profile
248	var linksJSON string
249	err := s.DB.QueryRow(
250		"SELECT description, website, about, about_format, links FROM "+table+" WHERE id = ?", id).
251		Scan(&p.Description, &p.Website, &p.About, &p.AboutFormat, &linksJSON)
252	if err != nil {
253		return p, err
254	}
255	if linksJSON != "" {
256		if err := json.Unmarshal([]byte(linksJSON), &p.Links); err != nil {
257			return p, fmt.Errorf("%s %d links: %w", kind, id, err)
258		}
259	}
260	return p, nil
261}
262
263// SetOwnerProfile updates the profile for kind "user" or "org".
264func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
265	table := map[string]string{"user": "users", "org": "orgs"}[kind]
266	if p.AboutFormat != "org" {
267		p.AboutFormat = "md"
268	}
269	links := ""
270	if len(p.Links) > 0 {
271		raw, err := json.Marshal(p.Links)
272		if err != nil {
273			return err
274		}
275		links = string(raw)
276	}
277	_, err := s.DB.Exec(
278		"UPDATE "+table+" SET description = ?, website = ?, about = ?, about_format = ?, links = ? WHERE id = ?",
279		p.Description, p.Website, p.About, p.AboutFormat, links, id)
280	return err
281}