From 458ba6a7ee81b95e71fdd3cf70887a86626b0926 Mon Sep 17 00:00:00 2001 From: etalon Date: Fri, 12 Jun 2026 18:57:10 +0200 Subject: [PATCH] feat: admin area with customers, users, settings, audit log, service status (phase 5) - customer CRUD wizard backend: per-system credential shapes validated via connector construction, AES-256-GCM at rest, write-only over the API - ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection - user management: roles, disable (revokes sessions), force password reset, delete; self-demotion/disable/delete guards - admin-editable runtime settings ({_id:app}) incl. atomizer URL override - audit log written on every privileged mutation + filterable list API - service status panel: mongo/atomizer/work-performer health + latency with late-bound breaker, sync and jobs status providers - tabbed admin UI (customers wizard dialog, users table, settings, status, audit) - compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger) Co-Authored-By: Claude Fable 5 --- PROGRESS.md | 1 + docker-compose.yml | 6 + internal/domain/customer.go | 56 ++++ internal/domain/settings.go | 17 ++ internal/http/admin.go | 194 ++++++++++++ internal/http/admin_customers.go | 376 ++++++++++++++++++++++++ internal/http/admin_integration_test.go | 315 ++++++++++++++++++++ internal/http/admin_users.go | 167 +++++++++++ internal/http/providers.go | 55 ++++ internal/http/server.go | 8 + internal/http/web.go | 12 +- internal/store/audit.go | 63 ++++ internal/store/customers.go | 106 +++++++ internal/store/settings.go | 54 ++++ internal/sync/azure.go | 166 +++++++++++ internal/sync/connector.go | 135 +++++++++ internal/sync/demo.go | 75 +++++ internal/sync/jira.go | 144 +++++++++ internal/sync/youtrack.go | 131 +++++++++ web/static/css/app.css | 15 + web/static/js/admin.js | 363 +++++++++++++++++++++++ web/templates/admin.html | 151 ++++++++++ 22 files changed, 2609 insertions(+), 1 deletion(-) create mode 100644 internal/domain/customer.go create mode 100644 internal/domain/settings.go create mode 100644 internal/http/admin.go create mode 100644 internal/http/admin_customers.go create mode 100644 internal/http/admin_integration_test.go create mode 100644 internal/http/admin_users.go create mode 100644 internal/http/providers.go create mode 100644 internal/store/audit.go create mode 100644 internal/store/customers.go create mode 100644 internal/store/settings.go create mode 100644 internal/sync/azure.go create mode 100644 internal/sync/connector.go create mode 100644 internal/sync/demo.go create mode 100644 internal/sync/jira.go create mode 100644 internal/sync/youtrack.go create mode 100644 web/static/js/admin.js create mode 100644 web/templates/admin.html diff --git a/PROGRESS.md b/PROGRESS.md index 5172811..52e0db9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -5,3 +5,4 @@ - Phase 2 (store layer): mongo-driver v2 connection with startup retry, §4.9 indexes idempotent, optimistic-concurrency UpdateVersioned (ErrVersionConflict/ErrNotFound), AES-256-GCM credential crypto, HMAC-signed file URL tokens (1h TTL), GridFS store with MIME sniffing + size cap + sha256, /readyz mongo check (verified 503 on stopped Mongo) — unit + integration tests green. - Phase 3 (auth): argon2id (PHC, spec params), login/register rate limiting, opaque Mongo sessions (30d sliding, TTL index), CSRF double-submit, bootstrap admin with forced password change, RBAC middleware + matrix tests, OIDC code flow with PKCE + email linking (verified-only), full integration suite incl. fake IdP — all green. - Phase 4 (base UI shell): embedded templates + static assets (no build step), §10 beige/dark tokens with radius 2px, theme toggle persisted to localStorage + profile, login/register/change-password pages, profile page (avatar upload w/ MIME check, bio, contacts, arbitrary extra fields, version-conflict 409), role-based navigation, /files/{id} serving (session or signed token), CSP without inline scripts + security headers — unit + integration tests green, stack verified live. +- Phase 5 (admin): customer CRUD wizard with per-system encrypted credentials + test connection (jira/ado/youtrack/demo connectors), user management (roles, disable w/ session revoke, force-reset, delete, self-lockout guards), runtime settings doc, audit log on every privileged mutation + list API, service-status panel (mongo/atomizer/WP health + latency, late-bound breaker/sync/jobs providers), tabbed admin UI - tests green. Fixed mongod WT_PANIC: container nofile ulimit was 1024, raised to 64000 in compose. diff --git a/docker-compose.yml b/docker-compose.yml index 2ade5c6..5d9e882 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,12 @@ services: restart: unless-stopped # modest cache so the whole stack fits comfortably on small hosts command: ["mongod", "--wiredTigerCacheSizeGB", "0.5"] + # WiredTiger needs far more FDs than the 1024 some hosts default to; + # running out crashes mongod with a WT_PANIC during file creation + ulimits: + nofile: + soft: 64000 + hard: 64000 # Mongo is reachable only on the compose network; never published to the # host (§12). volumes: diff --git a/internal/domain/customer.go b/internal/domain/customer.go new file mode 100644 index 0000000..4df3b1b --- /dev/null +++ b/internal/domain/customer.go @@ -0,0 +1,56 @@ +package domain + +import "time" + +type TicketingType string + +const ( + TicketingJira TicketingType = "jira" + TicketingAzure TicketingType = "azure_devops" + TicketingYouTrack TicketingType = "youtrack" + // TicketingDemo fabricates tickets locally so the whole flow works + // offline (§11.11). + TicketingDemo TicketingType = "demo" +) + +func (t TicketingType) Valid() bool { + switch t { + case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo: + return true + } + return false +} + +type Ticketing struct { + Type TicketingType `bson:"type" json:"type"` + BaseURL string `bson:"baseUrl" json:"baseUrl"` + CredentialsEnc string `bson:"credentialsEnc" json:"-"` + ProjectKey string `bson:"projectKey" json:"projectKey"` + PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"` + LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"` + LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error + LastSyncError string `bson:"lastSyncError" json:"lastSyncError"` +} + +// Customer == "project" in the UI (§4.2). +type Customer struct { + ID string `bson:"_id" json:"id"` + Name string `bson:"name" json:"name"` + Ticketing Ticketing `bson:"ticketing" json:"ticketing"` + ConsultantIDs []string `bson:"consultantIds" json:"consultantIds"` + DefaultBudget float64 `bson:"defaultBudget" json:"defaultBudget"` + Archived bool `bson:"archived" json:"archived"` + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` + Version int64 `bson:"version" json:"version"` +} + +// HasConsultant reports membership in the customer's consultant set. +func (c *Customer) HasConsultant(userID string) bool { + for _, id := range c.ConsultantIDs { + if id == userID { + return true + } + } + return false +} diff --git a/internal/domain/settings.go b/internal/domain/settings.go new file mode 100644 index 0000000..e7d9842 --- /dev/null +++ b/internal/domain/settings.go @@ -0,0 +1,17 @@ +package domain + +import "time" + +// AppSettings is the single admin-editable runtime settings document +// ({_id:"app"}, §4.8). Env vars are the defaults; these override where the +// spec marks them overridable. +type AppSettings struct { + ID string `bson:"_id" json:"-"` + BrandingName string `bson:"brandingName" json:"brandingName"` + AtomizerBaseURLOverride string `bson:"atomizerBaseUrlOverride" json:"atomizerBaseUrlOverride"` + HideCompetingClaims bool `bson:"hideCompetingClaims" json:"hideCompetingClaims"` + DefaultPollIntervalSec int `bson:"defaultPollIntervalSec" json:"defaultPollIntervalSec"` + DefaultCustomerBudget float64 `bson:"defaultCustomerBudget" json:"defaultCustomerBudget"` + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` + Version int64 `bson:"version" json:"version"` +} diff --git a/internal/http/admin.go b/internal/http/admin.go new file mode 100644 index 0000000..2d1cf0f --- /dev/null +++ b/internal/http/admin.go @@ -0,0 +1,194 @@ +package httpx + +import ( + "context" + "net/http" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + + "bountyboard/internal/store" +) + +func (s *Server) routesAdmin(mux *http.ServeMux) { + adm := func(h http.HandlerFunc) http.Handler { return s.authedRole("admin", h) } + + mux.Handle("GET /api/v1/admin/users", adm(s.handleAdminListUsers)) + mux.Handle("PATCH /api/v1/admin/users/{id}", adm(s.handleAdminPatchUser)) + mux.Handle("DELETE /api/v1/admin/users/{id}", adm(s.handleAdminDeleteUser)) + + mux.Handle("GET /api/v1/admin/customers", adm(s.handleAdminListCustomers)) + mux.Handle("POST /api/v1/admin/customers", adm(s.handleAdminCreateCustomer)) + mux.Handle("GET /api/v1/admin/customers/{id}", adm(s.handleAdminGetCustomer)) + mux.Handle("PATCH /api/v1/admin/customers/{id}", adm(s.handleAdminPatchCustomer)) + mux.Handle("DELETE /api/v1/admin/customers/{id}", adm(s.handleAdminDeleteCustomer)) + mux.Handle("POST /api/v1/admin/customers/test-connection", adm(s.handleAdminTestConnectionUnsaved)) + mux.Handle("POST /api/v1/admin/customers/{id}/test-connection", adm(s.handleAdminTestConnection)) + mux.Handle("POST /api/v1/admin/customers/{id}/sync-now", adm(s.handleAdminSyncNow)) + + mux.Handle("GET /api/v1/admin/settings", adm(s.handleAdminGetSettings)) + mux.Handle("PATCH /api/v1/admin/settings", adm(s.handleAdminPatchSettings)) + mux.Handle("GET /api/v1/admin/audit-log", adm(s.handleAdminAuditLog)) + mux.Handle("GET /api/v1/admin/service-status", adm(s.handleAdminServiceStatus)) +} + +// audit records a privileged mutation; failure is logged, never fatal. +func (s *Server) audit(r *http.Request, action, entity, entityID string, diff map[string]any) { + err := s.store.Audit(r.Context(), store.AuditEntry{ + ActorID: CurrentUser(r.Context()).ID, + ActorIP: ClientIP(r.Context()).String(), + Action: action, + Entity: entity, + EntityID: entityID, + Diff: diff, + }) + if err != nil { + s.log.Error("audit write failed", "action", action, "err", err) + } +} + +func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) { + st, err := s.store.GetSettings(r.Context()) + if err != nil { + s.internalError(w, r, "get settings", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"settings": st}) +} + +func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request) { + var req struct { + BrandingName *string `json:"brandingName"` + AtomizerBaseURLOverride *string `json:"atomizerBaseUrlOverride"` + HideCompetingClaims *bool `json:"hideCompetingClaims"` + DefaultPollIntervalSec *int `json:"defaultPollIntervalSec"` + DefaultCustomerBudget *float64 `json:"defaultCustomerBudget"` + } + if !decodeJSON(w, r, &req) { + return + } + set := bson.M{} + if req.BrandingName != nil { + set["brandingName"] = *req.BrandingName + } + if req.AtomizerBaseURLOverride != nil { + set["atomizerBaseUrlOverride"] = *req.AtomizerBaseURLOverride + } + if req.HideCompetingClaims != nil { + set["hideCompetingClaims"] = *req.HideCompetingClaims + } + if req.DefaultPollIntervalSec != nil { + if *req.DefaultPollIntervalSec < 10 { + writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s") + return + } + set["defaultPollIntervalSec"] = *req.DefaultPollIntervalSec + } + if req.DefaultCustomerBudget != nil { + set["defaultCustomerBudget"] = *req.DefaultCustomerBudget + } + if len(set) == 0 { + writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields") + return + } + st, err := s.store.PatchSettings(r.Context(), set) + if err != nil { + s.internalError(w, r, "patch settings", err) + return + } + s.audit(r, "settings.update", "settings", "app", map[string]any{"set": set}) + writeJSON(w, http.StatusOK, map[string]any{"settings": st}) +} + +func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"), + q.Get("cursor"), atoiDefault(q.Get("limit"), 50)) + if err != nil { + s.internalError(w, r, "list audit", err) + return + } + next := "" + if len(entries) > 0 { + next = entries[len(entries)-1].ID + } + writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next}) +} + +// serviceStatus is the §11.17 admin panel payload. Breaker and queue fields +// are filled in by the phases that introduce them. +type externalStatus struct { + Name string `json:"name"` + URL string `json:"url"` + Healthy bool `json:"healthy"` + LatencyMs int64 `json:"latencyMs"` + Error string `json:"error,omitempty"` + Breaker string `json:"breaker"` +} + +func (s *Server) handleAdminServiceStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second) + defer cancel() + + atomizer := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx)) + performer := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL) + if s.atomizer != nil { + atomizer.Breaker = s.atomizer.BreakerState() + } + if s.performer != nil { + performer.Breaker = s.performer.BreakerState() + } + + mongoOK := true + mongoErr := "" + if err := s.store.Ping(ctx); err != nil { + mongoOK, mongoErr = false, err.Error() + } + + out := map[string]any{ + "mongo": map[string]any{"healthy": mongoOK, "error": mongoErr}, + "atomizer": atomizer, + "workPerformer": performer, + "syncWorkers": s.syncStatuses(), + "jobs": s.jobStatuses(), + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) probeExternal(ctx context.Context, name, baseURL string) externalStatus { + st := externalStatus{Name: name, URL: baseURL, Breaker: "unknown"} + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz", nil) + if err != nil { + st.Error = err.Error() + return st + } + resp, err := healthProbeClient.Do(req) + st.LatencyMs = time.Since(start).Milliseconds() + if err != nil { + st.Error = err.Error() + return st + } + resp.Body.Close() + st.Healthy = resp.StatusCode == http.StatusOK + if !st.Healthy { + st.Error = resp.Status + } + return st +} + +var healthProbeClient = &http.Client{Timeout: 3 * time.Second} + +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + var n int + for _, c := range s { + if c < '0' || c > '9' { + return def + } + n = n*10 + int(c-'0') + } + return n +} diff --git a/internal/http/admin_customers.go b/internal/http/admin_customers.go new file mode 100644 index 0000000..e893004 --- /dev/null +++ b/internal/http/admin_customers.go @@ -0,0 +1,376 @@ +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 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"}) +} diff --git a/internal/http/admin_integration_test.go b/internal/http/admin_integration_test.go new file mode 100644 index 0000000..ab9f5f7 --- /dev/null +++ b/internal/http/admin_integration_test.go @@ -0,0 +1,315 @@ +//go:build integration + +package httpx + +import ( + "fmt" + "net/http" + "testing" + + "go.mongodb.org/mongo-driver/v2/bson" + + "bountyboard/internal/domain" + "bountyboard/internal/store" +) + +// promote grants roles to a user directly in the store. +func promote(t *testing.T, st *store.Store, userID string, roles map[string]bool) { + t.Helper() + set := bson.M{} + for role, v := range roles { + set["roles."+role] = v + } + if _, err := st.DB.Collection("users").UpdateOne(t.Context(), + bson.M{"_id": userID}, bson.M{"$set": set}); err != nil { + t.Fatal(err) + } +} + +// adminClient registers + promotes an admin and returns a logged-in client. +func adminClient(t *testing.T, ts string, st *store.Store, email string) (*http.Client, string, domain.User) { + t.Helper() + c := newClient(t) + u := registerUser(t, ts, c, email, "Admin "+email) + promote(t, st, u.ID, map[string]bool{"admin": true}) + return c, csrfFrom(t, c, ts), u +} + +func TestAdminRBACDenied(t *testing.T) { + ts, _, _, _ := newAuthStack(t, nil) + c := newClient(t) + registerUser(t, ts.URL, c, "plebeian@example.com", "Plebeian") + csrf := csrfFrom(t, c, ts.URL) + + // developer is denied on every admin route + for _, probe := range []struct{ method, path string }{ + {"GET", "/api/v1/admin/users"}, + {"GET", "/api/v1/admin/customers"}, + {"GET", "/api/v1/admin/settings"}, + {"GET", "/api/v1/admin/audit-log"}, + {"GET", "/api/v1/admin/service-status"}, + } { + req, _ := http.NewRequest(probe.method, ts.URL+probe.path, nil) + req.Header.Set(csrfHeader, csrf) + resp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("%s %s as developer: %d, want 403", probe.method, probe.path, resp.StatusCode) + } + } +} + +func TestAdminCustomerLifecycle(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + c, csrf, _ := adminClient(t, ts.URL, st, "boss@example.com") + + // a consultant to assign + cc := newClient(t) + consultant := registerUser(t, ts.URL, cc, "cons@example.com", "Cons") + promote(t, st, consultant.ID, map[string]bool{"consultant": true}) + + // create demo customer + resp := postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{ + "name": "ACME Demo", "type": "demo", "projectKey": "ACME", + "pollIntervalSec": 15, "defaultBudget": 2000, + "consultantIds": []string{consultant.ID}, + "credentials": map[string]string{}, + }, csrf) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create customer: %d", resp.StatusCode) + } + var created struct { + Customer struct { + ID string `json:"id"` + Version int64 `json:"version"` + Ticketing struct { + HasCredentials bool `json:"hasCredentials"` + } `json:"ticketing"` + } `json:"customer"` + } + bodyJSON(t, resp, &created) + id := created.Customer.ID + + // jira customer requires credentials + resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{ + "name": "Broken Jira", "type": "jira", "credentials": map[string]string{"email": "x@y.z"}, + }, csrf) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("jira without token: %d", resp.StatusCode) + } + resp.Body.Close() + + // duplicate name + resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{ + "name": "ACME Demo", "type": "demo", + }, csrf) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("duplicate name: %d", resp.StatusCode) + } + resp.Body.Close() + + // stored credentials round-trip through test-connection (demo always ok) + resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id+"/test-connection", map[string]any{}, csrf) + var test struct { + OK bool `json:"ok"` + } + bodyJSON(t, resp, &test) + if !test.OK { + t.Fatal("demo test-connection should pass") + } + + // unsaved-credentials test endpoint (jira shape validation) + resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/test-connection", map[string]any{ + "type": "jira", "baseUrl": "https://nope.invalid", "credentials": map[string]string{}, + }, csrf) + bodyJSON(t, resp, &test) + if test.OK { + t.Fatal("jira with empty credentials must fail the test") + } + + // patch: rename + archive + resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{ + "name": "ACME Renamed", "archived": true, "version": created.Customer.Version, + }, csrf) + if resp.StatusCode != http.StatusOK { + t.Fatalf("patch customer: %d", resp.StatusCode) + } + resp.Body.Close() + + // stale version conflicts + resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{ + "name": "Stale", "version": created.Customer.Version, + }, csrf) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("stale customer patch: %d", resp.StatusCode) + } + resp.Body.Close() + + // delete works while no tasks exist + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/customers/"+id, nil) + req.Header.Set(csrfHeader, csrf) + dResp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + dResp.Body.Close() + if dResp.StatusCode != http.StatusNoContent { + t.Fatalf("delete customer: %d", dResp.StatusCode) + } + + // audit log recorded the mutations + aResp, err := c.Get(ts.URL + "/api/v1/admin/audit-log") + if err != nil { + t.Fatal(err) + } + var audit struct { + Entries []store.AuditEntry `json:"entries"` + } + bodyJSON(t, aResp, &audit) + actions := map[string]bool{} + for _, e := range audit.Entries { + actions[e.Action] = true + } + for _, want := range []string{"customer.create", "customer.update", "customer.delete"} { + if !actions[want] { + t.Errorf("audit log missing action %q (have %v)", want, actions) + } + } +} + +func TestAdminUserManagement(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + c, csrf, admin := adminClient(t, ts.URL, st, "root2@example.com") + + tc := newClient(t) + target := registerUser(t, ts.URL, tc, "target@example.com", "Target") + + // search finds the user + resp, err := c.Get(ts.URL + "/api/v1/admin/users?q=target") + if err != nil { + t.Fatal(err) + } + var list struct { + Users []domain.User `json:"users"` + } + bodyJSON(t, resp, &list) + if len(list.Users) != 1 || list.Users[0].ID != target.ID { + t.Fatalf("search: %+v", list.Users) + } + + // grant consultant role + resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{ + "roles": map[string]bool{"admin": false, "consultant": true, "developer": true}, + }, csrf) + if resp.StatusCode != http.StatusOK { + t.Fatalf("grant role: %d", resp.StatusCode) + } + var patched struct { + User domain.User `json:"user"` + } + bodyJSON(t, resp, &patched) + if !patched.User.Roles.Consultant { + t.Fatal("consultant role not granted") + } + + // force reset marks mustChange + resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"forceReset": true}, csrf) + if resp.StatusCode != http.StatusOK { + t.Fatalf("force reset: %d", resp.StatusCode) + } + resp.Body.Close() + fresh, err := st.UserByID(t.Context(), target.ID) + if err != nil { + t.Fatal(err) + } + if !fresh.MustChangePassword() { + t.Fatal("forceReset did not set mustChange") + } + + // disabling kicks the user's session + resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"disabled": true}, csrf) + resp.Body.Close() + meResp, _ := tc.Get(ts.URL + "/api/v1/auth/me") + meResp.Body.Close() + if meResp.StatusCode == http.StatusOK { + t.Fatal("disabled user still has a working session") + } + + // self-demotion and self-disable are blocked + for i, body := range []map[string]any{ + {"roles": map[string]bool{"admin": false, "consultant": false, "developer": true}}, + {"disabled": true}, + } { + resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+admin.ID, body, csrf) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("self-mutation %d: %d, want 400", i, resp.StatusCode) + } + resp.Body.Close() + } + + // delete target + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/users/"+target.ID, nil) + req.Header.Set(csrfHeader, csrf) + dResp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + dResp.Body.Close() + if dResp.StatusCode != http.StatusNoContent { + t.Fatalf("delete user: %d", dResp.StatusCode) + } +} + +func TestAdminSettings(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + c, csrf, _ := adminClient(t, ts.URL, st, "settings@example.com") + + resp, err := c.Get(ts.URL + "/api/v1/admin/settings") + if err != nil { + t.Fatal(err) + } + var got struct { + Settings domain.AppSettings `json:"settings"` + } + bodyJSON(t, resp, &got) + if got.Settings.DefaultPollIntervalSec != 60 { + t.Fatalf("default poll = %d", got.Settings.DefaultPollIntervalSec) + } + + resp = patchJSON(t, c, ts.URL+"/api/v1/admin/settings", map[string]any{ + "brandingName": "ACME Bounties", "defaultPollIntervalSec": 30, + "hideCompetingClaims": true, + }, csrf) + if resp.StatusCode != http.StatusOK { + t.Fatalf("patch settings: %d", resp.StatusCode) + } + bodyJSON(t, resp, &got) + if got.Settings.BrandingName != "ACME Bounties" || got.Settings.DefaultPollIntervalSec != 30 || + !got.Settings.HideCompetingClaims { + t.Fatalf("settings after patch: %+v", got.Settings) + } +} + +func TestAdminServiceStatusShape(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + c, _, _ := adminClient(t, ts.URL, st, fmt.Sprintf("status-%d@example.com", 1)) + + resp, err := c.Get(ts.URL + "/api/v1/admin/service-status") + if err != nil { + t.Fatal(err) + } + var out map[string]any + bodyJSON(t, resp, &out) + for _, key := range []string{"mongo", "atomizer", "workPerformer", "syncWorkers", "jobs"} { + if _, ok := out[key]; !ok { + t.Errorf("service-status missing %q", key) + } + } + mongo := out["mongo"].(map[string]any) + if mongo["healthy"] != true { + t.Error("mongo should be healthy in tests") + } +} diff --git a/internal/http/admin_users.go b/internal/http/admin_users.go new file mode 100644 index 0000000..49af5e6 --- /dev/null +++ b/internal/http/admin_users.go @@ -0,0 +1,167 @@ +package httpx + +import ( + "errors" + "net/http" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "bountyboard/internal/domain" + "bountyboard/internal/store" +) + +func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + filter := bson.M{} + if search := q.Get("q"); search != "" { + filter["$or"] = []bson.M{ + {"email": bson.M{"$regex": regexEscape(search), "$options": "i"}}, + {"name": bson.M{"$regex": regexEscape(search), "$options": "i"}}, + } + } + if role := q.Get("role"); role != "" { + filter["roles."+role] = true + } + if cursor := q.Get("cursor"); cursor != "" { + filter["_id"] = bson.M{"$gt": cursor} + } + limit := atoiDefault(q.Get("limit"), 100) + if limit > 200 { + limit = 200 + } + cur, err := s.store.DB.Collection("users").Find(r.Context(), filter, + options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}).SetLimit(int64(limit))) + if err != nil { + s.internalError(w, r, "list users", err) + return + } + users := []domain.User{} + if err := cur.All(r.Context(), &users); err != nil { + s.internalError(w, r, "decode users", err) + return + } + next := "" + if len(users) == limit { + next = users[len(users)-1].ID + } + writeJSON(w, http.StatusOK, map[string]any{"users": users, "nextCursor": next}) +} + +func (s *Server) handleAdminPatchUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var req struct { + Roles *domain.Roles `json:"roles"` + Disabled *bool `json:"disabled"` + ForceReset *bool `json:"forceReset"` + Name *string `json:"name"` + } + if !decodeJSON(w, r, &req) { + return + } + actor := CurrentUser(r.Context()) + target, err := s.store.UserByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "user not found") + return + } + s.internalError(w, r, "load user", err) + return + } + + set := bson.M{} + diff := map[string]any{} + if req.Roles != nil { + // An admin cannot remove their own admin flag — lockout guard. + if target.ID == actor.ID && !req.Roles.Admin { + writeError(w, http.StatusBadRequest, "self_demotion", "you cannot remove your own admin role") + return + } + set["roles"] = *req.Roles + diff["roles"] = *req.Roles + } + if req.Disabled != nil { + if target.ID == actor.ID && *req.Disabled { + writeError(w, http.StatusBadRequest, "self_disable", "you cannot disable your own account") + return + } + set["disabled"] = *req.Disabled + diff["disabled"] = *req.Disabled + } + if req.Name != nil && *req.Name != "" { + set["name"] = *req.Name + diff["name"] = *req.Name + } + if req.ForceReset != nil && *req.ForceReset { + if target.Auth.Local == nil { + writeError(w, http.StatusBadRequest, "no_local_auth", "user has no local password to reset") + return + } + set["auth.local.mustChange"] = true + diff["forceReset"] = true + } + if len(set) == 0 { + writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields") + return + } + + if err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), + id, target.Version, bson.M{"$set": set}); err != nil { + if errors.Is(err, store.ErrVersionConflict) { + writeError(w, http.StatusConflict, "conflict", "user was modified concurrently; retry") + return + } + s.internalError(w, r, "patch user", err) + return + } + // Disabling revokes all sessions immediately. + if req.Disabled != nil && *req.Disabled { + if _, err := s.store.DeleteUserSessions(r.Context(), id); err != nil { + s.log.Warn("revoke sessions of disabled user", "err", err) + } + } + s.audit(r, "user.update", "user", id, diff) + + fresh, err := s.store.UserByID(r.Context(), id) + if err != nil { + s.internalError(w, r, "reload user", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": fresh}) +} + +func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == CurrentUser(r.Context()).ID { + writeError(w, http.StatusBadRequest, "self_delete", "you cannot delete your own account") + return + } + res, err := s.store.DB.Collection("users").DeleteOne(r.Context(), bson.M{"_id": id}) + if err != nil { + s.internalError(w, r, "delete user", err) + return + } + if res.DeletedCount == 0 { + writeError(w, http.StatusNotFound, "not_found", "user not found") + return + } + if _, err := s.store.DeleteUserSessions(r.Context(), id); err != nil { + s.log.Warn("revoke sessions of deleted user", "err", err) + } + s.audit(r, "user.delete", "user", id, nil) + w.WriteHeader(http.StatusNoContent) +} + +// regexEscape neutralizes regex metacharacters in user-supplied search text. +func regexEscape(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + switch r { + case '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '^', '$', '|', '\\': + out = append(out, '\\') + } + out = append(out, r) + } + return string(out) +} diff --git a/internal/http/providers.go b/internal/http/providers.go new file mode 100644 index 0000000..0e68dfc --- /dev/null +++ b/internal/http/providers.go @@ -0,0 +1,55 @@ +package httpx + +import "context" + +// Late-bound providers: these are wired by later subsystems (atomizer +// client, work-performer client, sync manager, job runner) after the server +// is constructed. All accessors are nil-safe so the admin status panel +// degrades gracefully while subsystems are absent (e.g. in tests). + +// BreakerInfo exposes a circuit breaker's state for the status panel. +type BreakerInfo interface { + BreakerState() string // closed | open | half-open +} + +// StatusProvider returns arbitrary JSON-marshalable status payloads. +type StatusProvider interface { + Statuses() any +} + +func (s *Server) SetAtomizerInfo(b BreakerInfo) { s.atomizer = b } +func (s *Server) SetPerformerInfo(b BreakerInfo) { s.performer = b } + +// SetSyncTrigger wires the sync manager's "sync now" entry point. +func (s *Server) SetSyncTrigger(f func(customerID string)) { s.syncTrigger = f } +func (s *Server) SetSyncStatusProvider(p StatusProvider) { + s.syncProvider = p +} +func (s *Server) SetJobsStatusProvider(p StatusProvider) { + s.jobsProvider = p +} + +func (s *Server) syncStatuses() any { + if s.syncProvider == nil { + return []any{} + } + return s.syncProvider.Statuses() +} + +func (s *Server) jobStatuses() any { + if s.jobsProvider == nil { + return map[string]any{} + } + return s.jobsProvider.Statuses() +} + +// effectiveAtomizerBaseURL honors the admin settings override (§3) over the +// env default. +func (s *Server) effectiveAtomizerBaseURL(ctx context.Context) string { + if s.store != nil { + if st, err := s.store.GetSettings(ctx); err == nil && st.AtomizerBaseURLOverride != "" { + return st.AtomizerBaseURLOverride + } + } + return s.cfg.AtomizerBaseURL +} diff --git a/internal/http/server.go b/internal/http/server.go index faa64f5..8c29e92 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -31,6 +31,13 @@ type Server struct { checks []ReadinessCheck startedAt time.Time httpSrv *http.Server + + // late-bound subsystem hooks (see providers.go) + atomizer BreakerInfo + performer BreakerInfo + syncProvider StatusProvider + jobsProvider StatusProvider + syncTrigger func(customerID string) } func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server { @@ -58,6 +65,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store. mux.HandleFunc("GET /metricsz", s.handleMetricsz) s.routesAuth(mux) s.routesProfile(mux) + s.routesAdmin(mux) s.routesWeb(mux) s.httpSrv = &http.Server{ diff --git a/internal/http/web.go b/internal/http/web.go index b1ac78b..bd5bc3f 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -170,6 +170,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) { }) })) + mux.HandleFunc("GET /admin", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { + if !u.Roles.Admin { + s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u}) + return + } + s.render(w, r, "admin.html", &pageData{ + Title: "Administration", Active: "admin", User: u, + Scripts: []string{"/static/js/admin.js"}, + }) + })) + // Placeholders for areas built in later phases; replaced as they land. placeholders := map[string]string{ "/board": "Bounty Board", @@ -177,7 +188,6 @@ func (s *Server) routesWeb(mux *http.ServeMux) { "/consultant/board": "Atomization Board", "/consultant/reviews": "Review Queue", "/consultant/pool": "Developer Pool", - "/admin": "Administration", "/messages": "Messages", "/metrics": "Metrics", } diff --git a/internal/store/audit.go b/internal/store/audit.go new file mode 100644 index 0000000..14286e9 --- /dev/null +++ b/internal/store/audit.go @@ -0,0 +1,63 @@ +package store + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "bountyboard/internal/ulid" +) + +// AuditEntry records one privileged mutation (§4.8). +type AuditEntry struct { + ID string `bson:"_id" json:"id"` + At time.Time `bson:"at" json:"at"` + ActorID string `bson:"actorId" json:"actorId"` + ActorIP string `bson:"actorIp" json:"actorIp"` + Action string `bson:"action" json:"action"` + Entity string `bson:"entity" json:"entity"` + EntityID string `bson:"entityId" json:"entityId"` + Diff map[string]any `bson:"diff,omitempty" json:"diff,omitempty"` +} + +// Audit appends to the audit log; failures are returned for logging but must +// never abort the mutation they describe. +func (s *Store) Audit(ctx context.Context, e AuditEntry) error { + e.ID = ulid.New() + e.At = time.Now().UTC() + if _, err := s.DB.Collection("auditLog").InsertOne(ctx, e); err != nil { + return fmt.Errorf("insert audit entry: %w", err) + } + return nil +} + +// ListAudit returns newest-first entries with optional filters and ULID +// cursor pagination. +func (s *Store) ListAudit(ctx context.Context, actorID, entityID, cursor string, limit int) ([]AuditEntry, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + filter := bson.M{} + if actorID != "" { + filter["actorId"] = actorID + } + if entityID != "" { + filter["entityId"] = entityID + } + if cursor != "" { + filter["_id"] = bson.M{"$lt": cursor} + } + cur, err := s.DB.Collection("auditLog").Find(ctx, filter, + options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit))) + if err != nil { + return nil, fmt.Errorf("list audit: %w", err) + } + out := []AuditEntry{} + if err := cur.All(ctx, &out); err != nil { + return nil, fmt.Errorf("decode audit: %w", err) + } + return out, nil +} diff --git a/internal/store/customers.go b/internal/store/customers.go new file mode 100644 index 0000000..afae851 --- /dev/null +++ b/internal/store/customers.go @@ -0,0 +1,106 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "bountyboard/internal/domain" + "bountyboard/internal/ulid" +) + +func (s *Store) customers() *mongo.Collection { return s.DB.Collection("customers") } + +func (s *Store) CreateCustomer(ctx context.Context, c *domain.Customer) error { + now := time.Now().UTC() + if c.ID == "" { + c.ID = ulid.New() + } + c.CreatedAt, c.UpdatedAt, c.Version = now, now, 1 + if c.ConsultantIDs == nil { + c.ConsultantIDs = []string{} + } + if _, err := s.customers().InsertOne(ctx, c); err != nil { + if mongo.IsDuplicateKeyError(err) { + return fmt.Errorf("customer %q: %w", c.Name, ErrDuplicate) + } + return fmt.Errorf("insert customer: %w", err) + } + return nil +} + +func (s *Store) CustomerByID(ctx context.Context, id string) (*domain.Customer, error) { + var c domain.Customer + err := s.customers().FindOne(ctx, bson.M{"_id": id}).Decode(&c) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("find customer: %w", err) + } + return &c, nil +} + +// ListCustomers returns customers, optionally restricted to a consultant and +// optionally including archived ones, sorted by name. +func (s *Store) ListCustomers(ctx context.Context, consultantID string, includeArchived bool) ([]domain.Customer, error) { + filter := bson.M{} + if !includeArchived { + filter["archived"] = false + } + if consultantID != "" { + filter["consultantIds"] = consultantID + } + cur, err := s.customers().Find(ctx, filter) + if err != nil { + return nil, fmt.Errorf("list customers: %w", err) + } + out := []domain.Customer{} + if err := cur.All(ctx, &out); err != nil { + return nil, fmt.Errorf("decode customers: %w", err) + } + return out, nil +} + +// UpdateCustomer applies a version-checked partial update. +func (s *Store) UpdateCustomer(ctx context.Context, id string, version int64, set bson.M) error { + return UpdateVersioned(ctx, s.customers(), id, version, bson.M{"$set": set}) +} + +func (s *Store) DeleteCustomer(ctx context.Context, id string) error { + res, err := s.customers().DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return fmt.Errorf("delete customer: %w", err) + } + if res.DeletedCount == 0 { + return ErrNotFound + } + return nil +} + +// SetSyncStatus records the outcome of a sync run (not version-checked: +// background bookkeeping must not race with admin edits). +func (s *Store) SetSyncStatus(ctx context.Context, id string, at time.Time, status, errMsg string) error { + _, err := s.customers().UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{ + "ticketing.lastSyncAt": at, + "ticketing.lastSyncStatus": status, + "ticketing.lastSyncError": errMsg, + }}) + if err != nil { + return fmt.Errorf("set sync status: %w", err) + } + return nil +} + +// CountTasksForCustomer guards customer deletion. +func (s *Store) CountTasksForCustomer(ctx context.Context, customerID string) (int64, error) { + n, err := s.DB.Collection("tasks").CountDocuments(ctx, bson.M{"customerId": customerID}) + if err != nil { + return 0, fmt.Errorf("count tasks: %w", err) + } + return n, nil +} diff --git a/internal/store/settings.go b/internal/store/settings.go new file mode 100644 index 0000000..497343e --- /dev/null +++ b/internal/store/settings.go @@ -0,0 +1,54 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "bountyboard/internal/domain" +) + +// GetSettings returns the app settings document, creating defaults on first +// access. +func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { + var st domain.AppSettings + err := s.DB.Collection("settings").FindOne(ctx, bson.M{"_id": "app"}).Decode(&st) + if errors.Is(err, mongo.ErrNoDocuments) { + st = domain.AppSettings{ + ID: "app", + BrandingName: "Bounty Board", + DefaultPollIntervalSec: 60, + DefaultCustomerBudget: 1000, + UpdatedAt: time.Now().UTC(), + Version: 1, + } + if _, err := s.DB.Collection("settings").InsertOne(ctx, st); err != nil && + !mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("init settings: %w", err) + } + return &st, nil + } + if err != nil { + return nil, fmt.Errorf("get settings: %w", err) + } + return &st, nil +} + +// PatchSettings applies a partial settings update (not version-raced in the +// UI; last write wins is acceptable for a single admin page). +func (s *Store) PatchSettings(ctx context.Context, set bson.M) (*domain.AppSettings, error) { + if _, err := s.GetSettings(ctx); err != nil { // ensure the doc exists + return nil, err + } + set["updatedAt"] = time.Now().UTC() + _, err := s.DB.Collection("settings").UpdateOne(ctx, bson.M{"_id": "app"}, + bson.M{"$set": set, "$inc": bson.M{"version": 1}}) + if err != nil { + return nil, fmt.Errorf("patch settings: %w", err) + } + return s.GetSettings(ctx) +} diff --git a/internal/sync/azure.go b/internal/sync/azure.go new file mode 100644 index 0000000..7c7defe --- /dev/null +++ b/internal/sync/azure.go @@ -0,0 +1,166 @@ +package sync + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" +) + +// azureConnector talks to Azure DevOps REST 7.1 with PAT basic auth. +type azureConnector struct { + baseURL string // https://dev.azure.com unless self-hosted + organization string + project string + pat string +} + +func (a *azureConnector) Authorize(req *http.Request) { + req.SetBasicAuth("", a.pat) +} + +func (a *azureConnector) orgURL() string { + return strings.TrimRight(a.baseURL, "/") + "/" + a.organization +} + +func (a *azureConnector) TestConnection(ctx context.Context) error { + var out struct { + Count int `json:"count"` + } + if err := getJSON(ctx, a.Authorize, a.orgURL()+"/_apis/projects?api-version=7.1", &out); err != nil { + return fmt.Errorf("azure devops /_apis/projects: %w", err) + } + return nil +} + +func (a *azureConnector) wiql(ctx context.Context, query string) ([]int, error) { + body, _ := json.Marshal(map[string]string{"query": query}) + var out struct { + WorkItems []struct { + ID int `json:"id"` + } `json:"workItems"` + } + u := fmt.Sprintf("%s/%s/_apis/wit/wiql?api-version=7.1", a.orgURL(), a.project) + if err := doJSON(ctx, a.Authorize, http.MethodPost, u, body, &out); err != nil { + return nil, fmt.Errorf("azure wiql: %w", err) + } + ids := make([]int, len(out.WorkItems)) + for i, wi := range out.WorkItems { + ids[i] = wi.ID + } + return ids, nil +} + +type azureWorkItem struct { + ID int `json:"id"` + Fields map[string]any `json:"fields"` + Links struct { + HTML struct { + Href string `json:"href"` + } `json:"html"` + } `json:"_links"` + Relations []struct { + Rel string `json:"rel"` + URL string `json:"url"` + Attributes struct { + Name string `json:"name"` + } `json:"attributes"` + } `json:"relations"` +} + +func (a *azureConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) { + q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s' AND [System.ChangedDate] >= '%s'`, + identity, a.project, since.UTC().Format("2006-01-02T15:04:05Z")) + ids, err := a.wiql(ctx, q) + if err != nil || len(ids) == 0 { + return nil, err + } + + var out []Ticket + for start := 0; start < len(ids); start += 200 { + end := min(start+200, len(ids)) + idStrs := make([]string, 0, end-start) + for _, id := range ids[start:end] { + idStrs = append(idStrs, fmt.Sprint(id)) + } + var batch struct { + Value []azureWorkItem `json:"value"` + } + u := fmt.Sprintf("%s/%s/_apis/wit/workitems?ids=%s&$expand=all&api-version=7.1", + a.orgURL(), a.project, strings.Join(idStrs, ",")) + if err := getJSON(ctx, a.Authorize, u, &batch); err != nil { + return nil, fmt.Errorf("azure workitems batch: %w", err) + } + for _, wi := range batch.Value { + out = append(out, a.toTicket(wi)) + } + } + return out, nil +} + +func (a *azureConnector) toTicket(wi azureWorkItem) Ticket { + title, _ := wi.Fields["System.Title"].(string) + descHTML, _ := wi.Fields["System.Description"].(string) + witype, _ := wi.Fields["System.WorkItemType"].(string) + url := wi.Links.HTML.Href + if url == "" { + url = fmt.Sprintf("%s/%s/_workitems/edit/%d", a.orgURL(), a.project, wi.ID) + } + t := Ticket{ + Key: fmt.Sprintf("%s-%d", strings.ToUpper(a.project), wi.ID), + URL: url, + Type: azureType(witype), + Title: title, + Description: stripHTML(descHTML), + Raw: map[string]any{"id": wi.ID, "workItemType": witype}, + } + for _, rel := range wi.Relations { + if rel.Rel == "AttachedFile" { + t.Attachments = append(t.Attachments, TicketAttachment{Name: rel.Attributes.Name, URL: rel.URL}) + } + } + return t +} + +func (a *azureConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) { + q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s'`, + identity, a.project) + ids, err := a.wiql(ctx, q) + if err != nil { + return nil, err + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("%s-%d", strings.ToUpper(a.project), id) + } + return keys, nil +} + +func azureType(name string) string { + switch strings.ToLower(name) { + case "epic": + return "epic" + case "user story", "product backlog item": + return "story" + default: + return "task" + } +} + +var tagRe = regexp.MustCompile(`<[^>]*>`) + +// stripHTML is a lossy HTML→text flattener for ADO descriptions. +func stripHTML(s string) string { + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "

