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.admin.configured() { return out } data, status, err := s.admin.do(ctx, http.MethodGet, "/api/collections/organizations/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.admin.configured() { return "" } data, status, err := s.admin.do(ctx, http.MethodGet, "/api/collections/organizations/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/organizations/records?perPage=500&sort=name&fields=id,name,created" if who != nil && !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.admin.do(r.Context(), http.MethodGet, path, nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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.admin.do(r.Context(), http.MethodPost, "/api/collections/organizations/records", map[string]any{"name": name}) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK { // Relay PocketBase's error (e.g. duplicate name violates the unique index). w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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.admin.do(r.Context(), http.MethodPatch, "/api/collections/organizations/records/"+url.PathEscape(id), map[string]any{"name": name}) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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/users/records?perPage=1&fields=id&filter=" + url.QueryEscape("organization = \""+id+"\"") data, status, err := s.admin.do(r.Context(), http.MethodGet, countPath, nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) 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.admin.do(r.Context(), http.MethodDelete, "/api/collections/organizations/records/"+url.PathEscape(id), nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK && status != http.StatusNoContent { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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 }