Files
BountyBoard/internal/http/admin_customers.go
T
etalon f3897907a3 feat: WeKan ticketing source
- wekan connector: board id as project key, cards assigned to the
  consultant's WeKan username (assignees with member fallback), archived
  cards skipped, client-side modifiedAt since-filtering, username/password
  login with token reuse (or pre-issued token), case-insensitive identity
- fake-WeKan unit tests: factory validation, test-connection, fetch
  filtering, key listing, token reuse
- admin customer wizard: WeKan option + credential fields
- OpenAPI enum + credential shapes, README identity mapping docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:15:34 +02:00

377 lines
12 KiB
Go

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"})
}