Go · 25476 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package orgs
4
5 import (
6 "context"
7 "errors"
8 "net/http"
9 "net/url"
10 "strconv"
11 "strings"
12
13 "github.com/go-chi/chi/v5"
14 "github.com/jackc/pgx/v5/pgtype"
15
16 "github.com/tenseleyFlow/shithub/internal/entitlements"
17 "github.com/tenseleyFlow/shithub/internal/orgs"
18 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
19 "github.com/tenseleyFlow/shithub/internal/web/middleware"
20 )
21
22 // MountTeams registers the per-org team surface. Read paths are
23 // public-but-filtered (secret teams hidden from non-members);
24 // mutations are owner-gated inside each handler.
25 func (h *Handlers) MountTeams(r chi.Router) {
26 r.Get("/{org}/teams", h.teamsList)
27 r.Post("/{org}/teams", h.teamCreate)
28 r.Get("/{org}/teams/{teamSlug}", h.teamView)
29 r.Post("/{org}/teams/{teamSlug}/members", h.teamMemberAddRemove)
30 r.Post("/{org}/teams/{teamSlug}/repos", h.teamRepoGrant)
31 }
32
33 type orgNavCounts struct {
34 RepoCount int64
35 MemberCount int64
36 TeamCount int64
37 }
38
39 type teamAggregateCounts struct {
40 MemberCount int64
41 RepoCount int64
42 ChildCount int64
43 }
44
45 type teamListItem struct {
46 ID int64
47 Slug string
48 DisplayName string
49 Description string
50 Privacy string
51 ParentSlug string
52 Path string
53 MemberCount int64
54 RepoCount int64
55 ChildCount int64
56 IsSecret bool
57 HasParent bool
58 CreatedLabel string
59 }
60
61 type teamRepoCandidate struct {
62 ID int64
63 Name string
64 Visibility string
65 }
66
67 type teamMemberCandidate struct {
68 ID int64
69 Username string
70 DisplayName string
71 }
72
73 // teamsList renders /{org}/teams. GitHub keeps org teams member-only:
74 // visible teams are visible to org members, while secret teams are
75 // further limited to team members and org owners.
76 func (h *Handlers) teamsList(w http.ResponseWriter, r *http.Request) {
77 org, ok := h.orgFromSlug(w, r)
78 if !ok {
79 return
80 }
81 viewer := middleware.CurrentUserFromContext(r.Context())
82 if !h.canSeeOrgTeams(w, r, org.ID, viewer) {
83 return
84 }
85 all, err := orgsdb.New().ListTeamsForOrg(r.Context(), h.d.Pool, org.ID)
86 if err != nil {
87 h.d.Logger.ErrorContext(r.Context(), "teams: list", "error", err)
88 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
89 return
90 }
91 visible := h.filterSecretTeams(r, all, org.ID, viewer)
92 counts := h.teamAggregateCounts(r.Context(), org.ID)
93 parentSlugs := teamParentSlugs(all)
94 items := h.teamListItems(org, visible, counts, parentSlugs)
95 visibleCount, secretCount := teamPrivacyCounts(items)
96 query := strings.TrimSpace(r.URL.Query().Get("q"))
97 privacy := strings.TrimSpace(r.URL.Query().Get("privacy"))
98 items = filterTeamListItems(items, query, privacy)
99 isOwner := false
100 canCreateSecretTeams := false
101 if !viewer.IsAnonymous() {
102 isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
103 if isOwner {
104 decision, derr := entitlements.CheckOrgFeature(r.Context(), entitlements.Deps{Pool: h.d.Pool}, org.ID, entitlements.FeatureOrgSecretTeams)
105 if derr != nil {
106 h.d.Logger.WarnContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "error", derr)
107 } else {
108 canCreateSecretTeams = decision.Allowed
109 }
110 }
111 }
112 navCounts := h.orgNavCounts(r.Context(), org.ID, int64(len(visible)))
113 if err := h.d.Render.RenderPage(w, r, "orgs/teams_list", map[string]any{
114 "Title": org.Slug + " · teams",
115 "CSRFToken": middleware.CSRFTokenForRequest(r),
116 "Org": org,
117 "AvatarURL": "/avatars/" + url.PathEscape(string(org.Slug)),
118 "ActiveOrgNav": "teams",
119 "Teams": items,
120 "TeamTotalCount": len(visible),
121 "VisibleCount": visibleCount,
122 "SecretCount": secretCount,
123 "Query": query,
124 "PrivacyFilter": privacy,
125 "RepoCount": navCounts.RepoCount,
126 "MemberCount": navCounts.MemberCount,
127 "TeamCount": navCounts.TeamCount,
128 "IsOwner": isOwner,
129 "Notice": teamsNoticeMessage(r.URL.Query().Get("notice")),
130 "CanCreateSecretTeams": canCreateSecretTeams,
131 }); err != nil {
132 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/teams_list", "error", err)
133 }
134 }
135
136 // teamCreate handles POST /{org}/teams. Owner-only.
137 func (h *Handlers) teamCreate(w http.ResponseWriter, r *http.Request) {
138 org, ok := h.orgFromSlug(w, r)
139 if !ok {
140 return
141 }
142 viewer := middleware.CurrentUserFromContext(r.Context())
143 if !h.requireOrgOwner(w, r, org.ID, viewer) {
144 return
145 }
146 if err := r.ParseForm(); err != nil {
147 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
148 return
149 }
150 privacy := strings.TrimSpace(r.PostFormValue("privacy"))
151 if privacy == "secret" {
152 decision, err := entitlements.CheckOrgFeature(r.Context(), entitlements.Deps{Pool: h.d.Pool}, org.ID, entitlements.FeatureOrgSecretTeams)
153 if err != nil {
154 h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "error", err)
155 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
156 return
157 }
158 if !decision.Allowed {
159 notice := "secret-teams-upgrade"
160 switch decision.Reason {
161 case entitlements.ReasonBillingActionNeeded:
162 notice = "secret-teams-billing"
163 case entitlements.ReasonEnterpriseContactSales:
164 notice = "secret-teams-enterprise"
165 }
166 http.Redirect(w, r, "/"+string(org.Slug)+"/teams?notice="+notice, http.StatusSeeOther)
167 return
168 }
169 }
170 parentID, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("parent_team_id")), 10, 64)
171 _, err := orgs.CreateTeam(r.Context(), h.deps(), orgs.CreateTeamParams{
172 OrgID: org.ID,
173 Slug: strings.TrimSpace(r.PostFormValue("slug")),
174 DisplayName: strings.TrimSpace(r.PostFormValue("display_name")),
175 Description: strings.TrimSpace(r.PostFormValue("description")),
176 ParentTeamID: parentID,
177 Privacy: privacy,
178 CreatedByUserID: viewer.ID,
179 })
180 if err != nil {
181 h.d.Logger.WarnContext(r.Context(), "teams: create",
182 "org", org.Slug, "error", err)
183 }
184 http.Redirect(w, r, "/"+string(org.Slug)+"/teams", http.StatusSeeOther)
185 }
186
187 func teamsNoticeMessage(code string) string {
188 switch code {
189 case "secret-teams-upgrade":
190 return "Secret teams require Team billing. Upgrade this organization to create them."
191 case "secret-teams-billing":
192 return "Secret teams are read-only until Team billing is brought back into good standing."
193 case "secret-teams-enterprise":
194 return "Secret teams are unavailable for Enterprise preview organizations. Contact sales to enable them."
195 case "private-collab-upgrade":
196 return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators."
197 default:
198 return ""
199 }
200 }
201
202 func (h *Handlers) secretTeamWriteNotice(ctx context.Context, orgID int64, team orgsdb.Team) (string, error) {
203 if team.Privacy != orgsdb.TeamPrivacySecret {
204 return "", nil
205 }
206 decision, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: h.d.Pool}, orgID, entitlements.FeatureOrgSecretTeams)
207 if err != nil {
208 return "", err
209 }
210 if decision.Allowed {
211 return "", nil
212 }
213 switch decision.Reason {
214 case entitlements.ReasonBillingActionNeeded:
215 return "secret-teams-billing", nil
216 case entitlements.ReasonEnterpriseContactSales:
217 return "secret-teams-enterprise", nil
218 default:
219 return "secret-teams-upgrade", nil
220 }
221 }
222
223 // teamView renders /{org}/teams/{teamSlug}. Members + repo access.
224 // Secret teams 404 for non-members + non-owners.
225 func (h *Handlers) teamView(w http.ResponseWriter, r *http.Request) {
226 org, ok := h.orgFromSlug(w, r)
227 if !ok {
228 return
229 }
230 team, ok := h.teamFromSlug(w, r, org.ID)
231 if !ok {
232 return
233 }
234 viewer := middleware.CurrentUserFromContext(r.Context())
235 if !h.canSeeOrgTeams(w, r, org.ID, viewer) {
236 return
237 }
238 if !h.canSeeTeam(r, team, viewer) {
239 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
240 return
241 }
242 q := orgsdb.New()
243 members, _ := q.ListTeamMembers(r.Context(), h.d.Pool, team.ID)
244 repos, _ := q.ListTeamRepoAccess(r.Context(), h.d.Pool, team.ID)
245 children, _ := q.ListChildTeams(r.Context(), h.d.Pool, pgtype.Int8{Int64: team.ID, Valid: true})
246 childItems := h.teamListItems(org, h.filterSecretTeams(r, children, org.ID, viewer),
247 h.teamAggregateCounts(r.Context(), org.ID), teamParentSlugs(children))
248 memberCandidates := h.teamMemberCandidates(r.Context(), org.ID, team.ID)
249 repoCandidates := h.teamRepoCandidates(r.Context(), org.ID, team.ID)
250 isOwner := false
251 canExpandTeam := false
252 secretTeamWritesDisabledMessage := ""
253 if !viewer.IsAnonymous() {
254 isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
255 canExpandTeam = isOwner
256 if isOwner {
257 noticeCode, nerr := h.secretTeamWriteNotice(r.Context(), org.ID, team)
258 if nerr != nil {
259 h.d.Logger.WarnContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", nerr)
260 canExpandTeam = false
261 secretTeamWritesDisabledMessage = "Secret team changes are temporarily unavailable."
262 } else if noticeCode != "" {
263 canExpandTeam = false
264 secretTeamWritesDisabledMessage = teamsNoticeMessage(noticeCode)
265 }
266 }
267 }
268 navCounts := h.orgNavCounts(r.Context(), org.ID, -1)
269 if err := h.d.Render.RenderPage(w, r, "orgs/team_view", map[string]any{
270 "Title": string(org.Slug) + "/" + string(team.Slug),
271 "CSRFToken": middleware.CSRFTokenForRequest(r),
272 "Org": org,
273 "AvatarURL": "/avatars/" + url.PathEscape(string(org.Slug)),
274 "ActiveOrgNav": "teams",
275 "Team": team,
276 "TeamDisplayName": teamDisplayName(team),
277 "TeamPath": h.teamPath(org, team),
278 "TeamPrivacy": string(team.Privacy),
279 "TeamIsSecret": team.Privacy == orgsdb.TeamPrivacySecret,
280 "ChildTeams": childItems,
281 "Members": members,
282 "MemberCandidates": memberCandidates,
283 "Repos": repos,
284 "RepoCandidates": repoCandidates,
285 "RepoCount": navCounts.RepoCount,
286 "MemberCount": navCounts.MemberCount,
287 "TeamCount": navCounts.TeamCount,
288 "IsOwner": isOwner,
289 "CanExpandTeam": canExpandTeam,
290 "SecretTeamWritesDisabledMessage": secretTeamWritesDisabledMessage,
291 "Notice": teamsNoticeMessage(r.URL.Query().Get("notice")),
292 }); err != nil {
293 h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/team_view", "error", err)
294 }
295 }
296
297 // teamMemberAddRemove handles POST .../members. Form action=add|remove.
298 // Both branches are owner-only; the orchestrator keeps idempotency.
299 func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) {
300 org, ok := h.orgFromSlug(w, r)
301 if !ok {
302 return
303 }
304 team, ok := h.teamFromSlug(w, r, org.ID)
305 if !ok {
306 return
307 }
308 viewer := middleware.CurrentUserFromContext(r.Context())
309 if !h.requireOrgOwner(w, r, org.ID, viewer) {
310 return
311 }
312 if err := r.ParseForm(); err != nil {
313 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
314 return
315 }
316 action := r.PostFormValue("action")
317 uid, ok := h.userIDFromTeamMemberForm(r)
318 if !ok {
319 http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
320 return
321 }
322 if action != "remove" && !h.userIsOrgMember(r.Context(), org.ID, uid) {
323 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
324 return
325 }
326 switch action {
327 case "remove":
328 _ = orgs.RemoveTeamMember(r.Context(), h.deps(), team.ID, uid)
329 default:
330 noticeCode, err := h.secretTeamWriteNotice(r.Context(), org.ID, team)
331 if err != nil {
332 h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", err)
333 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
334 return
335 }
336 if noticeCode != "" {
337 http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther)
338 return
339 }
340 role := r.PostFormValue("role")
341 if err := orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role); err != nil {
342 h.d.Logger.WarnContext(r.Context(), "teams: add member", "org_id", org.ID, "team_id", team.ID, "user_id", uid, "error", err)
343 if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
344 http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther)
345 return
346 }
347 }
348 }
349 http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
350 }
351
352 // teamRepoGrant handles POST .../repos. Form expects repo_id + role,
353 // or repo_id + action=remove. Owner-only.
354 func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) {
355 org, ok := h.orgFromSlug(w, r)
356 if !ok {
357 return
358 }
359 team, ok := h.teamFromSlug(w, r, org.ID)
360 if !ok {
361 return
362 }
363 viewer := middleware.CurrentUserFromContext(r.Context())
364 if !h.requireOrgOwner(w, r, org.ID, viewer) {
365 return
366 }
367 if err := r.ParseForm(); err != nil {
368 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
369 return
370 }
371 repoID, err := h.repoIDFromTeamForm(r, org.ID)
372 if err != nil || repoID == 0 {
373 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
374 return
375 }
376 if !h.repoBelongsToOrg(r.Context(), org.ID, repoID) {
377 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
378 return
379 }
380 if r.PostFormValue("action") == "remove" {
381 _ = orgs.RevokeTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID)
382 } else {
383 noticeCode, err := h.secretTeamWriteNotice(r.Context(), org.ID, team)
384 if err != nil {
385 h.d.Logger.ErrorContext(r.Context(), "teams: secret-team entitlement check", "org_id", org.ID, "team_id", team.ID, "error", err)
386 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
387 return
388 }
389 if noticeCode != "" {
390 http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther)
391 return
392 }
393 if err := orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID,
394 r.PostFormValue("role")); err != nil {
395 h.d.Logger.WarnContext(r.Context(), "teams: grant repo", "org_id", org.ID, "team_id", team.ID, "repo_id", repoID, "error", err)
396 if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
397 http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther)
398 return
399 }
400 }
401 }
402 http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
403 }
404
405 // ─── small helpers ─────────────────────────────────────────────────
406
407 func (h *Handlers) teamFromSlug(w http.ResponseWriter, r *http.Request, orgID int64) (orgsdb.Team, bool) {
408 slug := chi.URLParam(r, "teamSlug")
409 row, err := orgsdb.New().GetTeamByOrgAndSlug(r.Context(), h.d.Pool, orgsdb.GetTeamByOrgAndSlugParams{
410 OrgID: orgID, Slug: slug,
411 })
412 if err != nil {
413 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
414 return orgsdb.Team{}, false
415 }
416 return row, true
417 }
418
419 func (h *Handlers) canSeeOrgTeams(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool {
420 if viewer.IsAnonymous() {
421 http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
422 return false
423 }
424 if viewer.IsSiteAdmin {
425 return true
426 }
427 isMember, _ := orgs.IsMember(r.Context(), h.deps(), orgID, viewer.ID)
428 if !isMember {
429 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
430 return false
431 }
432 return true
433 }
434
435 func (h *Handlers) requireOrgOwner(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) bool {
436 if viewer.IsAnonymous() {
437 http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
438 return false
439 }
440 // Suspended actors get the same 403 as non-owners. Mirrors the
441 // suspended gate the policy package enforces on every other
442 // mutation surface — this gate doesn't go through policy.Can yet
443 // (the org/team actions aren't in the policy enum), so we
444 // short-circuit here (SR2 C4). Same shape as SR1 C1 fix.
445 if viewer.IsSuspended {
446 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
447 return false
448 }
449 owner, _ := orgs.IsOwner(r.Context(), h.deps(), orgID, viewer.ID)
450 if !owner {
451 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
452 return false
453 }
454 return true
455 }
456
457 func (h *Handlers) userIDByUsername(r *http.Request, username string) (int64, bool) {
458 var id int64
459 err := h.d.Pool.QueryRow(
460 r.Context(),
461 `SELECT id FROM users WHERE username = $1 AND deleted_at IS NULL`,
462 username,
463 ).Scan(&id)
464 if err != nil {
465 return 0, false
466 }
467 return id, true
468 }
469
470 func (h *Handlers) userIDFromTeamMemberForm(r *http.Request) (int64, bool) {
471 if raw := strings.TrimSpace(r.PostFormValue("user_id")); raw != "" {
472 id, err := strconv.ParseInt(raw, 10, 64)
473 if err == nil && id != 0 {
474 return id, true
475 }
476 return 0, false
477 }
478 username := strings.ToLower(strings.TrimSpace(r.PostFormValue("username")))
479 if username == "" {
480 return 0, false
481 }
482 return h.userIDByUsername(r, username)
483 }
484
485 func (h *Handlers) userIsOrgMember(ctx context.Context, orgID, userID int64) bool {
486 var exists bool
487 err := h.d.Pool.QueryRow(ctx,
488 `SELECT EXISTS(SELECT 1 FROM org_members WHERE org_id = $1 AND user_id = $2)`,
489 orgID, userID,
490 ).Scan(&exists)
491 return err == nil && exists
492 }
493
494 // canSeeTeam decides whether the viewer is allowed to see a team's members
495 // and repositories. canSeeOrgTeams has already enforced org membership;
496 // visible teams are readable to those members, while secret teams require
497 // team membership or org ownership.
498 func (h *Handlers) canSeeTeam(r *http.Request, team orgsdb.Team, viewer middleware.CurrentUser) bool {
499 if team.Privacy == orgsdb.TeamPrivacyVisible {
500 return true
501 }
502 if viewer.IsAnonymous() {
503 return false
504 }
505 // Org owner sees all.
506 if owner, _ := orgs.IsOwner(r.Context(), h.deps(), team.OrgID, viewer.ID); owner {
507 return true
508 }
509 // Team member? (SR2 M2 + M3: was an inline EXISTS preceded by a
510 // wasted ListTeamMembers call whose result was dropped with `_`.)
511 member, err := orgsdb.New().IsTeamMember(r.Context(), h.d.Pool, orgsdb.IsTeamMemberParams{
512 TeamID: team.ID, UserID: viewer.ID,
513 })
514 if err != nil {
515 return false
516 }
517 return member
518 }
519
520 // filterSecretTeams strips secret teams the viewer can't see after the
521 // caller has already established org-team-page visibility.
522 func (h *Handlers) filterSecretTeams(r *http.Request, all []orgsdb.Team, orgID int64, viewer middleware.CurrentUser) []orgsdb.Team {
523 if len(all) == 0 {
524 return all
525 }
526 out := make([]orgsdb.Team, 0, len(all))
527 isOwner := false
528 if !viewer.IsAnonymous() {
529 isOwner, _ = orgs.IsOwner(r.Context(), h.deps(), orgID, viewer.ID)
530 }
531 for _, t := range all {
532 if t.Privacy == orgsdb.TeamPrivacyVisible || isOwner {
533 out = append(out, t)
534 continue
535 }
536 // Secret + non-owner: only show when the viewer is a member.
537 if viewer.IsAnonymous() {
538 continue
539 }
540 // SR2 M2: was an inline EXISTS query.
541 member, err := orgsdb.New().IsTeamMember(r.Context(), h.d.Pool, orgsdb.IsTeamMemberParams{
542 TeamID: t.ID, UserID: viewer.ID,
543 })
544 if err == nil && member {
545 out = append(out, t)
546 }
547 }
548 return out
549 }
550
551 func (h *Handlers) orgNavCounts(ctx context.Context, orgID int64, visibleTeamCount int64) orgNavCounts {
552 var counts orgNavCounts
553 _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM repos WHERE owner_org_id = $1 AND deleted_at IS NULL`, orgID).Scan(&counts.RepoCount)
554 _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID).Scan(&counts.MemberCount)
555 if visibleTeamCount >= 0 {
556 counts.TeamCount = visibleTeamCount
557 } else {
558 _ = h.d.Pool.QueryRow(ctx, `SELECT count(*) FROM teams WHERE org_id = $1`, orgID).Scan(&counts.TeamCount)
559 }
560 return counts
561 }
562
563 func (h *Handlers) teamAggregateCounts(ctx context.Context, orgID int64) map[int64]teamAggregateCounts {
564 rows, err := h.d.Pool.Query(ctx, `
565 SELECT t.id,
566 count(DISTINCT tm.user_id)::bigint AS member_count,
567 count(DISTINCT tra.repo_id)::bigint AS repo_count,
568 count(DISTINCT child.id)::bigint AS child_count
569 FROM teams t
570 LEFT JOIN team_members tm ON tm.team_id = t.id
571 LEFT JOIN team_repo_access tra ON tra.team_id = t.id
572 LEFT JOIN teams child ON child.parent_team_id = t.id
573 WHERE t.org_id = $1
574 GROUP BY t.id`, orgID)
575 if err != nil {
576 h.d.Logger.WarnContext(ctx, "teams: counts", "org_id", orgID, "error", err)
577 return nil
578 }
579 defer rows.Close()
580 out := map[int64]teamAggregateCounts{}
581 for rows.Next() {
582 var id int64
583 var c teamAggregateCounts
584 if err := rows.Scan(&id, &c.MemberCount, &c.RepoCount, &c.ChildCount); err == nil {
585 out[id] = c
586 }
587 }
588 return out
589 }
590
591 func (h *Handlers) teamListItems(org orgsdb.Org, teams []orgsdb.Team, counts map[int64]teamAggregateCounts, parentSlugs map[int64]string) []teamListItem {
592 out := make([]teamListItem, 0, len(teams))
593 for _, team := range teams {
594 c := counts[team.ID]
595 parentSlug := ""
596 if team.ParentTeamID.Valid {
597 parentSlug = parentSlugs[team.ParentTeamID.Int64]
598 }
599 out = append(out, teamListItem{
600 ID: team.ID,
601 Slug: string(team.Slug),
602 DisplayName: teamDisplayName(team),
603 Description: team.Description,
604 Privacy: string(team.Privacy),
605 ParentSlug: parentSlug,
606 Path: h.teamPath(org, team),
607 MemberCount: c.MemberCount,
608 RepoCount: c.RepoCount,
609 ChildCount: c.ChildCount,
610 IsSecret: team.Privacy == orgsdb.TeamPrivacySecret,
611 HasParent: team.ParentTeamID.Valid,
612 CreatedLabel: team.CreatedAt.Time.Format("Jan 2, 2006"),
613 })
614 }
615 return out
616 }
617
618 func teamParentSlugs(teams []orgsdb.Team) map[int64]string {
619 if len(teams) == 0 {
620 return nil
621 }
622 byID := make(map[int64]string, len(teams))
623 for _, team := range teams {
624 byID[team.ID] = string(team.Slug)
625 }
626 return byID
627 }
628
629 func teamDisplayName(team orgsdb.Team) string {
630 if strings.TrimSpace(team.DisplayName) != "" {
631 return team.DisplayName
632 }
633 return string(team.Slug)
634 }
635
636 func teamPrivacyCounts(items []teamListItem) (visibleCount, secretCount int) {
637 for _, item := range items {
638 if item.IsSecret {
639 secretCount++
640 } else {
641 visibleCount++
642 }
643 }
644 return visibleCount, secretCount
645 }
646
647 func filterTeamListItems(items []teamListItem, query, privacy string) []teamListItem {
648 query = strings.ToLower(strings.TrimSpace(query))
649 privacy = strings.ToLower(strings.TrimSpace(privacy))
650 if query == "" && privacy == "" {
651 return items
652 }
653 out := make([]teamListItem, 0, len(items))
654 for _, item := range items {
655 if privacy == "visible" && item.IsSecret {
656 continue
657 }
658 if privacy == "secret" && !item.IsSecret {
659 continue
660 }
661 if query != "" {
662 haystack := strings.ToLower(item.Slug + " " + item.DisplayName + " " + item.Description)
663 if !strings.Contains(haystack, query) {
664 continue
665 }
666 }
667 out = append(out, item)
668 }
669 return out
670 }
671
672 func (h *Handlers) teamRepoCandidates(ctx context.Context, orgID, teamID int64) []teamRepoCandidate {
673 rows, err := h.d.Pool.Query(ctx, `
674 SELECT r.id, r.name, r.visibility::text
675 FROM repos r
676 LEFT JOIN team_repo_access a
677 ON a.repo_id = r.id AND a.team_id = $2
678 WHERE r.owner_org_id = $1
679 AND r.deleted_at IS NULL
680 AND a.repo_id IS NULL
681 ORDER BY lower(r.name)
682 LIMIT 100`, orgID, teamID)
683 if err != nil {
684 h.d.Logger.WarnContext(ctx, "teams: repo candidates", "org_id", orgID, "team_id", teamID, "error", err)
685 return nil
686 }
687 defer rows.Close()
688 out := []teamRepoCandidate{}
689 for rows.Next() {
690 var item teamRepoCandidate
691 if err := rows.Scan(&item.ID, &item.Name, &item.Visibility); err == nil {
692 out = append(out, item)
693 }
694 }
695 return out
696 }
697
698 func (h *Handlers) teamMemberCandidates(ctx context.Context, orgID, teamID int64) []teamMemberCandidate {
699 rows, err := h.d.Pool.Query(ctx, `
700 SELECT u.id, u.username, u.display_name
701 FROM org_members om
702 JOIN users u ON u.id = om.user_id
703 LEFT JOIN team_members tm
704 ON tm.team_id = $2 AND tm.user_id = u.id
705 WHERE om.org_id = $1
706 AND u.deleted_at IS NULL
707 AND tm.user_id IS NULL
708 ORDER BY lower(u.username)
709 LIMIT 100`, orgID, teamID)
710 if err != nil {
711 h.d.Logger.WarnContext(ctx, "teams: member candidates", "org_id", orgID, "team_id", teamID, "error", err)
712 return nil
713 }
714 defer rows.Close()
715 out := []teamMemberCandidate{}
716 for rows.Next() {
717 var item teamMemberCandidate
718 if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName); err == nil {
719 out = append(out, item)
720 }
721 }
722 return out
723 }
724
725 func (h *Handlers) repoIDFromTeamForm(r *http.Request, orgID int64) (int64, error) {
726 if raw := strings.TrimSpace(r.PostFormValue("repo_id")); raw != "" {
727 return strconv.ParseInt(raw, 10, 64)
728 }
729 repoName := strings.TrimSpace(r.PostFormValue("repo_name"))
730 if repoName == "" {
731 return 0, strconv.ErrSyntax
732 }
733 var id int64
734 err := h.d.Pool.QueryRow(
735 r.Context(),
736 `SELECT id FROM repos WHERE owner_org_id = $1 AND name = $2 AND deleted_at IS NULL`,
737 orgID, repoName,
738 ).Scan(&id)
739 return id, err
740 }
741
742 func (h *Handlers) repoBelongsToOrg(ctx context.Context, orgID, repoID int64) bool {
743 var exists bool
744 err := h.d.Pool.QueryRow(ctx,
745 `SELECT EXISTS(SELECT 1 FROM repos WHERE id = $1 AND owner_org_id = $2 AND deleted_at IS NULL)`,
746 repoID, orgID,
747 ).Scan(&exists)
748 return err == nil && exists
749 }
750
751 func (h *Handlers) teamPath(org orgsdb.Org, team orgsdb.Team) string {
752 return "/" + string(org.Slug) + "/teams/" + string(team.Slug)
753 }
754