package main import ( "context" "crypto/subtle" "errors" "log" "net/http" "strconv" "strings" "time" ) // User is the subset of an ST user record this worker cares about. type User struct { ID int64 Email string ChapterID int64 Department string AppointmentTypes []string IsRep bool } // STClient is what the handler needs from the Solidarity Tech API. type STClient interface { GetUser(ctx context.Context, id int64) (*User, error) ActiveAgent(ctx context.Context, userID int64) (int64, error) AssignAgent(ctx context.Context, userID, agentID int64) error } // Store is what the handler needs from persistence. type Store interface { AcquireLock(chapterID int64, dept string, repID int64) (int64, error) ReleaseLock(runID int64) error UpsertPeople(users []User, eligible map[string]bool) error LastSync() (int64, error) SetLastSync(ts int64) error RepsFor(chapterID int64, dept string) ([]int64, error) EligibleMembers(chapterID int64, dept string) ([]int64, error) LogSkip(runID, userID, existingAgent, wouldHaveAssigned int64) error SetProgress(runID, lastUserID int64) error } // ErrLocked is returned by AcquireLock when a run is already in flight // for that department. var ErrLocked = errors.New("department already running") type Server struct { st STClient store Store secret string eligible map[string]bool // runTimeout caps a single reconcile. runTimeout time.Duration } func NewServer(st STClient, store Store, secret string, eligible map[string]bool) *Server { return &Server{ st: st, store: store, secret: secret, eligible: eligible, runTimeout: 2 * time.Hour, } } // Eligible reports whether any of the user's appointment types is in set. // Labels are matched, not option values, so a rename in the ST dashboard // silently disqualifies everyone — log unknown labels during sync. func (u User) Eligible(set map[string]bool) bool { for _, label := range u.AppointmentTypes { if set[strings.ToLower(strings.TrimSpace(label))] { return true } } return false } func (s *Server) Routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("POST /webhook", s.handleWebhook) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) // TEMPORARY: remove before deploy. mux.HandleFunc("POST /debug/sync", func(w http.ResponseWriter, r *http.Request) { if !s.validSecret(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() if err := s.SyncRoster(ctx); err != nil { http.Error(w, err.Error(), 500) return } w.WriteHeader(http.StatusOK) }) return mux } func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { if !s.validSecret(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } repID, err := parseRepID(r) if err != nil { log.Printf("bad webhook payload: %v", err) http.Error(w, "bad request", http.StatusBadRequest) return } rep, err := s.st.GetUser(r.Context(), repID) if err != nil { log.Printf("lookup of rep %d failed: %v", repID, err) http.Error(w, "lookup failed", http.StatusInternalServerError) return } if rep.ChapterID == 0 { log.Printf("rel %d has no chapter; ignoring", repID) w.WriteHeader(http.StatusOK) return } dept := normalizeDept(rep.Department) if dept == "" { // An empty department would match every member whose department // is also blank. Never reconcile on it. log.Printf("rep %d has no department; ignoring", repID) w.WriteHeader(http.StatusOK) return } runID, err := s.store.AcquireLock(rep.ChapterID, dept, repID) if errors.Is(err, ErrLocked) { // 200, not 409: a non-2xx invites ST to retry, and a retried // duplicate is still a duplicate. log.Printf("dept %q already running; ignoring rep %d", dept, repID) w.WriteHeader(http.StatusOK) return } if err != nil { log.Printf("acquiring lock for %q failed: %v", dept, err) http.Error(w, "lock failed", http.StatusInternalServerError) return } // Detached from the request: r.Context() is cancelled the moment // this handler returns. go func() { defer func() { if err := s.store.ReleaseLock(runID); err != nil { log.Printf("releasing lock for run %d failed: %v", runID, err) } }() ctx, cancel := context.WithTimeout(context.Background(), s.runTimeout) defer cancel() if err := s.reconcile(ctx, runID, rep, dept); err != nil { log.Printf("run %d failed: %v", runID, err) } }() w.WriteHeader(http.StatusOK) } // ST doesn't support setting headers so we use the URL func (s *Server) validSecret(r *http.Request) bool { got := r.Header.Get("X-Secret") if got == "" { got = r.URL.Query().Get("s") } return subtle.ConstantTimeCompare([]byte(got), []byte(s.secret)) == 1 } // normalizeDept must be applied identically here and when filtering the // cached roster. Department 1 is a free-text field. func normalizeDept(s string) string { return strings.ToLower(strings.TrimSpace(s)) } // parseRepID reads the rep's ST user ID from the webhook query string. // ST sends it as {{ user.id }} in the webhook action's URL. func parseRepID(r *http.Request) (int64, error) { raw := r.URL.Query().Get("id") if raw == "" { return 0, errors.New("missing id") } return strconv.ParseInt(raw, 10, 64) }