91f03c21e8
Addresses the screenshot-review feedback in #26. No framework or build step added — all of this stays within the existing plain HTML/CSS/ vanilla-JS + go:embed architecture. - Nav: re-enable the bottom tab bar that was already built and switched off (Queue/On-call/Alerts/Team + a "More" sheet for Stats/Admin/Account), replacing the hamburger on phone width. - Queue: chip counts, a scroll fade on the filter row, a "Triggered Xh ago" + severity label per row, a chevron on the team switcher so it reads as a dropdown. - On-call: collapse repeated same-person days into shift bars (week view and "your shifts" both), show the week as a date range with the ISO week number as secondary text, split "Current shift" out from "Next shifts" with "ends in Nd", a pill badge + row highlight for "you". - Incident detail: fix the actual bug behind the duplicate "acknowledged" timeline entries (acknowledgeIncident's UPDATE had no guard on the incident's current status, so acknowledging an already-acknowledged incident silently re-logged the event — now idempotent, with regression tests on both the authenticated route and the ntfy ack-button route). Relabel escalation re-pages so they don't look like the same page landing twice. Copy the primary action up near the top. Label the "···" button. Group the timeline by phase (triggered/acknowledged/resolved). Add an "at a glance" summary row (duration/severity/responsible) and collapse the group labels by default. - Team overview: reword the vague copy ("One owner." etc.) into plain labels. - Empty states: fill in missing icons/one-liners across queue, alerts, stats and the incident timeline. - CSS: fix card padding bugs, verify link contrast already passes AA, introduce a --fs-* type-scale token set and migrate the few genuinely isolated cases onto it (left sizes tied to a fixed shape, a deliberately prominent display, or a non-negotiable constraint like the iOS-zoom-prevention input size as documented exceptions rather than guess at a render this change can't see). Verified with the full fmt/lint/test/helm-lint gate, plus a live instance against the test DB with seeded incidents and schedule data to trace the on-call grouping and timeline phase-splitting logic against real API responses. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
106 lines
3.6 KiB
Go
106 lines
3.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// issueAckToken mints the secret behind one notification's Acknowledge button
|
|
// and returns the raw value to embed in its URL. Only the hash is stored, the
|
|
// same way api_keys works.
|
|
//
|
|
// A fresh token per delivery rather than one per incident: the raw value only
|
|
// exists for as long as it takes to build the message, so there is nothing to
|
|
// look up and reuse later, and a reminder that supersedes an earlier page
|
|
// carries its own credential.
|
|
func issueAckToken(ctx context.Context, q querier, incidentID, userID int64) (string, error) {
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
now := time.Now()
|
|
if _, err := q.ExecContext(ctx, `
|
|
INSERT INTO incident_ack_tokens (token_hash, incident_id, user_id, created_at, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
hash, incidentID, userID, now.Unix(), now.Add(ackTokenTTL).Unix()); err != nil {
|
|
return "", err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// handleNotifyAck acknowledges an incident from the Acknowledge button in a
|
|
// push notification.
|
|
//
|
|
// It is deliberately outside AuthMiddleware: the caller is a phone acting on a
|
|
// notification, not a client holding an API key. What stands in for the key is
|
|
// the token in the path — 256 bits of entropy, valid for one incident, one
|
|
// action, and one day. It must stay publicly reachable for the button to work
|
|
// when the responder is off the cluster network.
|
|
func handleNotifyAck(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
h := sha256.Sum256([]byte(chi.URLParam(r, "token")))
|
|
hash := hex.EncodeToString(h[:])
|
|
|
|
var incidentID, userID int64
|
|
err := db.QueryRowContext(r.Context(), `
|
|
SELECT incident_id, user_id FROM incident_ack_tokens
|
|
WHERE token_hash = $1 AND expires_at > $2`,
|
|
hash, time.Now().Unix()).Scan(&incidentID, &userID)
|
|
if err != nil {
|
|
// Unknown and expired get the same answer, so the endpoint cannot be
|
|
// used to probe which tokens once existed.
|
|
respond(w, http.StatusNotFound, errResp("invalid or expired token"))
|
|
return
|
|
}
|
|
|
|
acked, err := acknowledgeIncident(r.Context(), db, incidentID, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if !acked {
|
|
// Either the incident closed between the page and the tap, or it was
|
|
// already acknowledged (e.g. from the web UI, or an earlier tap of
|
|
// the same button) — either way nothing the responder did wrong, so
|
|
// report the actual state rather than assuming "resolved", and let
|
|
// ntfy show a success toast rather than a failure.
|
|
inc, err := fetchIncident(r.Context(), db, incidentID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, map[string]any{
|
|
"incident_id": incidentID,
|
|
"status": inc.Status,
|
|
})
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, map[string]any{
|
|
"incident_id": incidentID,
|
|
"status": "acknowledged",
|
|
})
|
|
}
|
|
}
|
|
|
|
// purgeAckTokens drops tokens whose notifications are long past. Nothing else
|
|
// deletes them: incidents are archived rather than removed, so the cascade never
|
|
// fires in practice.
|
|
func purgeAckTokens(ctx context.Context, db *sql.DB) {
|
|
res, err := db.ExecContext(ctx,
|
|
"DELETE FROM incident_ack_tokens WHERE expires_at < $1", time.Now().Unix())
|
|
if err != nil {
|
|
log.Printf("sweeper: purge ack tokens: %v", err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n > 0 {
|
|
log.Printf("sweeper: purged %d expired ack token(s)", n)
|
|
}
|
|
}
|