f3897907a3
- 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>
377 lines
12 KiB
Go
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"})
|
|
}
|