From 40f16346f5222109f6b7f7feab4fc4b8d92977e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Jovanovi=C4=87?= Date: Wed, 9 Sep 2026 23:46:35 +0200 Subject: [PATCH] add basic schema and sync function, sorting by appointment type --- client.go | 86 +++++++++++++++++++++++++++++++++++++++++++------------ main.go | 21 +++++++++++++- server.go | 51 ++++++++++++++++++++++++++++++--- store.go | 82 +++++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 207 insertions(+), 33 deletions(-) diff --git a/client.go b/client.go index 5579bfa..6c2a0e9 100644 --- a/client.go +++ b/client.go @@ -2,13 +2,14 @@ package main import ( "context" + "encoding/json" "fmt" "io" "log" "net/http" + "net/url" "strconv" "time" - "encoding/json" "golang.org/x/time/rate" ) @@ -102,6 +103,7 @@ func (a apiUser) toUser() *User { Email: a.Email, ChapterID: a.ChapterID, Department: departmentOf(a.CustomProps), + AppointmentTypes: optionLabels(a.CustomProps, "appointment-type"), } } @@ -118,27 +120,36 @@ func departmentOf(props map[string]json.RawMessage) string { return s } +//optionLabels returne the labels of an option-valued custom-property +// ST serialises select and checkbox fields as [{label, value}], and the +// same label can appear more than once. + +func optionLabels(props map[string]json.RawMessage, key string) []string { + raw, ok := props[key] + if !ok || len(raw) == 0 { + return nil + } + var arr []struct { + Label string `json:"label"` + Value string `json:"value"` + } + if err := json.Unmarshal(raw, &arr); err != nil { + return nil + } + out := make([]string, 0, len(arr)) + for _, o := range arr { + out = append(out, o.Label) + } + return out +} + // isRep tolerates all three shapes "department-rep" has been seen in: // array of options, bare string, bare bool. func isRep(props map[string]json.RawMessage) bool { - raw, ok := props["department-rep"] - if !ok || len(raw) == 0 { - return false - } - var arr []struct{ Value string } - if err := json.Unmarshal(raw, &arr); err == nil { - return len(arr) > 0 - } - var s string - if err := json.Unmarshal(raw, &s); err == nil { - return s != "" && s != "false" - } - var b bool - if err := json.Unmarshal(raw, &b); err == nil { - return b - } - return false + return len(optionLabels(props, "department-rep")) > 0 } + + func (c *Client) GetUser(ctx context.Context, id int64) (*User, error) { raw, err := c.do(ctx, http.MethodGet, "/users/"+strconv.FormatInt(id, 10), nil) if err != nil { @@ -150,3 +161,42 @@ func (c *Client) GetUser(ctx context.Context, id int64) (*User, error) { } return u.toUser(), nil } + +// ListUsers pages the full user list. If since is non-zero, only users +// updated after that Unix timestamp are returned. +func (c *Client) ListUsers(ctx context.Context, since int64) ([]User, error) { + var out []User + offset := 0 + + for { + q := url.Values{} + q.Set("_limit", "100") + q.Set("_offset", strconv.Itoa(offset)) + if since > 0 { + q.Set("_since", strconv.FormatInt(since, 10)) + } + + raw, err := c.do(ctx, http.MethodGet, "/users?"+q.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("listing users at offset %d: %w", offset, err) + } + + var env struct { + Data []apiUser `json:"data"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, fmt.Errorf("parsing users at offset %d: %w", offset, err) + } + + for _, a := range env.Data { + out = append(out, *a.toUser()) + } + + if len(env.Data) < 100 { + return out, nil + } + offset += 100 + + log.Printf("listed %d users so far", len(out)) + } +} diff --git a/main.go b/main.go index 235878a..20287cd 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,28 @@ package main import ( "log" "net/http" + "strings" "os" ) +// ELIGIBLE_APPOINTMENTS is a comma-separated list of appointment type +// labels, matched case-insensitively. +func loadEligible() map[string]bool { + raw := os.Getenv("ELIGIBLE_APPOINTMENTS") + if raw == "" { + raw = "senate,recall" + } + set := map[string]bool{} + for _, s := range strings.Split(raw, ",") { + if s = strings.ToLower(strings.TrimSpace(s)); s != "" { + set[s] = true + } + } + log.Printf("eligible appointment types: %v", set) + return set +} + +// get other variables, like api key and webhook secret func main() { secret := os.Getenv("WEBHOOK_SECRET") if secret == "" { @@ -37,7 +56,7 @@ func main() { log.Println("DRY_RUN set: no writes will be sent to Solidarity Tech") } - srv := NewServer(NewClient(apiKey, dryRun), store, secret) + srv := NewServer(NewClient(apiKey, dryRun), store, secret, loadEligible()) log.Println("listening on :8080") log.Fatal(http.ListenAndServe(":8080", srv.Routes())) } diff --git a/server.go b/server.go index ddb3ee8..2adfe4c 100644 --- a/server.go +++ b/server.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/subtle" + "fmt" "errors" "log" "net/http" @@ -17,6 +18,7 @@ type User struct { Email string ChapterID int64 Department string + AppointmentTypes []string } // STClient is what the handler needs from the Solidarity Tech API. @@ -38,27 +40,68 @@ type Server struct { st STClient store Store secret string - - // runTimeout caps a single reconcile. At 2 req/sec, size this - // against your largest department. + eligible map[string]bool + // runTimeout caps a single reconcile. runTimeout time.Duration } -func NewServer(st STClient, store Store, secret string) *Server { +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("GET /debug/users", func(w http.ResponseWriter, r *http.Request) { + client, ok := s.st.(*Client) + if !ok { + http.Error(w, "not a real client", 500) + return + } + users, err := client.ListUsers(r.Context(), 0) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + fmt.Fprintf(w, "%d users\n", len(users)) + counts := map[string]int{} + eligible := 0 + for _, u := range users { + if !u.Eligible(s.eligible) { + continue + } + eligible++ + counts[fmt.Sprintf("%d|%s", u.ChapterID, normalizeDept(u.Department))]++ + } + fmt.Fprintf(w, "%d users, %d eligible\n\n", len(users), eligible) + for k, n := range counts { + fmt.Fprintf(w, "%6d %s\n", n, k) + } + }) + return mux } diff --git a/store.go b/store.go index 68ca5f6..0f6d4e3 100644 --- a/store.go +++ b/store.go @@ -9,18 +9,22 @@ import ( ) const schema = ` -CREATE TABLE IF NOT EXISTS runs ( - id INTEGER PRIMARY KEY, - chapter_id INTEGER NOT NULL, - department TEXT NOT NULL, - rep_user_id INTEGER NOT NULL, - status TEXT NOT NULL, - started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - ended_at TEXT +CREATE TABLE IF NOT EXISTS people ( + id INTEGER PRIMARY KEY, + email TEXT, + chapter_id INTEGER, + department TEXT, + appointment_type TEXT, + is_rep INTEGER NOT NULL DEFAULT 0, + synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE UNIQUE INDEX IF NOT EXISTS one_run_per_dept - ON runs(chapter_id, department) WHERE status = 'running'; +CREATE INDEX IF NOT EXISTS people_dept ON people(chapter_id, department); + +CREATE TABLE IF NOT EXISTS sync_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); ` type SQLStore struct{ db *sql.DB } @@ -64,3 +68,61 @@ func (s *SQLStore) SweepStaleLocks() error { WHERE status='running'`) return err } + +func (s *SQLStore) UpsertPeople(users []User, eligible map[string]bool) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(` + INSERT INTO people (id, email, chapter_id, department, appointment_type, is_rep, synced_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + email=excluded.email, + chapter_id=excluded.chapter_id, + department=excluded.department, + appointment_type=excluded.appointment_type, + is_rep=excluded.is_rep, + synced_at=CURRENT_TIMESTAMP`) + if err != nil { + return err + } + defer stmt.Close() + + for _, u := range users { + rep := 0 + if u.IsRep { + rep = 1 + } + _, err := stmt.Exec(u.ID, u.Email, u.ChapterID, + normalizeDept(u.Department), + strings.Join(u.AppointmentTypes, ","), + rep) + if err != nil { + return fmt.Errorf("upserting user %d: %w", u.ID, err) + } + } + return tx.Commit() +} + +func (s *SQLStore) LastSync() (int64, error) { + var v string + err := s.db.QueryRow(`SELECT value FROM sync_state WHERE key='users_synced_at'`).Scan(&v) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + return strconv.ParseInt(v, 10, 64) +} + +func (s *SQLStore) SetLastSync(ts int64) error { + _, err := s.db.Exec(` + INSERT INTO sync_state (key, value) VALUES ('users_synced_at', ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value`, + strconv.FormatInt(ts, 10)) + return err +}