", "\n") + s = tagRe.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, " ", " ") + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return strings.TrimSpace(s) +} diff --git a/internal/sync/connector.go b/internal/sync/connector.go new file mode 100644 index 0000000..a886217 --- /dev/null +++ b/internal/sync/connector.go @@ -0,0 +1,135 @@ +// Package sync imports tickets from customer ticketing systems by polling +// (§5.3) and exposes per-system connectors used for both syncing and the +// admin "Test connection" button. +package sync + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "bountyboard/internal/domain" +) + +// Ticket is the system-agnostic shape a connector returns. +type Ticket struct { + Key string + URL string + Type string // epic | story | task + Title string + Description string + Attachments []TicketAttachment + Raw map[string]any +} + +type TicketAttachment struct { + Name string + URL string + MimeType string +} + +// Connector talks to one customer's ticketing system. +type Connector interface { + // TestConnection performs a cheap authenticated call (§5.3). + TestConnection(ctx context.Context) error + // FetchUpdated returns tickets assigned to identity updated since the + // watermark (callers pass lastSyncAt − 5 min for the overlap window). + FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) + // ListAssignedKeys returns every ticket key currently assigned to + // identity — used to flag orphaned tasks. + ListAssignedKeys(ctx context.Context, identity string) ([]string, error) + // Authorize adds the system's auth headers to an attachment download. + Authorize(req *http.Request) +} + +// Credentials is the decrypted per-system credential map (§4.2 shapes). +type Credentials map[string]string + +// NewConnector builds the connector for a ticketing config + decrypted +// credentials. +func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Credentials) (Connector, error) { + switch t { + case domain.TicketingJira: + if creds["email"] == "" || creds["apiToken"] == "" { + return nil, fmt.Errorf("jira requires email and apiToken") + } + return &jiraConnector{baseURL: baseURL, projectKey: projectKey, email: creds["email"], apiToken: creds["apiToken"]}, nil + case domain.TicketingAzure: + if creds["organization"] == "" || creds["project"] == "" || creds["pat"] == "" { + return nil, fmt.Errorf("azure_devops requires organization, project and pat") + } + if baseURL == "" { + baseURL = "https://dev.azure.com" + } + return &azureConnector{baseURL: baseURL, organization: creds["organization"], project: creds["project"], pat: creds["pat"]}, nil + case domain.TicketingYouTrack: + if creds["permanentToken"] == "" { + return nil, fmt.Errorf("youtrack requires permanentToken") + } + return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil + case domain.TicketingDemo: + return &demoConnector{projectKey: projectKey}, nil + default: + return nil, fmt.Errorf("unknown ticketing type %q", t) + } +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +// getJSON performs an authorized GET and decodes JSON, with one retry on +// 5xx/network errors (§12). +func getJSON(ctx context.Context, authorize func(*http.Request), url string, dst any) error { + return doJSON(ctx, authorize, http.MethodGet, url, nil, dst) +} + +func doJSON(ctx context.Context, authorize func(*http.Request), method, url string, body []byte, dst any) error { + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader(body)) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + authorize(req) + resp, err := httpClient.Do(req) + if err != nil { + lastErr = err + continue + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20)) + resp.Body.Close() + if err != nil { + lastErr = err + continue + } + if resp.StatusCode >= 500 { + lastErr = fmt.Errorf("%s %s: status %d", method, url, resp.StatusCode) + continue + } + if resp.StatusCode >= 400 { + return fmt.Errorf("%s %s: status %d: %.200s", method, url, resp.StatusCode, data) + } + if dst == nil { + return nil + } + if err := json.Unmarshal(data, dst); err != nil { + return fmt.Errorf("decode %s: %w", url, err) + } + return nil + } + return lastErr +} + +func bodyReader(b []byte) io.Reader { + if b == nil { + return nil + } + return bytes.NewReader(b) +} diff --git a/internal/sync/demo.go b/internal/sync/demo.go new file mode 100644 index 0000000..4666c56 --- /dev/null +++ b/internal/sync/demo.go @@ -0,0 +1,75 @@ +package sync + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" +) + +// demoConnector fabricates tickets locally (§11.11) so the entire +// import→atomize→publish→claim flow is demonstrable offline. Deterministic: +// the same project key always yields the same tickets. A project key ending +// in "-ORPHAN" omits the last ticket from ListAssignedKeys to exercise the +// orphan flow. +type demoConnector struct { + projectKey string +} + +var demoTickets = []struct { + suffix int + typ string + title string + description string +}{ + {1, "epic", "Customer self-service portal", + "Build a portal where customers can view invoices, update billing details, and download usage reports. Must integrate with the existing SSO and billing systems."}, + {2, "story", "User import from CSV", + "As an admin I can bulk-import users from a CSV file with column mapping, validation preview, and an idempotent re-run option. Errors are reported per row."}, + {3, "story", "Email notification preferences", + "As a user I can choose which events trigger email notifications (digest, immediate, off) and unsubscribe via a one-click link that does not require login."}, + {4, "task", "Rate-limit public API endpoints", + "Add per-token rate limiting to all public API endpoints with sensible defaults, 429 responses with Retry-After, and metrics for limit hits."}, + {5, "task", "Fix timezone handling in reports", + "Scheduled reports render dates in server time instead of the workspace timezone. Audit all report templates and normalize via the workspace setting."}, +} + +func (d *demoConnector) Authorize(*http.Request) {} + +func (d *demoConnector) TestConnection(context.Context) error { return nil } + +func (d *demoConnector) key(i int) string { + base := strings.TrimSuffix(strings.ToUpper(d.projectKey), "-ORPHAN") + if base == "" { + base = "DEMO" + } + return fmt.Sprintf("%s-%d", base, i) +} + +func (d *demoConnector) FetchUpdated(_ context.Context, _ string, _ time.Time) ([]Ticket, error) { + out := make([]Ticket, 0, len(demoTickets)) + for _, dt := range demoTickets { + out = append(out, Ticket{ + Key: d.key(dt.suffix), + URL: "https://demo.invalid/browse/" + d.key(dt.suffix), + Type: dt.typ, + Title: dt.title, + Description: dt.description, + Raw: map[string]any{"demo": true, "suffix": dt.suffix}, + }) + } + return out, nil +} + +func (d *demoConnector) ListAssignedKeys(context.Context, string) ([]string, error) { + n := len(demoTickets) + if strings.HasSuffix(strings.ToUpper(d.projectKey), "-ORPHAN") { + n-- // last ticket "disappears" upstream → orphan flow + } + keys := make([]string, 0, n) + for _, dt := range demoTickets[:n] { + keys = append(keys, d.key(dt.suffix)) + } + return keys, nil +} diff --git a/internal/sync/jira.go b/internal/sync/jira.go new file mode 100644 index 0000000..5f63243 --- /dev/null +++ b/internal/sync/jira.go @@ -0,0 +1,144 @@ +package sync + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// jiraConnector talks to Jira Cloud REST v3 with email+API-token basic auth. +type jiraConnector struct { + baseURL string + projectKey string + email string + apiToken string +} + +func (j *jiraConnector) Authorize(req *http.Request) { + req.SetBasicAuth(j.email, j.apiToken) +} + +func (j *jiraConnector) TestConnection(ctx context.Context) error { + var me struct { + AccountID string `json:"accountId"` + } + if err := getJSON(ctx, j.Authorize, j.baseURL+"/rest/api/3/myself", &me); err != nil { + return fmt.Errorf("jira /myself: %w", err) + } + return nil +} + +type jiraIssue struct { + Key string `json:"key"` + Fields struct { + Summary string `json:"summary"` + Description map[string]any `json:"description"` // ADF document + IssueType struct { + Name string `json:"name"` + } `json:"issuetype"` + Attachment []struct { + Filename string `json:"filename"` + Content string `json:"content"` + MimeType string `json:"mimeType"` + } `json:"attachment"` + } `json:"fields"` +} + +func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) { + var all []jiraIssue + for startAt := 0; ; { + var page struct { + Issues []jiraIssue `json:"issues"` + Total int `json:"total"` + } + u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d", + j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt) + if err := getJSON(ctx, j.Authorize, u, &page); err != nil { + return nil, fmt.Errorf("jira search: %w", err) + } + all = append(all, page.Issues...) + startAt += len(page.Issues) + if len(page.Issues) == 0 || startAt >= page.Total { + return all, nil + } + } +} + +func (j *jiraConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) { + jql := fmt.Sprintf(`assignee = %q AND project = %q AND updated >= %q ORDER BY updated ASC`, + identity, j.projectKey, since.UTC().Format("2006/01/02 15:04")) + issues, err := j.search(ctx, jql, "summary,description,issuetype,attachment") + if err != nil { + return nil, err + } + out := make([]Ticket, 0, len(issues)) + for _, is := range issues { + t := Ticket{ + Key: is.Key, + URL: j.baseURL + "/browse/" + is.Key, + Type: jiraType(is.Fields.IssueType.Name), + Title: is.Fields.Summary, + Description: adfToText(is.Fields.Description), + Raw: map[string]any{"key": is.Key, "issueType": is.Fields.IssueType.Name}, + } + for _, a := range is.Fields.Attachment { + t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Filename, URL: a.Content, MimeType: a.MimeType}) + } + out = append(out, t) + } + return out, nil +} + +func (j *jiraConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) { + jql := fmt.Sprintf(`assignee = %q AND project = %q`, identity, j.projectKey) + issues, err := j.search(ctx, jql, "key") + if err != nil { + return nil, err + } + keys := make([]string, len(issues)) + for i, is := range issues { + keys[i] = is.Key + } + return keys, nil +} + +func jiraType(name string) string { + switch strings.ToLower(name) { + case "epic": + return "epic" + case "story", "user story": + return "story" + default: + return "task" + } +} + +// adfToText flattens an Atlassian Document Format tree into plain text — +// good enough for atomization input. +func adfToText(node map[string]any) string { + if node == nil { + return "" + } + var sb strings.Builder + var walk func(n map[string]any) + walk = func(n map[string]any) { + if text, ok := n["text"].(string); ok { + sb.WriteString(text) + } + if t, _ := n["type"].(string); t == "paragraph" || t == "heading" || t == "listItem" { + defer sb.WriteString("\n") + } + if content, ok := n["content"].([]any); ok { + for _, child := range content { + if m, ok := child.(map[string]any); ok { + walk(m) + } + } + } + } + walk(node) + return strings.TrimSpace(sb.String()) +} diff --git a/internal/sync/youtrack.go b/internal/sync/youtrack.go new file mode 100644 index 0000000..150a3dd --- /dev/null +++ b/internal/sync/youtrack.go @@ -0,0 +1,131 @@ +package sync + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// youtrackConnector talks to YouTrack's REST API with a permanent token. +type youtrackConnector struct { + baseURL string + projectKey string + token string +} + +func (y *youtrackConnector) Authorize(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+y.token) +} + +func (y *youtrackConnector) TestConnection(ctx context.Context) error { + var me struct { + Login string `json:"login"` + } + if err := getJSON(ctx, y.Authorize, y.baseURL+"/api/users/me?fields=login", &me); err != nil { + return fmt.Errorf("youtrack /api/users/me: %w", err) + } + return nil +} + +type ytIssue struct { + IDReadable string `json:"idReadable"` + Summary string `json:"summary"` + Description string `json:"description"` + Attachments []struct { + Name string `json:"name"` + URL string `json:"url"` + MimeType string `json:"mimeType"` + } `json:"attachments"` + CustomFields []struct { + Name string `json:"name"` + Value any `json:"value"` + } `json:"customFields"` +} + +func (y *youtrackConnector) query(ctx context.Context, q, fields string) ([]ytIssue, error) { + var all []ytIssue + for skip := 0; ; skip += 100 { + var page []ytIssue + u := fmt.Sprintf("%s/api/issues?query=%s&fields=%s&$top=100&$skip=%d", + y.baseURL, url.QueryEscape(q), url.QueryEscape(fields), skip) + if err := getJSON(ctx, y.Authorize, u, &page); err != nil { + return nil, fmt.Errorf("youtrack issues: %w", err) + } + all = append(all, page...) + if len(page) < 100 { + return all, nil + } + } +} + +const ytFields = "idReadable,summary,description,attachments(name,url,mimeType),customFields(name,value(name))" + +func (y *youtrackConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) { + q := fmt.Sprintf("assignee: %s project: %s updated: %s .. *", + identity, y.projectKey, since.UTC().Format("2006-01-02T15:04")) + issues, err := y.query(ctx, q, ytFields) + if err != nil { + return nil, err + } + out := make([]Ticket, 0, len(issues)) + for _, is := range issues { + out = append(out, y.toTicket(is)) + } + return out, nil +} + +func (y *youtrackConnector) toTicket(is ytIssue) Ticket { + issueType := "task" + for _, cf := range is.CustomFields { + if cf.Name == "Type" { + if v, ok := cf.Value.(map[string]any); ok { + if name, ok := v["name"].(string); ok { + issueType = ytType(name) + } + } + } + } + t := Ticket{ + Key: is.IDReadable, + URL: y.baseURL + "/issue/" + is.IDReadable, + Type: issueType, + Title: is.Summary, + Description: is.Description, + Raw: map[string]any{"idReadable": is.IDReadable}, + } + for _, a := range is.Attachments { + u := a.URL + if strings.HasPrefix(u, "/") { + u = y.baseURL + u + } + t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Name, URL: u, MimeType: a.MimeType}) + } + return t +} + +func (y *youtrackConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) { + q := fmt.Sprintf("assignee: %s project: %s", identity, y.projectKey) + issues, err := y.query(ctx, q, "idReadable") + if err != nil { + return nil, err + } + keys := make([]string, len(issues)) + for i, is := range issues { + keys[i] = is.IDReadable + } + return keys, nil +} + +func ytType(name string) string { + switch strings.ToLower(name) { + case "epic": + return "epic" + case "story", "user story", "feature": + return "story" + default: + return "task" + } +} diff --git a/web/static/css/app.css b/web/static/css/app.css index df99502..18c786c 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -155,6 +155,21 @@ table.list th { color: var(--muted); font-size: 0.875rem; } .toast.err { border-left-color: var(--err); } .toast.ok { border-left-color: var(--ok); } +.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } + +dialog { + background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 24px; max-width: 90vw; max-height: 90vh; overflow: auto; +} +dialog::backdrop { background: rgba(0,0,0,0.4); } +fieldset { + border: 1px solid var(--border); border-radius: var(--radius); + margin: 0 0 16px; padding: 16px; +} +legend { font-weight: 600; padding: 0 8px; } + @media (max-width: 640px) { .row { flex-direction: column; gap: 0; } .topnav { flex-wrap: wrap; } diff --git a/web/static/js/admin.js b/web/static/js/admin.js new file mode 100644 index 0000000..37e839e --- /dev/null +++ b/web/static/js/admin.js @@ -0,0 +1,363 @@ +import { api, toast } from '/static/js/api.js'; + +const errorBox = document.getElementById('error'); +const showErr = (e) => { errorBox.textContent = e.message || String(e); }; + +// ---- tabs ---- +const tabs = document.querySelectorAll('[role=tab]'); +tabs.forEach((btn) => btn.addEventListener('click', () => { + tabs.forEach((b) => b.setAttribute('aria-selected', b === btn ? 'true' : 'false')); + document.querySelectorAll('[role=tabpanel]').forEach((p) => { p.hidden = true; }); + document.getElementById('tab-' + btn.dataset.tab).hidden = false; + loaders[btn.dataset.tab](); +})); + +// ---- customers ---- +const dlg = document.getElementById('customer-dialog'); +let editingCustomer = null; // null = creating +let consultants = []; + +function credsFromForm(type) { + switch (type) { + case 'jira': + return { email: val('c-jira-email'), apiToken: val('c-jira-token') }; + case 'azure_devops': + return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') }; + case 'youtrack': + return { permanentToken: val('c-yt-token') }; + default: + return {}; + } +} +const val = (id) => document.getElementById(id).value.trim(); + +function credsEntered(type) { + const c = credsFromForm(type); + return Object.values(c).some(Boolean); +} + +document.getElementById('c-type').addEventListener('change', (e) => { + document.querySelectorAll('.creds').forEach((f) => { f.hidden = true; }); + document.getElementById('creds-' + e.target.value).hidden = false; + document.getElementById('f-baseurl').style.display = + e.target.value === 'demo' ? 'none' : ''; +}); + +async function loadConsultants() { + const res = await api('GET', '/api/v1/admin/users?role=consultant&limit=200'); + consultants = res.users; + const sel = document.getElementById('c-consultants'); + sel.replaceChildren(...consultants.map((u) => { + const o = document.createElement('option'); + o.value = u.id; + o.textContent = `${u.name} <${u.email}>`; + return o; + })); +} + +function openCustomerDialog(customer) { + editingCustomer = customer || null; + document.getElementById('customer-dialog-title').textContent = + customer ? `Edit ${customer.name}` : 'New customer'; + document.getElementById('c-name').value = customer ? customer.name : ''; + document.getElementById('c-type').value = customer ? customer.ticketing.type : 'jira'; + document.getElementById('c-project').value = customer ? customer.ticketing.projectKey : ''; + document.getElementById('c-baseurl').value = customer ? customer.ticketing.baseUrl : ''; + document.getElementById('c-poll').value = customer ? customer.ticketing.pollIntervalSec : 60; + document.getElementById('c-budget').value = customer ? customer.defaultBudget : 1000; + document.querySelectorAll('.creds input').forEach((i) => { i.value = ''; }); + document.getElementById('c-type').dispatchEvent(new Event('change')); + const sel = document.getElementById('c-consultants'); + [...sel.options].forEach((o) => { + o.selected = customer ? customer.consultantIds.includes(o.value) : false; + }); + document.getElementById('c-test-result').textContent = + customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : ''; + dlg.showModal(); +} + +document.getElementById('customer-new').addEventListener('click', async () => { + try { await loadConsultants(); openCustomerDialog(null); } catch (e) { showErr(e); } +}); +document.getElementById('c-cancel').addEventListener('click', () => dlg.close()); + +document.getElementById('c-test').addEventListener('click', async () => { + const out = document.getElementById('c-test-result'); + out.textContent = 'Testing…'; + const type = val('c-type'); + try { + let res; + if (editingCustomer && !credsEntered(type)) { + res = await api('POST', `/api/v1/admin/customers/${editingCustomer.id}/test-connection`, {}); + } else { + res = await api('POST', '/api/v1/admin/customers/test-connection', { + type, baseUrl: val('c-baseurl'), projectKey: val('c-project'), + credentials: credsFromForm(type), + }); + } + out.textContent = res.ok ? `✓ Connected (${res.latencyMs} ms)` : `✗ ${res.error}`; + out.style.color = res.ok ? 'var(--ok)' : 'var(--err)'; + } catch (e) { out.textContent = '✗ ' + e.message; out.style.color = 'var(--err)'; } +}); + +document.getElementById('customer-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const type = val('c-type'); + const body = { + name: val('c-name'), + type, + baseUrl: val('c-baseurl'), + projectKey: val('c-project'), + pollIntervalSec: parseInt(val('c-poll'), 10) || 60, + defaultBudget: parseFloat(val('c-budget')) || 0, + consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value), + }; + if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type); + try { + if (editingCustomer) { + body.version = editingCustomer.version; + await api('PATCH', `/api/v1/admin/customers/${editingCustomer.id}`, body); + } else { + await api('POST', '/api/v1/admin/customers', body); + } + dlg.close(); + toast('Customer saved.', 'ok'); + loadCustomers(); + } catch (err) { + if (err.status === 409) toast('Conflict: ' + err.message, 'err'); + else showErr(err); + } +}); + +async function loadCustomers() { + try { + const res = await api('GET', '/api/v1/admin/customers?includeArchived=true'); + const host = document.getElementById('customer-list'); + host.replaceChildren(...res.customers.map(renderCustomerCard)); + if (!res.customers.length) { + host.textContent = 'No customers yet — create the first one.'; + } + } catch (e) { showErr(e); } +} + +function renderCustomerCard(c) { + const card = document.createElement('div'); + card.className = 'card'; + const syncBadge = c.ticketing.lastSyncStatus === 'ok' + ? `sync ok` + : c.ticketing.lastSyncStatus === 'error' + ? `sync error` + : 'never synced'; + card.innerHTML = ` +
+

