package httpx import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" "bountyboard/internal/crypto" "bountyboard/internal/domain" "bountyboard/internal/store" syncpkg "bountyboard/internal/sync" ) // customerPayload is the create/update body. Credentials are write-only: // they are encrypted at rest and never returned. type customerPayload struct { Name *string `json:"name"` Type *domain.TicketingType `json:"type"` BaseURL *string `json:"baseUrl"` ProjectKey *string `json:"projectKey"` Credentials *syncpkg.Credentials `json:"credentials"` PollInterval *int `json:"pollIntervalSec"` ConsultantIDs *[]string `json:"consultantIds"` DefaultBudget *float64 `json:"defaultBudget"` Archived *bool `json:"archived"` Version *int64 `json:"version"` } func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) { plain, err := json.Marshal(creds) if err != nil { return "", err } return crypto.Seal(s.cfg.CredentialsEncKey, plain) } func (s *Server) openCredentials(c *domain.Customer) (syncpkg.Credentials, error) { if c.Ticketing.CredentialsEnc == "" { return syncpkg.Credentials{}, nil } plain, err := crypto.Open(s.cfg.CredentialsEncKey, c.Ticketing.CredentialsEnc) if err != nil { return nil, err } var creds syncpkg.Credentials if err := json.Unmarshal(plain, &creds); err != nil { return nil, err } return creds, nil } // customerJSON augments the customer with a hasCredentials flag (the // ciphertext itself is never serialized). func customerJSON(c *domain.Customer) map[string]any { return map[string]any{ "id": c.ID, "name": c.Name, "ticketing": map[string]any{ "type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL, "projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec, "lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus, "lastSyncError": c.Ticketing.LastSyncError, "hasCredentials": c.Ticketing.CredentialsEnc != "", }, "consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget, "archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt, "version": c.Version, } } func (s *Server) handleAdminListCustomers(w http.ResponseWriter, r *http.Request) { include := r.URL.Query().Get("includeArchived") == "true" customers, err := s.store.ListCustomers(r.Context(), "", include) if err != nil { s.internalError(w, r, "list customers", err) return } out := make([]map[string]any, len(customers)) for i := range customers { out[i] = customerJSON(&customers[i]) } writeJSON(w, http.StatusOK, map[string]any{"customers": out}) } func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request) { c, err := s.store.CustomerByID(r.Context(), r.PathValue("id")) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "customer not found") return } s.internalError(w, r, "get customer", err) return } writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)}) } func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) { for _, id := range ids { u, err := s.store.UserByID(ctx, id) if err != nil || !u.Roles.Consultant { return id, false } } return "", true } func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Request) { var req customerPayload if !decodeJSON(w, r, &req) { return } if req.Name == nil || strings.TrimSpace(*req.Name) == "" { writeError(w, http.StatusBadRequest, "invalid_name", "customer name is required") return } if req.Type == nil || !req.Type.Valid() { writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack, wekan or demo") return } settings, err := s.store.GetSettings(r.Context()) if err != nil { s.internalError(w, r, "get settings", err) return } c := &domain.Customer{ Name: strings.TrimSpace(*req.Name), Ticketing: domain.Ticketing{ Type: *req.Type, PollIntervalSec: settings.DefaultPollIntervalSec, }, DefaultBudget: settings.DefaultCustomerBudget, ConsultantIDs: []string{}, } if req.BaseURL != nil { c.Ticketing.BaseURL = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/") } if req.ProjectKey != nil { c.Ticketing.ProjectKey = strings.TrimSpace(*req.ProjectKey) } if req.PollInterval != nil && *req.PollInterval >= 10 { c.Ticketing.PollIntervalSec = *req.PollInterval } if req.DefaultBudget != nil { c.DefaultBudget = *req.DefaultBudget } if req.ConsultantIDs != nil { if bad, ok := s.validateConsultants(r.Context(), *req.ConsultantIDs); !ok { writeError(w, http.StatusBadRequest, "invalid_consultant", "not a consultant: "+bad) return } c.ConsultantIDs = *req.ConsultantIDs } creds := syncpkg.Credentials{} if req.Credentials != nil { creds = *req.Credentials } // Validate credential shape by constructing the connector. if _, err := syncpkg.NewConnector(c.Ticketing.Type, c.Ticketing.BaseURL, c.Ticketing.ProjectKey, creds); err != nil { writeError(w, http.StatusBadRequest, "invalid_credentials", err.Error()) return } if c.Ticketing.CredentialsEnc, err = s.sealCredentials(creds); err != nil { s.internalError(w, r, "seal credentials", err) return } if err := s.store.CreateCustomer(r.Context(), c); err != nil { if errors.Is(err, store.ErrDuplicate) { writeError(w, http.StatusConflict, "name_taken", "a customer with this name exists") return } s.internalError(w, r, "create customer", err) return } s.audit(r, "customer.create", "customer", c.ID, map[string]any{"name": c.Name, "type": c.Ticketing.Type}) writeJSON(w, http.StatusCreated, map[string]any{"customer": customerJSON(c)}) } func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req customerPayload if !decodeJSON(w, r, &req) { return } c, err := s.store.CustomerByID(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "customer not found") return } s.internalError(w, r, "get customer", err) return } set := bson.M{} diff := map[string]any{} if req.Name != nil && strings.TrimSpace(*req.Name) != "" { set["name"] = strings.TrimSpace(*req.Name) diff["name"] = set["name"] } if req.Type != nil { if !req.Type.Valid() { writeError(w, http.StatusBadRequest, "invalid_type", "bad ticketing type") return } set["ticketing.type"] = *req.Type diff["type"] = *req.Type } if req.BaseURL != nil { set["ticketing.baseUrl"] = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/") diff["baseUrl"] = set["ticketing.baseUrl"] } if req.ProjectKey != nil { set["ticketing.projectKey"] = strings.TrimSpace(*req.ProjectKey) diff["projectKey"] = set["ticketing.projectKey"] } if req.PollInterval != nil { if *req.PollInterval < 10 { writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s") return } set["ticketing.pollIntervalSec"] = *req.PollInterval diff["pollIntervalSec"] = *req.PollInterval } if req.ConsultantIDs != nil { if bad, ok := s.validateConsultants(r.Context(), *req.ConsultantIDs); !ok { writeError(w, http.StatusBadRequest, "invalid_consultant", "not a consultant: "+bad) return } set["consultantIds"] = *req.ConsultantIDs diff["consultantIds"] = *req.ConsultantIDs } if req.DefaultBudget != nil { set["defaultBudget"] = *req.DefaultBudget diff["defaultBudget"] = *req.DefaultBudget } if req.Archived != nil { set["archived"] = *req.Archived diff["archived"] = *req.Archived } if req.Credentials != nil { enc, err := s.sealCredentials(*req.Credentials) if err != nil { s.internalError(w, r, "seal credentials", err) return } set["ticketing.credentialsEnc"] = enc diff["credentials"] = "rotated" } if len(set) == 0 { writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields") return } version := c.Version if req.Version != nil { version = *req.Version } if err := s.store.UpdateCustomer(r.Context(), id, version, set); err != nil { if errors.Is(err, store.ErrVersionConflict) { writeError(w, http.StatusConflict, "conflict", "customer was modified concurrently; reload") return } s.internalError(w, r, "update customer", err) return } s.audit(r, "customer.update", "customer", id, diff) fresh, err := s.store.CustomerByID(r.Context(), id) if err != nil { s.internalError(w, r, "reload customer", err) return } writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(fresh)}) } func (s *Server) handleAdminDeleteCustomer(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") n, err := s.store.CountTasksForCustomer(r.Context(), id) if err != nil { s.internalError(w, r, "count tasks", err) return } if n > 0 { writeError(w, http.StatusConflict, "has_tasks", "customer has imported tasks; archive it instead of deleting") return } if err := s.store.DeleteCustomer(r.Context(), id); err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "customer not found") return } s.internalError(w, r, "delete customer", err) return } s.audit(r, "customer.delete", "customer", id, nil) w.WriteHeader(http.StatusNoContent) } // handleAdminTestConnection tests the stored configuration of an existing // customer. func (s *Server) handleAdminTestConnection(w http.ResponseWriter, r *http.Request) { c, err := s.store.CustomerByID(r.Context(), r.PathValue("id")) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "customer not found") return } s.internalError(w, r, "get customer", err) return } creds, err := s.openCredentials(c) if err != nil { s.internalError(w, r, "decrypt credentials", err) return } s.runConnectionTest(w, r, c.Ticketing.Type, c.Ticketing.BaseURL, c.Ticketing.ProjectKey, creds) } // handleAdminTestConnectionUnsaved tests credentials supplied in the wizard // before the customer is saved. func (s *Server) handleAdminTestConnectionUnsaved(w http.ResponseWriter, r *http.Request) { var req struct { Type domain.TicketingType `json:"type"` BaseURL string `json:"baseUrl"` ProjectKey string `json:"projectKey"` Credentials syncpkg.Credentials `json:"credentials"` } if !decodeJSON(w, r, &req) { return } s.runConnectionTest(w, r, req.Type, strings.TrimRight(req.BaseURL, "/"), req.ProjectKey, req.Credentials) } func (s *Server) runConnectionTest(w http.ResponseWriter, r *http.Request, t domain.TicketingType, baseURL, projectKey string, creds syncpkg.Credentials) { conn, err := syncpkg.NewConnector(t, baseURL, projectKey, creds) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()}) return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() start := time.Now() if err := conn.TestConnection(ctx); err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error(), "latencyMs": time.Since(start).Milliseconds()}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "latencyMs": time.Since(start).Milliseconds()}) } func (s *Server) handleAdminSyncNow(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, err := s.store.CustomerByID(r.Context(), id); err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "customer not found") return } s.internalError(w, r, "get customer", err) return } if s.syncTrigger == nil { writeError(w, http.StatusServiceUnavailable, "sync_unavailable", "sync manager is not running") return } s.syncTrigger(id) s.audit(r, "customer.sync-now", "customer", id, nil) writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"}) }