Files
BountyBoard/internal/http/server.go
T
etalon 70a813edfa feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)
- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests
- developer board: pool-scoped visibility, customer/search/minBounty/sort
  filters, stale-task age badges, competing-claims visibility setting,
  saved filters in profile extras
- claims: request/withdraw (developer), approve/decline (consultant) with
  notifications to winners and losers; unassign/abandon back to board
- work tracking: start, sanitized comments with @mention notifications,
  time logging, submit for review
- review queue + review with per-AC checklist stored on the timeline;
  approve writes the immutable bountyAwards row (human assignees only)
- assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint,
  idempotent by jobId, artifacts downloaded into GridFS, failure path
  keeps the task assigned with timeline + notification
- notifications API + bell with unread badge, dropdown, page, WS toasts
- pages: bounty board, my-tasks kanban, task detail (role-driven actions,
  review dialog, AI dialog), review queue, developer pool

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:11:05 +02:00

126 lines
3.5 KiB
Go

package httpx
import (
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"time"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/files"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
"bountyboard/internal/workperform"
)
// shutdownBudget is the §12 graceful-shutdown drain window.
const shutdownBudget = 15 * time.Second
type Server struct {
cfg *config.Config
log *slog.Logger
metrics *metrics.Registry
store *store.Store
files *files.Store
templates map[string]*template.Template
oidc *auth.OIDCClient
loginLimiter *auth.RateLimiter
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)
publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
performerClient *workperform.Client
}
// SetPerformerClient wires the §5.2 client (also feeds the status panel).
func (s *Server) SetPerformerClient(c *workperform.Client) {
s.performerClient = c
s.performer = c
}
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server {
templates, err := parseTemplates()
if err != nil {
// Templates are embedded; failure is a build defect caught by any
// test or first boot, never a runtime condition.
panic(fmt.Sprintf("parse templates: %v", err))
}
s := &Server{
cfg: cfg,
log: log,
metrics: reg,
store: st,
files: fs,
templates: templates,
oidc: auth.NewOIDCClient(cfg, log),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(),
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /metricsz", s.handleMetricsz)
s.routesAuth(mux)
s.routesProfile(mux)
s.routesAdmin(mux)
s.routesTasks(mux)
s.routesBoard(mux)
s.routesConsultant(mux)
s.routesWorkResults(mux)
mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux)
s.httpSrv = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.AppPort),
Handler: s.withMiddleware(mux),
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
}
return s
}
// Handler exposes the full middleware-wrapped handler for tests.
func (s *Server) Handler() http.Handler { return s.httpSrv.Handler }
// Run serves until ctx is canceled (SIGTERM/SIGINT), then drains in-flight
// requests within the shutdown budget.
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.log.Info("http server listening", "addr", s.httpSrv.Addr, "baseUrl", s.cfg.AppBaseURL)
if err := s.httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
}
s.log.Info("shutting down", "budget", shutdownBudget.String())
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownBudget)
defer cancel()
if err := s.httpSrv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return <-errCh
}