Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit abea3dc

Browse files
author
Codex
committed
feat: add web GUI report editor with Go + Templ + HTMX
Add a web-based weekly report editor that runs alongside the Slack bot in the same binary. Managers can preview items grouped by AI-classified sections, reclassify items between sections, edit/delete items, preview markdown, and generate reports — all through an HTMX-powered interface. Key changes: - SQLite WAL mode for concurrent web + Slack access - Authoritative corrections: manual reclassifications override LLM - chi router with Slack OAuth, CSRF protection, session cookies - Report editor shows real BuildReportsFromLast output (WYSIWYG) - Non-managers see only their own items (matches Slack behavior) - Generation mutex prevents concurrent Slack + web report writes - deps.go pattern matching existing codebase convention - 31 new tests across 3 test suites
1 parent f1e3ad6 commit abea3dc

37 files changed

Lines changed: 4204 additions & 1 deletion

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ Configuration is layered: `config.yaml` is loaded first, then environment variab
3636
- **Report**: `report_private` (bool, when true `/generate-report` DMs the report to the caller instead of posting to the channel; default false)
3737
- **Network**: `tls_skip_verify` (bool, skip TLS cert verification for internal/corporate CAs; default false)
3838
- **Team**: `team_name` (used in report header and filename)
39+
- **Web UI**: `web_enabled` (bool), `web_port` (int, default 8080), `web_client_id`, `web_client_secret` (Slack OAuth), `web_session_secret` (cookie HMAC key), `web_base_url` (OAuth redirect base)
3940

4041
See `config.yaml` and `README.md` for full reference.
4142

4243
## Architecture
4344

4445
The application uses a cmd/internal layout with the executable under `cmd/reportbot` and core logic split by domain under `internal/*` packages:
4546

