Phase 6b/7b: dashboard codes editor, decode events, human-readable briefs (AUD-115)

- New /codes dashboard page (behind the session login, linked in the top bar):
  lists every mapping by kind with Add, inline Edit+Save and Delete. Sam can put
  real teacher surnames in without a rebuild (FR-17).
- Decode timetable EVENTS through the same map as periods, so an ensemble reads
  'JAZZ ENSEMBLE - Ensemble Room - Mr KWO' instead of raw codes.
- teacherName splits comma- or space-separated codes ('HHL, OFG').
- SeedCodes is a one-time bootstrap while the table is empty, so a correction or
  a delete is never overwritten or restored.
- Tests: decode Label/Event, the /codes page (save, refuse, csrf, delete, auth),
  store re-seed behaviour. go test ./internal/... passes.
This commit is contained in:
2026-10-16 12:50:31 +11:00
parent 80b54524ea
commit 3e25011399
11 changed files with 433 additions and 28 deletions

View File

@@ -79,6 +79,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail))
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush))
mux.HandleFunc("GET /codes", s.requireAuth(s.handleCodesPage))
mux.HandleFunc("POST /codes", s.requireAuth(s.handleCodeSave))
mux.HandleFunc("POST /codes/delete", s.requireAuth(s.handleCodeDelete))
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
mux.HandleFunc("POST /summaries/send", s.requireAuth(s.handleSendBrief))
mux.HandleFunc("POST /summaries/{id}/send", s.requireAuth(s.handleSummarySend))
@@ -1421,6 +1424,139 @@ func pathID(r *http.Request, name string) (int64, bool) {
return v, true
}
// ---------- codes (the editable decode map, FR-17) ----------
// codeKinds is the page order and the plain-English label for the four decode
// groups.
var codeKinds = []struct {
Kind string
Label string
Hint string
}{
{store.CodeSubject, "Subjects", "e.g. 7SCF → Year 7 Science. New subject codes appear each term."},
{store.CodeTeacher, "Teachers", "Compass gives initials only. Put the surname here, e.g. KNI → Mr Knight. Unknown initials stay raw."},
{store.CodeRoom, "Rooms", "e.g. B11 → Room B11, GYM4 → Gym 4."},
{store.CodePeriod, "Period labels", "Word slots only, e.g. C → Connect. A number is always “Period N”."},
}
// codesData renders the editable decode map (AGENTS.md §4 FR-17). The map is
// data, not code: a correction here changes the very next brief.
type codesData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
Groups []codeGroup
ErrMsg string
Flash string
FlashLevel string
}
type codeGroup struct {
Kind string
Label string
Hint string
Codes []codeRow
}
type codeRow struct {
Kind string
Code string
Name string
}
// handleCodesPage lists every mapping so Sam can correct it in the dashboard.
func (s *Server) handleCodesPage(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
data := codesData{
Title: "Codes",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
}
// Same flash contract as the dashboard: ?msg=… is the text, ?level=error
// makes it red.
if msg := strings.TrimSpace(r.URL.Query().Get("msg")); msg != "" {
data.Flash = msg
data.FlashLevel = "ok"
}
if strings.TrimSpace(r.URL.Query().Get("level")) == "error" {
data.FlashLevel = "error"
}
rows, err := s.store.ListCodes(ctx)
if err != nil {
s.log.Error("list codes", "err", err)
data.ErrMsg = "Could not read the codes: " + err.Error()
}
byKind := map[string][]codeRow{}
for _, c := range rows {
byKind[c.Kind] = append(byKind[c.Kind], codeRow{Kind: c.Kind, Code: c.Code, Name: c.Name})
}
for _, k := range codeKinds {
data.Groups = append(data.Groups, codeGroup{Kind: k.Kind, Label: k.Label, Hint: k.Hint, Codes: byKind[k.Kind]})
}
s.render(w, r, "codes.html", data)
}
// handleCodeSave creates or corrects one mapping (FR-17).
func (s *Server) handleCodeSave(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("code save rejected: bad csrf token")
http.Error(w, "session expired", http.StatusForbidden)
return
}
row, err := s.store.SetCode(ctx,
strings.TrimSpace(r.PostFormValue("kind")),
strings.TrimSpace(r.PostFormValue("code")),
strings.TrimSpace(r.PostFormValue("name")))
if err != nil {
s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("Not saved: "+err.Error()))
return
}
s.log.Info("code saved", "kind", row.Kind, "code", row.Code, "name", row.Name)
s.redirectAfterWrite(w, r, "/codes?msg="+url.QueryEscape("Saved "+row.Kind+" "+row.Code+" → "+row.Name))
}
// handleCodeDelete removes one mapping, so the code falls back to raw.
func (s *Server) handleCodeDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("code delete rejected: bad csrf token")
http.Error(w, "session expired", http.StatusForbidden)
return
}
kind := strings.TrimSpace(r.PostFormValue("kind"))
code := strings.TrimSpace(r.PostFormValue("code"))
ok, err := s.store.DeleteCode(ctx, kind, code)
switch {
case err != nil:
s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("Not removed: "+err.Error()))
case !ok:
s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("No such code: "+kind+" "+code))
default:
s.log.Info("code deleted", "kind", kind, "code", code)
s.redirectAfterWrite(w, r, "/codes?msg="+url.QueryEscape("Removed "+kind+" "+code+"; it renders raw again."))
}
}
// ---------- partials ----------
func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) {

View File

@@ -875,3 +875,91 @@ func TestMorningEndpointDryRun(t *testing.T) {
t.Errorf("a dry run must not record deliveries: %+v", rows)
}
}
// TestCodesPageEditsDecodeMap proves FR-17 in the dashboard: Sam can see the
// decode map, correct a teacher initial to a surname, and remove a mapping.
// A corrected or deleted mapping must stick, not reappear from the seed.
func TestCodesPageEditsDecodeMap(t *testing.T) {
ts, st := newTestServer(t)
client := login(t, ts)
page := getBody(t, client, ts.URL+"/codes")
if !strings.Contains(page, "KNI") || !strings.Contains(page, "Teachers") {
t.Fatalf("codes page does not list the seeded teacher codes")
}
token := csrfToken(t, page)
// Correct an initial to a surname.
resp, err := client.PostForm(ts.URL+"/codes", url.Values{
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Knight"},
})
if err != nil {
t.Fatalf("save: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", resp.StatusCode)
}
codes, err := st.Codes(context.Background())
if err != nil {
t.Fatalf("codes: %v", err)
}
if codes.Teacher["KNI"] != "Mr Knight" {
t.Errorf("teacher KNI = %q, want Mr Knight", codes.Teacher["KNI"])
}
// An empty name is refused and the mapping is unchanged.
resp, err = client.PostForm(ts.URL+"/codes", url.Values{
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {""},
})
if err != nil {
t.Fatalf("bad save: %v", err)
}
resp.Body.Close()
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "level=error") {
t.Errorf("bad save Location = %q, want an error flash", loc)
}
if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "Mr Knight" {
t.Errorf("the refused save changed the mapping to %q", codes.Teacher["KNI"])
}
// A bad CSRF token is refused.
resp, err = client.PostForm(ts.URL+"/codes", url.Values{
"csrf": {"wrong"}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Nope"},
})
if err != nil {
t.Fatalf("csrf save: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("bad csrf status = %d, want 403", resp.StatusCode)
}
// Delete returns the code to raw, and it stays deleted.
resp, err = client.PostForm(ts.URL+"/codes/delete", url.Values{
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"},
})
if err != nil {
t.Fatalf("delete: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("delete status = %d, want 303", resp.StatusCode)
}
if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "" {
t.Errorf("teacher KNI still mapped after delete: %q", codes.Teacher["KNI"])
}
// The page is behind the session login.
anon := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
anonResp, err := anon.Get(ts.URL + "/codes")
if err != nil {
t.Fatalf("anon get: %v", err)
}
anonResp.Body.Close()
if anonResp.StatusCode != http.StatusSeeOther {
t.Errorf("anonymous /codes = %d, want a redirect to login", anonResp.StatusCode)
}
}

View File

@@ -0,0 +1,88 @@
{{define "codes.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Compass / Teams</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Compass / Teams
</a>
<div class="nav-bar__right">
<a class="button-utility" href="/">What is due</a>
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button-utility" type="submit">Sign out</button>
</form>
</div>
</nav>
<main class="container">
<header class="page-head">
<h1 class="heading-1">Codes</h1>
<p class="page-head__sub">Plain-English names for the Compass codes in the briefs.
A code that is not here renders as the raw code — never blank.</p>
</header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
{{if .Flash}}<p class="flash flash--{{.FlashLevel}}" role="status">{{.Flash}}</p>{{end}}
{{range .Groups}}
<section class="section" aria-labelledby="codes-{{.Kind}}-title">
<div class="section__head">
<h2 id="codes-{{.Kind}}-title" class="heading-2">{{.Label}}</h2>
<span class="category-pill">{{len .Codes}}</span>
</div>
<p class="page-head__sub">{{.Hint}}</p>
<form method="post" action="/codes" class="inline-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="kind" value="{{.Kind}}">
<input class="text-input" type="text" name="code" placeholder="code" required>
<input class="text-input" type="text" name="name" placeholder="plain English" required>
<button class="button-primary" type="submit">Add</button>
</form>
{{if .Codes}}
<ul class="item-list">
{{range .Codes}}
<li class="item">
<div class="item__body">
<form method="post" action="/codes" class="inline-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="kind" value="{{.Kind}}">
<input type="hidden" name="code" value="{{.Code}}">
<code class="item__subject">{{.Code}}</code>
<input class="text-input" type="text" name="name" value="{{.Name}}" required>
<button class="button-primary" type="submit">Save</button>
</form>
</div>
<form class="item__actions" method="post" action="/codes/delete"
onsubmit="return confirm('Remove this mapping? The code renders raw again.');">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="kind" value="{{.Kind}}">
<input type="hidden" name="code" value="{{.Code}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="section__empty">No codes yet. Add one above.</p>
{{end}}
</section>
{{end}}
</main>
<footer class="app-foot">Family Home Lab · Compass / Teams</footer>
</body>
</html>{{end}}

View File

@@ -17,6 +17,7 @@
Compass / Teams
</a>
<div class="nav-bar__right">
<a class="button-utility" href="/codes">Codes</a>
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">

View File

@@ -17,6 +17,7 @@
Compass / Teams
</a>
<div class="nav-bar__right">
<a class="button-utility" href="/codes">Codes</a>
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">