${esc(c.name)} ${c.archived ? 'archived' : ''}

+ ${syncBadge} +
+

${esc(c.ticketing.type)} · project ${esc(c.ticketing.projectKey || '—')} · + poll ${c.ticketing.pollIntervalSec}s · budget ${c.defaultBudget} · + ${c.consultantIds.length} consultant(s) + ${c.ticketing.lastSyncAt && !c.ticketing.lastSyncAt.startsWith('0001') ? ' · last sync ' + new Date(c.ticketing.lastSyncAt).toLocaleString() : ''}

+
+ + + + +
`; + card.querySelector('[data-act=edit]').addEventListener('click', async () => { + try { await loadConsultants(); openCustomerDialog(c); } catch (e) { showErr(e); } + }); + card.querySelector('[data-act=sync]').addEventListener('click', async () => { + try { await api('POST', `/api/v1/admin/customers/${c.id}/sync-now`, {}); toast('Sync queued.', 'ok'); } + catch (e) { toast(e.message, 'err'); } + }); + card.querySelector('[data-act=archive]').addEventListener('click', async () => { + try { + await api('PATCH', `/api/v1/admin/customers/${c.id}`, { archived: !c.archived, version: c.version }); + loadCustomers(); + } catch (e) { toast(e.message, 'err'); } + }); + card.querySelector('[data-act=delete]').addEventListener('click', async () => { + if (!confirm(`Delete customer "${c.name}"? This cannot be undone.`)) return; + try { await api('DELETE', `/api/v1/admin/customers/${c.id}`); loadCustomers(); } + catch (e) { toast(e.message, 'err'); } + }); + return card; +} + +// ---- users ---- +let userSearchTimer; +document.getElementById('user-search').addEventListener('input', () => { + clearTimeout(userSearchTimer); + userSearchTimer = setTimeout(loadUsers, 250); +}); + +async function loadUsers() { + try { + const q = encodeURIComponent(document.getElementById('user-search').value.trim()); + const res = await api('GET', `/api/v1/admin/users?q=${q}&limit=200`); + const tbody = document.querySelector('#user-table tbody'); + tbody.replaceChildren(...res.users.map(renderUserRow)); + } catch (e) { showErr(e); } +} + +function renderUserRow(u) { + const tr = document.createElement('tr'); + const roleCell = (role) => { + const td = document.createElement('td'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = u.roles[role]; + cb.setAttribute('aria-label', `${role} role for ${u.name}`); + cb.addEventListener('change', async () => { + const roles = { ...u.roles, [role]: cb.checked }; + try { + const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { roles }); + u = res.user; + toast(`Roles updated for ${u.name}.`, 'ok'); + } catch (e) { cb.checked = !cb.checked; toast(e.message, 'err'); } + }); + td.appendChild(cb); + return td; + }; + const name = document.createElement('td'); name.textContent = u.name; + const email = document.createElement('td'); email.textContent = u.email; + const status = document.createElement('td'); + status.textContent = u.disabled ? 'disabled' : 'active'; + const actions = document.createElement('td'); + + const disableBtn = document.createElement('button'); + disableBtn.className = 'btn small'; + disableBtn.textContent = u.disabled ? 'Enable' : 'Disable'; + disableBtn.addEventListener('click', async () => { + try { + const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { disabled: !u.disabled }); + u = res.user; status.textContent = u.disabled ? 'disabled' : 'active'; + disableBtn.textContent = u.disabled ? 'Enable' : 'Disable'; + } catch (e) { toast(e.message, 'err'); } + }); + const resetBtn = document.createElement('button'); + resetBtn.className = 'btn small'; + resetBtn.textContent = 'Force reset'; + resetBtn.addEventListener('click', async () => { + try { await api('PATCH', `/api/v1/admin/users/${u.id}`, { forceReset: true }); toast('Password reset forced.', 'ok'); } + catch (e) { toast(e.message, 'err'); } + }); + const delBtn = document.createElement('button'); + delBtn.className = 'btn small danger'; + delBtn.textContent = 'Delete'; + delBtn.addEventListener('click', async () => { + if (!confirm(`Delete user ${u.email}?`)) return; + try { await api('DELETE', `/api/v1/admin/users/${u.id}`); tr.remove(); } + catch (e) { toast(e.message, 'err'); } + }); + actions.append(disableBtn, document.createTextNode(' '), resetBtn, document.createTextNode(' '), delBtn); + tr.append(name, email, roleCell('admin'), roleCell('consultant'), roleCell('developer'), status, actions); + return tr; +} + +// ---- settings ---- +async function loadSettings() { + try { + const res = await api('GET', '/api/v1/admin/settings'); + const s = res.settings; + document.getElementById('s-branding').value = s.brandingName; + document.getElementById('s-atomizer').value = s.atomizerBaseUrlOverride; + document.getElementById('s-poll').value = s.defaultPollIntervalSec; + document.getElementById('s-budget').value = s.defaultCustomerBudget; + document.getElementById('s-hideclaims').checked = s.hideCompetingClaims; + } catch (e) { showErr(e); } +} + +document.getElementById('settings-form').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await api('PATCH', '/api/v1/admin/settings', { + brandingName: val('s-branding'), + atomizerBaseUrlOverride: val('s-atomizer'), + defaultPollIntervalSec: parseInt(val('s-poll'), 10), + defaultCustomerBudget: parseFloat(val('s-budget')), + hideCompetingClaims: document.getElementById('s-hideclaims').checked, + }); + toast('Settings saved.', 'ok'); + } catch (err) { toast(err.message, 'err'); } +}); + +// ---- status ---- +async function loadStatus() { + const host = document.getElementById('status-body'); + host.textContent = 'Loading…'; + try { + const s = await api('GET', '/api/v1/admin/service-status'); + const ext = (e) => ` +
+ ${esc(e.name)} ${esc(e.url)} + ${e.healthy + ? `healthy · ${e.latencyMs} ms` + : `down`} + breaker: ${esc(e.breaker)} +
`; + let workers = '

No sync workers running.

'; + if (Array.isArray(s.syncWorkers) && s.syncWorkers.length) { + workers = s.syncWorkers.map((wkr) => ` +
+ ${esc(wkr.customerName)} every ${wkr.pollIntervalSec}s + ${esc(wkr.lastStatus || 'pending')} +
`).join(''); + } + host.innerHTML = ` +
MongoDB + ${s.mongo.healthy ? 'healthy' + : `${esc(s.mongo.error)}`}
+ ${ext(s.atomizer)} + ${ext(s.workPerformer)} +