46-
- **cmd/reportbot/main.go** — Entry point: loads config, initializes DB, creates Slack client, starts nudge and auto-fetch schedulers, starts Socket Mode bot
47+
- **cmd/reportbot/main.go** — Entry point: loads config, initializes DB, creates Slack client, starts nudge and auto-fetch schedulers, optionally starts web UI server, starts Socket Mode bot
4748
- **internal/config/config.go** — Config struct, YAML + env loading with validation, `IsManagerID()` permission check
4849
- **internal/domain/models.go** — Core types (`WorkItem`, `GitLabMR`, `GitHubPR`, `ReportSection`) and `CurrentWeekRange()` calendar week calculator
4950
- **internal/storage/sqlite/db.go** — SQLite schema and CRUD: `work_items`, `classification_history`, `classification_corrections` tables
@@ -58,6 +59,13 @@ The application uses a cmd/internal layout with the executable under `cmd/report
5859
- **internal/report/report_builder.go** — Template parsing, LLM classification pipeline, merge logic, status ordering, markdown rendering (team + boss modes)
5960
- **internal/report/report.go** — Report file writing (markdown `.md` and email draft `.eml`) to disk
6061
- **internal/nudge/nudge.go** — Scheduled weekly reminder and DM sender (`sendNudges` also used by `/check` nudge buttons)
62+
- **internal/web/handlers/server.go** — Web UI: chi router, CSRF middleware, Slack OAuth, report editor (Go + Templ + HTMX)
63+
- **internal/web/handlers/report.go** — Report editor handlers: editor page, reclassify, edit, delete, preview, generate with polling
64+
- **internal/web/handlers/auth.go** — Slack OAuth login/callback/logout handlers
65+
- **internal/web/middleware/auth.go** — Session cookie auth middleware, role derivation per-request via `IsManagerID()`
66+
- **internal/web/deps.go** — Package-level function vars wrapping sqlite/report/config calls (same pattern as slack/deps.go)
67+
- **internal/web/auth.go** — HMAC-signed session cookie create/validate, OAuth state generation
68+
- **internal/web/templates/*.templ** — Templ templates: layout, login, report editor, item rows, section groups, preview, edit form
6169

6270
## Key Flows
6371

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,13 @@ report_channel_id: "C01234567"
178178
external_http_timeout_seconds: 90 # optional: timeout for GitLab/GitHub/LLM HTTP calls
179179
tls_skip_verify: false # optional: skip TLS cert verification for internal/corporate CAs
180180

181+
# Web UI (optional)
182+
web_enabled: false
183+
web_port: 8080
184+
web_client_id: "" # Slack OAuth client ID
185+
web_client_secret: "" # Slack OAuth client secret
186+
web_session_secret: "" # Random secret for cookie signing (openssl rand -hex 32)
187+
web_base_url: "http://localhost:8080"
181188
```
182189
183190
Set `CONFIG_PATH` env var to load from a different path (default: `./config.yaml`).
@@ -206,6 +213,12 @@ export REPORT_CHANNEL_ID=C01234567
206213
export EXTERNAL_HTTP_TIMEOUT_SECONDS=90 # Optional: timeout for external API HTTP calls
207214
export TLS_SKIP_VERIFY=true # Optional: skip TLS cert verification
208215
export AUTO_FETCH_SCHEDULE="0 9 * * 1-5" # Optional: cron schedule for auto-fetch
216+
export WEB_ENABLED=true # Optional: enable web UI
217+
export WEB_PORT=8080
218+
export WEB_CLIENT_ID=your-slack-client-id # Slack OAuth client ID
219+
export WEB_CLIENT_SECRET=your-slack-client-secret
220+
export WEB_SESSION_SECRET=$(openssl rand -hex 32)
221+
export WEB_BASE_URL=http://localhost:8080
209222
export MONDAY_CUTOFF_TIME=12:00
210223
export TIMEZONE=America/Los_Angeles
211224
```
@@ -227,6 +240,7 @@ Set `llm_critic_enabled` / `LLM_CRITIC_ENABLED` to enable a second LLM pass that
227240
Set `openai_base_url` / `OPENAI_BASE_URL` when `llm_provider=openai` and you want to use an OpenAI-compatible endpoint instead of `api.openai.com` (for example a lab-hosted `gpt-oss-120b` server).
228241
Set `external_http_timeout_seconds` / `EXTERNAL_HTTP_TIMEOUT_SECONDS` to tune timeout limits for GitLab/GitHub/LLM API requests.
229242
Set `tls_skip_verify` / `TLS_SKIP_VERIFY` to skip TLS certificate verification when connecting to internal or corporate API servers with self-signed or internal CA certificates.
243+
Set `web_enabled` / `WEB_ENABLED` to serve the report editor web UI alongside the Slack bot. Configure `web_client_id`, `web_client_secret` with your Slack app's OAuth credentials and set `web_session_secret` to a random 32-byte hex string for cookie signing. The web UI uses Slack OAuth for authentication and the same `manager_slack_ids` for permissions.
230244

231245
Glossary example (`llm_glossary.yaml`):
232246

config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,14 @@ timezone: "America/Los_Angeles"
7878

7979
# Team name used in report titles and filenames
8080
team_name: "My Team"
81+
82+
# Web UI (optional — set web_enabled: true to serve the report editor at localhost:8080)
83+
web_enabled: false
84+
web_port: 8080
85+
# Slack OAuth credentials for "Sign in with Slack" (from your Slack app settings)
86+
web_client_id: ""
87+
web_client_secret: ""
88+
# Random secret for signing session cookies (generate with: openssl rand -hex 32)
89+
web_session_secret: ""
90+
# Base URL for OAuth redirect (must match the redirect URL in Slack app settings)
91+
web_base_url: "http://localhost:8080"

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ require (
1313
)
1414

1515
require (
16+
github.com/a-h/templ v0.3.1001 // indirect
17+
github.com/go-chi/chi/v5 v5.2.5 // indirect
18+
github.com/gorilla/csrf v1.7.3 // indirect
19+
github.com/gorilla/securecookie v1.1.2 // indirect
1620
github.com/gorilla/websocket v1.5.3 // indirect
1721
github.com/tidwall/gjson v1.18.0 // indirect
1822
github.com/tidwall/match v1.1.1 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
2+
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
13
github.com/anthropics/anthropic-sdk-go v1.22.0 h1:sgo4Ob5pC5InKCi/5Ukn5t9EjPJ7KTMaKm5beOYt6rM=
24
github.com/anthropics/anthropic-sdk-go v1.22.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
35
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
46
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7+
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
8+
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
59
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
610
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
11+
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
12+
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
13+
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
14+
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
715
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
816
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
917
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=

internal/app/app.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ package app
22

33
import (
44
"log"
5+
"net/http"
56
"os"
7+
"os/signal"
8+
"syscall"
9+
610
"reportbot/internal/config"
711
"reportbot/internal/fetch"
812
"reportbot/internal/httpx"
913
slackbot "reportbot/internal/integrations/slack"
1014
"reportbot/internal/nudge"
1115
"reportbot/internal/storage/sqlite"
16+
"reportbot/internal/web/handlers"
1217

1318
"github.com/slack-go/slack"
1419
)
@@ -49,6 +54,30 @@ func Main() {
4954
nudge.StartNudgeScheduler(cfg, db, api)
5055
fetch.StartAutoFetchScheduler(cfg, db, api)
5156

57+
// Start web UI if enabled
58+
var webSrv *http.Server
59+
if cfg.WebEnabled {
60+
webSrv = handlers.NewServer(cfg, db)
61+
go func() {
62+
log.Printf("Web UI listening on :%d", cfg.WebPort)
63+
if err := webSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
64+
log.Printf("Web server error: %v", err)
65+
}
66+
}()
67+
}
68+
69+
// App-level signal handler for graceful shutdown
70+
go func() {
71+
sigCh := make(chan os.Signal, 1)
72+
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
73+
<-sigCh
74+
log.Println("Shutting down...")
75+
if webSrv != nil {
76+
webSrv.Close()
77+
}
78+
os.Exit(0)
79+
}()
80+
5281
log.Println("Starting Engineering Report Bot...")
5382
if err := slackbot.StartSlackBot(cfg, db, api); err != nil {
5483
log.Fatalf("Slack bot error: %v", err)

internal/config/config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ type Config struct {
5757
Timezone string `yaml:"timezone"`
5858
TeamName string `yaml:"team_name"`
5959

60+
// Web UI
61+
WebEnabled bool `yaml:"web_enabled"`
62+
WebPort int `yaml:"web_port"`
63+
WebClientID string `yaml:"web_client_id"`
64+
WebClientSecret string `yaml:"web_client_secret"`
65+
WebSessionSecret string `yaml:"web_session_secret"`
66+
WebBaseURL string `yaml:"web_base_url"`
67+
6068
Location *time.Location `yaml:"-"` // computed from Timezone, not from YAML
6169
}
6270

@@ -115,6 +123,12 @@ func LoadConfig() Config {
115123
envOverride(&cfg.AutoFetchSchedule, "AUTO_FETCH_SCHEDULE")
116124
envOverride(&cfg.MondayCutoffTime, "MONDAY_CUTOFF_TIME")
117125
envOverride(&cfg.Timezone, "TIMEZONE")
126+
envOverrideBool(&cfg.WebEnabled, "WEB_ENABLED")
127+
envOverrideInt(&cfg.WebPort, "WEB_PORT")
128+
envOverride(&cfg.WebClientID, "WEB_CLIENT_ID")
129+
envOverride(&cfg.WebClientSecret, "WEB_CLIENT_SECRET")
130+
envOverride(&cfg.WebSessionSecret, "WEB_SESSION_SECRET")
131+
envOverride(&cfg.WebBaseURL, "WEB_BASE_URL")
118132

119133
if ids := os.Getenv("MANAGER_SLACK_IDS"); ids != "" {
120134
cfg.ManagerSlackIDs = nil
@@ -171,6 +185,12 @@ func LoadConfig() Config {
171185
if cfg.TeamName == "" {
172186
cfg.TeamName = "My Team"
173187
}
188+
if cfg.WebPort == 0 {
189+
cfg.WebPort = 8080
190+
}
191+
if cfg.WebBaseURL == "" {
192+
cfg.WebBaseURL = fmt.Sprintf("http://localhost:%d", cfg.WebPort)
193+
}
174194
if cfg.Timezone == "" {
175195
cfg.Timezone = "Local"
176196
}

internal/report/report_builder.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"regexp"
88
"sort"
99
"strings"
10+
"sync"
1011
"time"
1112
"unicode"
1213
)
@@ -60,6 +61,9 @@ var classifySectionsFn = func(cfg Config, items []WorkItem, options []sectionOpt
6061
return CategorizeItemsToSections(cfg, items, options, existing, corrections, historicalItems)
6162
}
6263

64+
// GenerationMu prevents concurrent report generation from Slack and web UI.
65+
var GenerationMu sync.Mutex
66+
6367
type BuildResult struct {
6468
Template *ReportTemplate
6569
Usage LLMUsage
@@ -88,6 +92,11 @@ func BuildReportsFromLast(cfg Config, items []WorkItem, reportDate time.Time, co
8892
}
8993
}
9094

95+
// Apply authoritative corrections: override LLM decisions with manual corrections.
96+
// Corrections are hard overrides, not suggestions — if a manager moved an item
97+
// to a section, that decision sticks even if the LLM disagrees.
98+
applyAuthoritativeCorrections(decisions, corrections, items)
99+
91100
confidenceThreshold := cfg.LLMConfidence
92101
if confidenceThreshold <= 0 || confidenceThreshold > 1 {
93102
confidenceThreshold = 0.70
@@ -331,6 +340,35 @@ func trimDoneItems(t *ReportTemplate) {
331340
}
332341
}
333342

343+
// applyAuthoritativeCorrections overrides LLM decisions with manual corrections.
344+
// For each item that has a correction, the LLM's section assignment is replaced
345+
// with the corrected section, and confidence is set to 1.0 to ensure it passes
346+
// the confidence threshold.
347+
func applyAuthoritativeCorrections(decisions map[int64]LLMSectionDecision, corrections []ClassificationCorrection, items []WorkItem) {
348+
if len(corrections) == 0 || len(decisions) == 0 {
349+
return
350+
}
351+
// Build a map from work_item_id to the most recent correction
352+
correctionByItem := make(map[int64]ClassificationCorrection)
353+
for _, c := range corrections {
354+
if existing, ok := correctionByItem[c.WorkItemID]; !ok || c.CorrectedAt.After(existing.CorrectedAt) {
355+
correctionByItem[c.WorkItemID] = c
356+
}
357+
}
358+
// Override LLM decisions for items that have corrections
359+
for _, item := range items {
360+
correction, ok := correctionByItem[item.ID]
361+
if !ok {
362+
continue
363+
}
364+
if d, exists := decisions[item.ID]; exists {
365+
d.SectionID = correction.CorrectedSectionID
366+
d.Confidence = 1.0 // Ensure it passes any confidence threshold
367+
decisions[item.ID] = d
368+
}
369+
}
370+
}
371+
334372
func mergeIncomingItems(
335373
t *ReportTemplate,
336374
items []WorkItem,
@@ -598,6 +636,14 @@ func renderBossMarkdown(t *ReportTemplate) string {
598636
)
599637
}
600638

639+
// RenderMarkdownByMode renders a template as markdown in the given mode ("team" or "boss").
640+
func RenderMarkdownByMode(t *ReportTemplate, mode string) string {
641+
if mode == "boss" {
642+
return renderBossMarkdown(t)
643+
}
644+
return renderTeamMarkdown(t)
645+
}
646+
601647
func renderMarkdown(
602648
t *ReportTemplate,
603649
categoryHeading func(cat TemplateCategory) string,

internal/storage/sqlite/db.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package sqlite
22

33
import (
44
"database/sql"
5+
"fmt"
56
"reportbot/internal/domain"
67
"time"
78

@@ -20,6 +21,14 @@ func InitDB(path string) (*sql.DB, error) {
2021
return nil, err
2122
}
2223

24+
// Enable WAL mode for concurrent readers + single writer (needed for web UI + Slack bot)
25+
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
26+
return nil, fmt.Errorf("enabling WAL mode: %w", err)
27+
}
28+
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
29+
return nil, fmt.Errorf("setting busy timeout: %w", err)
30+
}
31+
2332
schema := `
2433
CREATE TABLE IF NOT EXISTS work_items (
2534
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -545,6 +554,33 @@ func InsertClassificationCorrection(db *sql.DB, c ClassificationCorrection) erro
545554
return err
546555
}
547556

