package api import ( "context" "encoding/json" "net/http" "net/url" "strings" ) // orgView is the trimmed organization shape returned to clients. type orgView struct { ID string `json:"id"` Name string `json:"name"` Created string `json:"created"` } // orgNameMap returns an id→name map of all organizations via the service // account. On any error it returns an empty (non-nil) map so callers can index // it safely. func (s *Server) orgNameMap(ctx context.Context) map[string]string { out := map[string]string{} if !s.pb.Configured() { return out } data, status, err := s.pb.Raw(ctx, http.MethodGet, "/api/collections/"+colOrgs+"/records?perPage=500&fields=id,name", nil) if err != nil || status != http.StatusOK { return out } var list struct { Items []orgView `json:"items"` } _ = json.Unmarshal(data, &list) for _, o := range list.Items { out[o.ID] = o.Name } return out } // orgName resolves a single organization's name (best effort; "" on miss). func (s *Server) orgName(ctx context.Context, id string) string { if id == "" || !s.pb.Configured() { return "" } data, status, err := s.pb.Raw(ctx, http.MethodGet, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(id)+"?fields=id,name", nil) if err != nil || status != http.StatusOK { return "" } var o orgView _ = json.Unmarshal(data, &o) return o.Name } // GET /api/orgs — list organizations (manager only). Superadmins see all; // admins see only their own organization. func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) { who := caller(r) path := "/api/collections/" + colOrgs + "/records?perPage=500&sort=name&fields=id,name,created" if !who.isSuperadmin() { if who.OrgID == "" { writeJSON(w, http.StatusOK, map[string]any{"organizations": []orgView{}}) return } path += "&filter=" + url.QueryEscape("id = \""+who.OrgID+"\"") } data, status, err := s.pb.Raw(r.Context(), http.MethodGet, path, nil) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { relay(w, status, data) return } var list struct { Items []orgView `json:"items"` } _ = json.Unmarshal(data, &list) writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items}) } // POST /api/orgs — create an organization (superadmin only). Body: {name}. func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) { name, ok := decodeOrgName(w, r) if !ok { return } data, status, err := s.pb.Raw(r.Context(), http.MethodPost, "/api/collections/"+colOrgs+"/records", map[string]any{"name": name}) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { // Relay PocketBase's error (e.g. duplicate name violates the unique index). relay(w, status, data) return } var org orgView _ = json.Unmarshal(data, &org) writeJSON(w, http.StatusCreated, map[string]any{"organization": org}) } // PATCH /api/orgs/{id} — rename an organization (superadmin only). Body: {name}. func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { writeError(w, http.StatusBadRequest, "missing organization id") return } name, ok := decodeOrgName(w, r) if !ok { return } data, status, err := s.pb.Raw(r.Context(), http.MethodPatch, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), map[string]any{"name": name}) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { relay(w, status, data) return } var org orgView _ = json.Unmarshal(data, &org) writeJSON(w, http.StatusOK, map[string]any{"organization": org}) } // DELETE /api/orgs/{id} — delete an organization (superadmin only). Refused // while the org still has members, to avoid silently orphaning users. func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { writeError(w, http.StatusBadRequest, "missing organization id") return } // Guard: block deletion if any user still belongs to this org. countPath := "/api/collections/" + s.usersCollection() + "/records?perPage=1&fields=id&filter=" + url.QueryEscape("organization = \""+id+"\"") data, status, err := s.pb.Raw(r.Context(), http.MethodGet, countPath, nil) if err != nil { writeUpstreamDown(w, err) return } if status == http.StatusOK { var page struct { TotalItems int `json:"totalItems"` } _ = json.Unmarshal(data, &page) if page.TotalItems > 0 { writeError(w, http.StatusConflict, "organization still has members; reassign or remove them first") return } } data, status, err = s.pb.Raw(r.Context(), http.MethodDelete, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), nil) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK && status != http.StatusNoContent { relay(w, status, data) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // decodeOrgName parses and validates a {name} body, writing an error response // and returning ok=false on failure. func decodeOrgName(w http.ResponseWriter, r *http.Request) (string, bool) { var body struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return "", false } name := strings.TrimSpace(body.Name) if name == "" { writeError(w, http.StatusBadRequest, "organization name is required") return "", false } if len(name) > 120 { writeError(w, http.StatusBadRequest, "organization name is too long (max 120)") return "", false } return name, true }