Sync workers

${workers} +

Job queue

+

${esc(JSON.stringify(s.jobs))}

`; + } catch (e) { host.textContent = e.message; } +} +document.getElementById('status-refresh').addEventListener('click', loadStatus); + +// ---- audit ---- +let auditCursor = ''; +async function loadAudit(reset = true) { + try { + const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim()); + if (reset) auditCursor = ''; + const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`); + auditCursor = res.nextCursor; + const rows = res.entries.map((e) => { + const tr = document.createElement('tr'); + const cells = [ + new Date(e.at).toLocaleString(), e.actorId, e.action, + `${e.entity}/${e.entityId}`, e.diff ? JSON.stringify(e.diff) : '', + ]; + cells.forEach((c) => { + const td = document.createElement('td'); + td.textContent = c; + tr.appendChild(td); + }); + return tr; + }); + const tbody = document.querySelector('#audit-table tbody'); + if (reset) tbody.replaceChildren(...rows); + else tbody.append(...rows); + } catch (e) { showErr(e); } +} +document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true)); +document.getElementById('audit-more').addEventListener('click', () => loadAudit(false)); + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +const loaders = { + customers: loadCustomers, + users: loadUsers, + settings: loadSettings, + status: loadStatus, + audit: () => loadAudit(true), +}; +loadCustomers(); diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..355f660 --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,151 @@ +{{define "content"}} +

Administration

+ + + + +
+
+

Customers

+ +
+
+ + +
+

New customer

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ Jira credentials +
+
+
+ + + +
+
+ + +
+
+ + +
+
+
+ + +

Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.

+
+
+ + + + + + +
+
+
+
+ + + + + + + + +{{end}}