Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user