This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
"github.com/andrew/go-computer/internal/signals"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func HandleSignalsList(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, e := bookmark(r, id, false); e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
sources, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "SignalSource" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "createdAt"`, id, user(r))
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
events, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "Signal" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "occurredAt" DESC LIMIT 50`, id, user(r))
|
||||
respond(w, 200, object{"sources": sources, "signals": events, "types": signals.PublicSourceTypes()}, e)
|
||||
}
|
||||
func HandleSignalsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
b, ok := read(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if str(b, "action") == "check" {
|
||||
// Review COR-4: a manual "Check now" must not race with the scheduler
|
||||
// or another click. Take the SAME advisory lease the scheduler uses —
|
||||
// identical key, held on a dedicated connection because session-level
|
||||
// advisory locks are connection-bound (a pooled lock/unlock could land
|
||||
// on different connections and leak the lock) — and answer 409 while
|
||||
// it is held. The 45-second cap keeps the pass inside the server's
|
||||
// WriteTimeout so it cannot be cut off mid-mutation.
|
||||
ctx := r.Context()
|
||||
leaseKey := "signal-poll:" + user(r)
|
||||
conn, e := database(r).Conn(ctx)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
var locked bool
|
||||
if e := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, leaseKey).Scan(&locked); e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
if !locked {
|
||||
respond(w, 0, nil, conflict("A check is already running"))
|
||||
return
|
||||
}
|
||||
defer conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, leaseKey)
|
||||
job, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
res, e := signals.RunSignalChecks(job, database(r), user(r), id)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
// Review CR-16: a manual check answers 200 with the structured
|
||||
// per-source outcome (detail lives in each source's lastStatus);
|
||||
// failed sources are counted, not folded into a blanket 500.
|
||||
respond(w, 200, object{"checked": res.Sources, "created": res.Created, "failed": res.Failed}, nil)
|
||||
return
|
||||
}
|
||||
if str(b, "type") != "email" {
|
||||
respond(w, 0, nil, bad("Unknown signal type"))
|
||||
return
|
||||
}
|
||||
cfg, ok := b["config"].(map[string]any)
|
||||
if !ok {
|
||||
respond(w, 0, nil, bad("Config must be an object"))
|
||||
return
|
||||
}
|
||||
clean := object{}
|
||||
for _, key := range []string{"from", "subject"} {
|
||||
if v, ok := cfg[key]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
respond(w, 0, nil, bad("Config values must be text"))
|
||||
return
|
||||
}
|
||||
rs := []rune(strings.TrimSpace(s))
|
||||
if len(rs) > 200 {
|
||||
rs = rs[:200]
|
||||
}
|
||||
clean[key] = string(rs)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(clean)
|
||||
v, e := db.Insert(r.Context(), database(r), "SignalSource", object{"id": db.NewID(), "bookmarkId": id, "ownerId": user(r), "type": "email", "config": string(raw)})
|
||||
respond(w, 201, v, e)
|
||||
}
|
||||
func HandleSignalSourcesUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := read(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok = b["enabled"].(bool); !ok {
|
||||
respond(w, 0, nil, bad("Enabled must be boolean"))
|
||||
return
|
||||
}
|
||||
v, e := db.Update(r.Context(), database(r), "SignalSource", r.PathValue("id"), "ownerId", user(r), object{"enabled": b["enabled"], "updatedAt": time.Now().UTC()})
|
||||
respond(w, 200, v, e)
|
||||
}
|
||||
func HandleSignalSourcesDelete(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 200, OK{true}, remove(r, "SignalSource", r.PathValue("id"), "ownerId"))
|
||||
}
|
||||
Reference in New Issue
Block a user