557+
// ReclassifyItem atomically records a correction and updates the item's category.
558+
func ReclassifyItem(db *sql.DB, c ClassificationCorrection, newCategory string) error {
559+
tx, err := db.Begin()
560+
if err != nil {
561+
return fmt.Errorf("begin reclassify tx: %w", err)
562+
}
563+
defer tx.Rollback()
564+
565+
_, err = tx.Exec(
566+
`INSERT INTO classification_corrections
567+
(work_item_id, original_section_id, original_label, corrected_section_id, corrected_label, description, corrected_by)
568+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
569+
c.WorkItemID, c.OriginalSectionID, c.OriginalLabel,
570+
c.CorrectedSectionID, c.CorrectedLabel, c.Description, c.CorrectedBy,
571+
)
572+
if err != nil {
573+
return fmt.Errorf("insert correction: %w", err)
574+
}
575+
576+
_, err = tx.Exec("UPDATE work_items SET category = ? WHERE id = ?", newCategory, c.WorkItemID)
577+
if err != nil {
578+
return fmt.Errorf("update category: %w", err)
579+
}
580+
581+
return tx.Commit()
582+
}
583+
548584
func GetRecentCorrections(db *sql.DB, since time.Time, limit int) ([]ClassificationCorrection, error) {
549585
rows, err := db.Query(
550586
`SELECT id, work_item_id, original_section_id, original_label,

0 commit comments

Comments
 (0)