diff --git a/.gitignore b/.gitignore index b14620a..328432c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ docker-compose-* coverage.out coverage.txt coverage.html +testdata/ +.gstack/ diff --git a/CLAUDE.md b/CLAUDE.md index 48a8f22..6738834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,12 @@ docker run -d --name reportbot \ -v /path/to/config.yaml:/app/config.yaml:ro \ -v reportbot-data:/app/data \ reportbot + +# Docker Compose with Web UI (HTTPS via Caddy) +export WEB_HOST=https://192.168.1.100 +export WEB_CLIENT_SECRET=xxx +export WEB_SESSION_SECRET=$(openssl rand -hex 32) +docker-compose --project-name reportbot up -d ``` ## Configuration @@ -36,6 +42,7 @@ Configuration is layered: `config.yaml` is loaded first, then environment variab - **Report**: `report_private` (bool, when true `/generate-report` DMs the report to the caller instead of posting to the channel; default false) - **Network**: `tls_skip_verify` (bool, skip TLS cert verification for internal/corporate CAs; default false) - **Team**: `team_name` (used in report header and filename) +- **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) See `config.yaml` and `README.md` for full reference. @@ -43,7 +50,7 @@ See `config.yaml` and `README.md` for full reference. The application uses a cmd/internal layout with the executable under `cmd/reportbot` and core logic split by domain under `internal/*` packages: -- **cmd/reportbot/main.go** — Entry point: loads config, initializes DB, creates Slack client, starts nudge and auto-fetch schedulers, starts Socket Mode bot +- **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 - **internal/config/config.go** — Config struct, YAML + env loading with validation, `IsManagerID()` permission check - **internal/domain/models.go** — Core types (`WorkItem`, `GitLabMR`, `GitHubPR`, `ReportSection`) and `CurrentWeekRange()` calendar week calculator - **internal/storage/sqlite/db.go** — SQLite schema and CRUD: `work_items`, `classification_history`, `classification_corrections` tables @@ -58,6 +65,13 @@ The application uses a cmd/internal layout with the executable under `cmd/report - **internal/report/report_builder.go** — Template parsing, LLM classification pipeline, merge logic, status ordering, markdown rendering (team + boss modes) - **internal/report/report.go** — Report file writing (markdown `.md` and email draft `.eml`) to disk - **internal/nudge/nudge.go** — Scheduled weekly reminder and DM sender (`sendNudges` also used by `/check` nudge buttons) +- **internal/web/handlers/server.go** — Web UI: chi router, CSRF middleware, Slack OAuth, report editor (Go + Templ + HTMX) +- **internal/web/handlers/report.go** — Report editor handlers: editor page, reclassify, edit, delete, preview, generate with polling +- **internal/web/handlers/auth.go** — Slack OAuth login/callback/logout handlers +- **internal/web/middleware/auth.go** — Session cookie auth middleware, role derivation per-request via `IsManagerID()` +- **internal/web/deps.go** — Package-level function vars wrapping sqlite/report/config calls (same pattern as slack/deps.go) +- **internal/web/auth.go** — HMAC-signed session cookie create/validate, OAuth state generation +- **internal/web/templates/*.templ** — Templ templates: layout, login, report editor, item rows, section groups, preview, edit form ## Key Flows diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..8d82dd8 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,4 @@ +:443 { + tls /certs/cert.pem /certs/key.pem + reverse_proxy reportbot:8088 +} diff --git a/Dockerfile b/Dockerfile index c26a8b9..0f229c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,4 +18,6 @@ WORKDIR /app COPY --from=builder /app/reportbot . RUN mkdir -p /app/reports +EXPOSE 8088 + ENTRYPOINT ["./reportbot"] diff --git a/README.md b/README.md index 4b3cd95..2f62120 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,13 @@ report_channel_id: "C01234567" external_http_timeout_seconds: 90 # optional: timeout for GitLab/GitHub/LLM HTTP calls tls_skip_verify: false # optional: skip TLS cert verification for internal/corporate CAs +# Web UI (optional) +web_enabled: false +web_port: 8080 +web_client_id: "" # Slack OAuth client ID +web_client_secret: "" # Slack OAuth client secret +web_session_secret: "" # Random secret for cookie signing (openssl rand -hex 32) +web_base_url: "http://localhost:8080" ``` Set `CONFIG_PATH` env var to load from a different path (default: `./config.yaml`). @@ -206,6 +213,12 @@ export REPORT_CHANNEL_ID=C01234567 export EXTERNAL_HTTP_TIMEOUT_SECONDS=90 # Optional: timeout for external API HTTP calls export TLS_SKIP_VERIFY=true # Optional: skip TLS cert verification export AUTO_FETCH_SCHEDULE="0 9 * * 1-5" # Optional: cron schedule for auto-fetch +export WEB_ENABLED=true # Optional: enable web UI +export WEB_PORT=8080 +export WEB_CLIENT_ID=your-slack-client-id # Slack OAuth client ID +export WEB_CLIENT_SECRET=your-slack-client-secret +export WEB_SESSION_SECRET=$(openssl rand -hex 32) +export WEB_BASE_URL=http://localhost:8080 export MONDAY_CUTOFF_TIME=12:00 export TIMEZONE=America/Los_Angeles ``` @@ -227,6 +240,7 @@ Set `llm_critic_enabled` / `LLM_CRITIC_ENABLED` to enable a second LLM pass that 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). Set `external_http_timeout_seconds` / `EXTERNAL_HTTP_TIMEOUT_SECONDS` to tune timeout limits for GitLab/GitHub/LLM API requests. 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. +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. Glossary example (`llm_glossary.yaml`): @@ -293,6 +307,41 @@ docker run -d --name reportbot \ The volume persists the SQLite database and generated reports across restarts. +#### Option C: Docker Compose with Web UI (HTTPS via Caddy) + +For running the web report editor alongside the Slack bot, use Docker Compose with +the included Caddy reverse proxy for automatic HTTPS (self-signed cert, works with +IP addresses on internal networks): + +```bash +# 1. Set environment variables +export SLACK_BOT_TOKEN=xoxb-... +export SLACK_APP_TOKEN=xapp-... +export GITLAB_TOKEN=glpat-... +export OPENAI_API_KEY=sk-... +export WEB_HOST=https://192.168.1.100 # your server IP or domain +export WEB_CLIENT_SECRET=your-slack-secret # from Slack app Basic Information +export WEB_SESSION_SECRET=$(openssl rand -hex 32) + +# 2. Configure config.yaml with web settings +# web_enabled: true +# web_port: 8088 +# web_client_id: "your-slack-client-id" +# web_base_url: "https://192.168.1.100" + +# 3. Add OAuth redirect URL in Slack app settings: +# https://192.168.1.100/auth/slack/callback + +# 4. Build and run +docker build -t reportbot . +docker-compose --project-name reportbot up -d +``` + +Caddy handles TLS termination with a self-signed certificate (no internet or domain +required). Your browser will show a certificate warning on first visit — accept it +once. The Slack OAuth flow works because the redirect happens in the browser, not +server-to-server. + ## Usage ### Reporting Work Items diff --git a/config.yaml b/config.yaml index 54178ec..8d26e58 100644 --- a/config.yaml +++ b/config.yaml @@ -78,3 +78,14 @@ timezone: "America/Los_Angeles" # Team name used in report titles and filenames team_name: "My Team" + +# Web UI (optional — set web_enabled: true to serve the report editor at localhost:8080) +web_enabled: false +web_port: 8080 +# Slack OAuth credentials for "Sign in with Slack" (from your Slack app settings) +web_client_id: "" +web_client_secret: "" +# Random secret for signing session cookies (generate with: openssl rand -hex 32) +web_session_secret: "" +# Base URL for OAuth redirect (must match the redirect URL in Slack app settings) +web_base_url: "http://localhost:8080" diff --git a/docker-compose.yaml b/docker-compose.yaml index 4884c02..09483a9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,6 +2,10 @@ # export SLACK_APP_TOKEN=xxxx # export GITLAB_TOKEN=xxxx # export OPENAI_API_KEY=xxxx +# export WEB_HOST=https://192.168.1.100 # IP or domain for HTTPS +# export WEB_CLIENT_SECRET=xxxx # Slack OAuth client secret +# export WEB_SESSION_SECRET=xxxx # openssl rand -hex 32 +# # docker-compose --project-name reportbot up # docker-compose --project-name reportbot down # @@ -12,17 +16,37 @@ # - ./my-custom-config.yaml:/app/config.yaml:ro # - ./my-custom-glossary.yaml:/app/llm_glossary.yaml services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./certs:/certs:ro + - caddy-data:/data + - caddy-config:/config + reportbot: container_name: reportbot image: reportbot + expose: + - "8088" environment: SLACK_BOT_TOKEN: "${SLACK_BOT_TOKEN}" SLACK_APP_TOKEN: "${SLACK_APP_TOKEN}" GITLAB_TOKEN: "${GITLAB_TOKEN}" OPENAI_API_KEY: "${OPENAI_API_KEY}" + WEB_CLIENT_SECRET: "${WEB_CLIENT_SECRET}" + WEB_SESSION_SECRET: "${WEB_SESSION_SECRET}" volumes: - ./reportbot-data:/app/data - ./reportbot-reports:/app/reports - ./llm_glossary.yaml:/app/llm_glossary.yaml - ./llm_classification_guide.md:/app/llm_classification_guide.md - ./config.yaml:/app/config.yaml:ro + +volumes: + caddy-data: + caddy-config: diff --git a/go.mod b/go.mod index 985ec30..7f88096 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,10 @@ require ( ) require ( + github.com/a-h/templ v0.3.1001 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/gorilla/csrf v1.7.3 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/go.sum b/go.sum index 79aa7ad..c21da9f 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,17 @@ +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/anthropics/anthropic-sdk-go v1.22.0 h1:sgo4Ob5pC5InKCi/5Ukn5t9EjPJ7KTMaKm5beOYt6rM= github.com/anthropics/anthropic-sdk-go v1.22.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= diff --git a/internal/app/app.go b/internal/app/app.go index dcc41f3..a1b45ad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,14 +1,23 @@ package app import ( + "context" + "fmt" "log" + "net" + "net/http" "os" + "os/signal" + "syscall" + "time" + "reportbot/internal/config" "reportbot/internal/fetch" "reportbot/internal/httpx" slackbot "reportbot/internal/integrations/slack" "reportbot/internal/nudge" "reportbot/internal/storage/sqlite" + "reportbot/internal/web/handlers" "github.com/slack-go/slack" ) @@ -38,7 +47,9 @@ func Main() { log.Printf("Database initialized at %s", cfg.DBPath) defer db.Close() - os.MkdirAll(cfg.ReportOutputDir, 0755) + if err := os.MkdirAll(cfg.ReportOutputDir, 0755); err != nil { + log.Fatalf("Failed to create report output directory %s: %v", cfg.ReportOutputDir, err) + } log.Printf("Report output dir: %s", cfg.ReportOutputDir) api := slack.New( @@ -49,6 +60,39 @@ func Main() { nudge.StartNudgeScheduler(cfg, db, api) fetch.StartAutoFetchScheduler(cfg, db, api) + // Start web UI if enabled — verify bind before proceeding + var webSrv *http.Server + if cfg.WebEnabled { + webSrv = handlers.NewServer(cfg, db) + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WebPort)) + if err != nil { + log.Fatalf("Web server bind failed on port %d: %v", cfg.WebPort, err) + } + log.Printf("Web UI listening on :%d", cfg.WebPort) + go func() { + if err := webSrv.Serve(ln); err != nil && err != http.ErrServerClosed { + log.Printf("Web server error: %v", err) + } + }() + } + + // App-level signal handler for graceful shutdown + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + <-sigCh + log.Println("Shutting down...") + if webSrv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := webSrv.Shutdown(ctx); err != nil { + log.Printf("Web server shutdown error: %v", err) + } + } + db.Close() + os.Exit(0) + }() + log.Println("Starting Engineering Report Bot...") if err := slackbot.StartSlackBot(cfg, db, api); err != nil { log.Fatalf("Slack bot error: %v", err) diff --git a/internal/config/config.go b/internal/config/config.go index 58c676d..f992415 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,14 @@ type Config struct { Timezone string `yaml:"timezone"` TeamName string `yaml:"team_name"` + // Web UI + WebEnabled bool `yaml:"web_enabled"` + WebPort int `yaml:"web_port"` + WebClientID string `yaml:"web_client_id"` + WebClientSecret string `yaml:"web_client_secret"` + WebSessionSecret string `yaml:"web_session_secret"` + WebBaseURL string `yaml:"web_base_url"` + Location *time.Location `yaml:"-"` // computed from Timezone, not from YAML } @@ -115,6 +123,12 @@ func LoadConfig() Config { envOverride(&cfg.AutoFetchSchedule, "AUTO_FETCH_SCHEDULE") envOverride(&cfg.MondayCutoffTime, "MONDAY_CUTOFF_TIME") envOverride(&cfg.Timezone, "TIMEZONE") + envOverrideBool(&cfg.WebEnabled, "WEB_ENABLED") + envOverrideInt(&cfg.WebPort, "WEB_PORT") + envOverride(&cfg.WebClientID, "WEB_CLIENT_ID") + envOverride(&cfg.WebClientSecret, "WEB_CLIENT_SECRET") + envOverride(&cfg.WebSessionSecret, "WEB_SESSION_SECRET") + envOverride(&cfg.WebBaseURL, "WEB_BASE_URL") if ids := os.Getenv("MANAGER_SLACK_IDS"); ids != "" { cfg.ManagerSlackIDs = nil @@ -171,6 +185,12 @@ func LoadConfig() Config { if cfg.TeamName == "" { cfg.TeamName = "My Team" } + if cfg.WebPort == 0 { + cfg.WebPort = 8080 + } + if cfg.WebBaseURL == "" { + cfg.WebBaseURL = fmt.Sprintf("http://localhost:%d", cfg.WebPort) + } if cfg.Timezone == "" { cfg.Timezone = "Local" } @@ -225,6 +245,18 @@ func LoadConfig() Config { log.Fatalf("llm_provider must be 'anthropic' or 'openai', got '%s'", cfg.LLMProvider) } + if cfg.WebEnabled { + if cfg.WebSessionSecret == "" { + log.Fatalf("web_session_secret is required when web_enabled=true (generate with: openssl rand -hex 32)") + } + if cfg.WebClientID == "" { + log.Fatalf("web_client_id is required when web_enabled=true") + } + if cfg.WebClientSecret == "" { + log.Fatalf("web_client_secret is required when web_enabled=true") + } + } + if strings.EqualFold(cfg.Timezone, "Local") { cfg.Location = time.Local } else { diff --git a/internal/report/report_builder.go b/internal/report/report_builder.go index f8543d9..46270db 100644 --- a/internal/report/report_builder.go +++ b/internal/report/report_builder.go @@ -7,6 +7,7 @@ import ( "regexp" "sort" "strings" + "sync" "time" "unicode" ) @@ -60,6 +61,9 @@ var classifySectionsFn = func(cfg Config, items []WorkItem, options []sectionOpt return CategorizeItemsToSections(cfg, items, options, existing, corrections, historicalItems) } +// GenerationMu prevents concurrent report generation from Slack and web UI. +var GenerationMu sync.Mutex + type BuildResult struct { Template *ReportTemplate Usage LLMUsage @@ -88,6 +92,11 @@ func BuildReportsFromLast(cfg Config, items []WorkItem, reportDate time.Time, co } } + // Apply authoritative corrections: override LLM decisions with manual corrections. + // Corrections are hard overrides, not suggestions — if a manager moved an item + // to a section, that decision sticks even if the LLM disagrees. + applyAuthoritativeCorrections(decisions, corrections, items) + confidenceThreshold := cfg.LLMConfidence if confidenceThreshold <= 0 || confidenceThreshold > 1 { confidenceThreshold = 0.70 @@ -331,6 +340,35 @@ func trimDoneItems(t *ReportTemplate) { } } +// applyAuthoritativeCorrections overrides LLM decisions with manual corrections. +// For each item that has a correction, the LLM's section assignment is replaced +// with the corrected section, and confidence is set to 1.0 to ensure it passes +// the confidence threshold. +func applyAuthoritativeCorrections(decisions map[int64]LLMSectionDecision, corrections []ClassificationCorrection, items []WorkItem) { + if len(corrections) == 0 || len(decisions) == 0 { + return + } + // Build a map from work_item_id to the most recent correction + correctionByItem := make(map[int64]ClassificationCorrection) + for _, c := range corrections { + if existing, ok := correctionByItem[c.WorkItemID]; !ok || c.CorrectedAt.After(existing.CorrectedAt) { + correctionByItem[c.WorkItemID] = c + } + } + // Override LLM decisions for items that have corrections + for _, item := range items { + correction, ok := correctionByItem[item.ID] + if !ok { + continue + } + if d, exists := decisions[item.ID]; exists { + d.SectionID = correction.CorrectedSectionID + d.Confidence = 1.0 // Ensure it passes any confidence threshold + decisions[item.ID] = d + } + } +} + func mergeIncomingItems( t *ReportTemplate, items []WorkItem, @@ -598,6 +636,14 @@ func renderBossMarkdown(t *ReportTemplate) string { ) } +// RenderMarkdownByMode renders a template as markdown in the given mode ("team" or "boss"). +func RenderMarkdownByMode(t *ReportTemplate, mode string) string { + if mode == "boss" { + return renderBossMarkdown(t) + } + return renderTeamMarkdown(t) +} + func renderMarkdown( t *ReportTemplate, categoryHeading func(cat TemplateCategory) string, diff --git a/internal/storage/sqlite/db.go b/internal/storage/sqlite/db.go index a2ef274..5cfabc9 100644 --- a/internal/storage/sqlite/db.go +++ b/internal/storage/sqlite/db.go @@ -2,7 +2,9 @@ package sqlite import ( "database/sql" + "fmt" "reportbot/internal/domain" + "strings" "time" _ "github.com/mattn/go-sqlite3" @@ -20,6 +22,14 @@ func InitDB(path string) (*sql.DB, error) { return nil, err } + // Enable WAL mode for concurrent readers + single writer (needed for web UI + Slack bot) + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + return nil, fmt.Errorf("enabling WAL mode: %w", err) + } + if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil { + return nil, fmt.Errorf("setting busy timeout: %w", err) + } + schema := ` CREATE TABLE IF NOT EXISTS work_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -532,6 +542,83 @@ func GetLatestClassification(db *sql.DB, workItemID int64) (ClassificationRecord return r, err } +// GetLatestClassificationsForItems returns the most recent classification for each +// of the given work item IDs. Items with no classification are omitted from the result. +func GetLatestClassificationsForItems(db *sql.DB, itemIDs []int64) (map[int64]ClassificationRecord, error) { + if len(itemIDs) == 0 { + return nil, nil + } + // Build query with placeholders + placeholders := make([]string, len(itemIDs)) + args := make([]interface{}, len(itemIDs)) + for i, id := range itemIDs { + placeholders[i] = "?" + args[i] = id + } + query := fmt.Sprintf( + `SELECT ch.id, ch.work_item_id, ch.section_id, ch.section_label, ch.confidence, + ch.normalized_status, ch.ticket_ids, ch.duplicate_of, ch.llm_provider, ch.llm_model, ch.classified_at + FROM classification_history ch + INNER JOIN ( + SELECT work_item_id, MAX(classified_at) AS max_at + FROM classification_history + WHERE work_item_id IN (%s) + GROUP BY work_item_id + ) latest ON ch.work_item_id = latest.work_item_id AND ch.classified_at = latest.max_at`, + strings.Join(placeholders, ","), + ) + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[int64]ClassificationRecord) + for rows.Next() { + var r ClassificationRecord + if err := rows.Scan( + &r.ID, &r.WorkItemID, &r.SectionID, &r.SectionLabel, &r.Confidence, + &r.NormalizedStatus, &r.TicketIDs, &r.DuplicateOf, + &r.LLMProvider, &r.LLMModel, &r.ClassifiedAt, + ); err != nil { + return nil, err + } + result[r.WorkItemID] = r + } + return result, rows.Err() +} + +// GetAllSectionLabels returns all distinct section_id → section_label pairs from classification history. +func GetAllSectionLabels(db *sql.DB) (map[string]string, error) { + rows, err := db.Query( + `SELECT DISTINCT section_id, section_label FROM classification_history WHERE section_id != '' ORDER BY section_id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := make(map[string]string) + for rows.Next() { + var id, label string + if err := rows.Scan(&id, &label); err != nil { + return nil, err + } + if label == "" { + label = id + } + result[id] = label + } + return result, rows.Err() +} + +// RenameSectionLabel updates the display label for all classification records with the given section_id. +// This is display-only — the section_id (used by LLM) is unchanged. +func RenameSectionLabel(db *sql.DB, sectionID, newLabel string) error { + _, err := db.Exec( + "UPDATE classification_history SET section_label = ? WHERE section_id = ?", + newLabel, sectionID) + return err +} + // --- Classification Corrections --- func InsertClassificationCorrection(db *sql.DB, c ClassificationCorrection) error { @@ -545,6 +632,46 @@ func InsertClassificationCorrection(db *sql.DB, c ClassificationCorrection) erro return err } +// ReclassifyItem atomically records a correction, updates the item's category, +// and inserts a new classification_history record so the web UI shows the change immediately. +func ReclassifyItem(db *sql.DB, c ClassificationCorrection, newCategory string) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("begin reclassify tx: %w", err) + } + defer tx.Rollback() + + _, err = tx.Exec( + `INSERT INTO classification_corrections + (work_item_id, original_section_id, original_label, corrected_section_id, corrected_label, description, corrected_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + c.WorkItemID, c.OriginalSectionID, c.OriginalLabel, + c.CorrectedSectionID, c.CorrectedLabel, c.Description, c.CorrectedBy, + ) + if err != nil { + return fmt.Errorf("insert correction: %w", err) + } + + _, err = tx.Exec("UPDATE work_items SET category = ? WHERE id = ?", newCategory, c.WorkItemID) + if err != nil { + return fmt.Errorf("update category: %w", err) + } + + // Insert a classification_history record so GetLatestClassificationsForItems + // returns the corrected section on next page load + _, err = tx.Exec( + `INSERT INTO classification_history + (work_item_id, section_id, section_label, confidence, llm_provider, llm_model) + VALUES (?, ?, ?, 1.0, 'manual', 'user')`, + c.WorkItemID, c.CorrectedSectionID, c.CorrectedLabel, + ) + if err != nil { + return fmt.Errorf("insert classification history: %w", err) + } + + return tx.Commit() +} + func GetRecentCorrections(db *sql.DB, since time.Time, limit int) ([]ClassificationCorrection, error) { rows, err := db.Query( `SELECT id, work_item_id, original_section_id, original_label, diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..ff593f0 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,114 @@ +package web + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const sessionCookieName = "reportbot_session" +const sessionDuration = 24 * time.Hour + +// SessionPayload is the data stored in the session cookie. +type SessionPayload struct { + UserID string `json:"uid"` + UserName string `json:"name"` + ExpiresAt time.Time `json:"exp"` +} + +// CreateSessionCookie creates an HMAC-signed session cookie. +// Set secure=true when serving over HTTPS (derived from WebBaseURL scheme). +func CreateSessionCookie(secret string, payload SessionPayload, secure bool) (*http.Cookie, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal session: %w", err) + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(data) + sig := mac.Sum(nil) + + value := base64.URLEncoding.EncodeToString(data) + "." + base64.URLEncoding.EncodeToString(sig) + + return &http.Cookie{ + Name: sessionCookieName, + Value: value, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure, + MaxAge: int(sessionDuration.Seconds()), + }, nil +} + +// ValidateSessionCookie validates an HMAC-signed session cookie and returns the payload. +func ValidateSessionCookie(secret string, cookie *http.Cookie) (SessionPayload, error) { + var payload SessionPayload + + parts := splitCookieValue(cookie.Value) + if len(parts) != 2 { + return payload, fmt.Errorf("invalid cookie format") + } + + data, err := base64.URLEncoding.DecodeString(parts[0]) + if err != nil { + return payload, fmt.Errorf("decode payload: %w", err) + } + + sig, err := base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return payload, fmt.Errorf("decode signature: %w", err) + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(data) + expectedSig := mac.Sum(nil) + + if !hmac.Equal(sig, expectedSig) { + return payload, fmt.Errorf("invalid signature") + } + + if err := json.Unmarshal(data, &payload); err != nil { + return payload, fmt.Errorf("unmarshal session: %w", err) + } + + if time.Now().After(payload.ExpiresAt) { + return payload, fmt.Errorf("session expired") + } + + return payload, nil +} + +// ClearSessionCookie returns a cookie that clears the session. +func ClearSessionCookie() *http.Cookie { + return &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + } +} + +// GenerateOAuthState creates a random state parameter for OAuth CSRF protection. +func GenerateOAuthState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func splitCookieValue(v string) []string { + for i := len(v) - 1; i >= 0; i-- { + if v[i] == '.' { + return []string{v[:i], v[i+1:]} + } + } + return []string{v} +} diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go new file mode 100644 index 0000000..4cfcf96 --- /dev/null +++ b/internal/web/auth_test.go @@ -0,0 +1,130 @@ +package web + +import ( + "net/http" + "testing" + "time" +) + +func TestCreateSessionCookie(t *testing.T) { + secret := "test-secret-key-1234" + payload := SessionPayload{ + UserID: "U12345", + UserName: "alice", + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + cookie, err := CreateSessionCookie(secret, payload, false) + if err != nil { + t.Fatalf("CreateSessionCookie failed: %v", err) + } + + if cookie.Name != "reportbot_session" { + t.Errorf("expected cookie name 'reportbot_session', got %q", cookie.Name) + } + if cookie.Path != "/" { + t.Errorf("expected cookie path '/', got %q", cookie.Path) + } + if !cookie.HttpOnly { + t.Error("expected cookie to be HttpOnly") + } + if cookie.SameSite != http.SameSiteLaxMode { + t.Errorf("expected SameSiteLaxMode, got %v", cookie.SameSite) + } + if cookie.Value == "" { + t.Error("expected non-empty cookie value") + } + // Value should contain a dot separating payload and signature + found := false + for _, c := range cookie.Value { + if c == '.' { + found = true + break + } + } + if !found { + t.Error("expected cookie value to contain a '.' separator between payload and signature") + } +} + +func TestValidateSessionCookie(t *testing.T) { + secret := "test-secret-key-roundtrip" + payload := SessionPayload{ + UserID: "U99999", + UserName: "bob", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + cookie, err := CreateSessionCookie(secret, payload, false) + if err != nil { + t.Fatalf("CreateSessionCookie failed: %v", err) + } + + got, err := ValidateSessionCookie(secret, cookie) + if err != nil { + t.Fatalf("ValidateSessionCookie failed: %v", err) + } + + if got.UserID != "U99999" { + t.Errorf("expected UserID 'U99999', got %q", got.UserID) + } + if got.UserName != "bob" { + t.Errorf("expected UserName 'bob', got %q", got.UserName) + } +} + +func TestSessionCookieExpiry(t *testing.T) { + secret := "test-secret-key-expiry" + payload := SessionPayload{ + UserID: "UEXPIRED", + UserName: "expired-user", + ExpiresAt: time.Now().Add(-1 * time.Hour), // already expired + } + + cookie, err := CreateSessionCookie(secret, payload, false) + if err != nil { + t.Fatalf("CreateSessionCookie failed: %v", err) + } + + _, err = ValidateSessionCookie(secret, cookie) + if err == nil { + t.Fatal("expected ValidateSessionCookie to fail for expired session, but got nil error") + } +} + +func TestSessionCookieHMACIntegrity(t *testing.T) { + secret := "test-secret-key-hmac" + payload := SessionPayload{ + UserID: "UTAMPER", + UserName: "tamper-user", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + cookie, err := CreateSessionCookie(secret, payload, false) + if err != nil { + t.Fatalf("CreateSessionCookie failed: %v", err) + } + + // Tamper with the cookie value by flipping a character in the signature portion + original := cookie.Value + tampered := original[:len(original)-1] + lastChar := original[len(original)-1] + if lastChar == 'A' { + tampered += "B" + } else { + tampered += "A" + } + cookie.Value = tampered + + _, err = ValidateSessionCookie(secret, cookie) + if err == nil { + t.Fatal("expected ValidateSessionCookie to fail for tampered cookie, but got nil error") + } + + // Also test with a completely wrong secret + cookie.Value = original + _, err = ValidateSessionCookie("wrong-secret", cookie) + if err == nil { + t.Fatal("expected ValidateSessionCookie to fail with wrong secret, but got nil error") + } +} diff --git a/internal/web/csrf.go b/internal/web/csrf.go new file mode 100644 index 0000000..0bda46b --- /dev/null +++ b/internal/web/csrf.go @@ -0,0 +1,71 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + "log" + "net/http" +) + +const csrfCookieName = "_csrf" +const csrfHeaderName = "X-CSRF-Token" +const csrfTokenLength = 32 + +// CSRFMiddleware implements double-submit cookie CSRF protection. +// On GET requests: sets a CSRF cookie if not present. +// On POST/PUT/DELETE requests: validates that the X-CSRF-Token header matches the cookie. +func CSRFMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET", "HEAD", "OPTIONS": + // Safe methods — ensure CSRF cookie exists + if _, err := r.Cookie(csrfCookieName); err != nil { + token := generateCSRFToken() + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + HttpOnly: false, // Must be readable by JavaScript + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + }) + } + next.ServeHTTP(w, r) + + default: + // Mutation methods — validate token + cookie, err := r.Cookie(csrfCookieName) + if err != nil || cookie.Value == "" { + log.Printf("CSRF reject %s %s: cookie missing (err=%v)", r.Method, r.URL.Path, err) + http.Error(w, "CSRF cookie missing", http.StatusForbidden) + return + } + headerToken := r.Header.Get(csrfHeaderName) + if headerToken == "" { + headerToken = r.FormValue("csrf_token") + } + if headerToken != cookie.Value { + log.Printf("CSRF reject %s %s: token mismatch (header=%q cookie=%q)", r.Method, r.URL.Path, headerToken, cookie.Value) + http.Error(w, "CSRF token mismatch", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + } + }) +} + +// GetCSRFToken extracts the CSRF token from the request cookie. +// Used by handlers to pass the token to templates. +func GetCSRFToken(r *http.Request) string { + cookie, err := r.Cookie(csrfCookieName) + if err != nil { + return "" + } + return cookie.Value +} + +func generateCSRFToken() string { + b := make([]byte, csrfTokenLength) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/web/deps.go b/internal/web/deps.go new file mode 100644 index 0000000..02bc9bf --- /dev/null +++ b/internal/web/deps.go @@ -0,0 +1,89 @@ +package web + +import ( + "database/sql" + "reportbot/internal/config" + "reportbot/internal/domain" + llm "reportbot/internal/integrations/llm" + "reportbot/internal/report" + "reportbot/internal/storage/sqlite" + "time" +) + +// Type aliases for convenience. +type Config = config.Config +type WorkItem = domain.WorkItem +type ClassificationRecord = domain.ClassificationRecord +type ClassificationCorrection = domain.ClassificationCorrection +type BuildResult = report.BuildResult +type LLMSectionDecision = llm.LLMSectionDecision +type ReportTemplate = report.ReportTemplate + +// Database delegates — package-level function vars for testability. +var GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]WorkItem, error) { + return sqlite.GetItemsByDateRange(db, from, to) +} + +var GetWorkItemByID = func(db *sql.DB, id int64) (WorkItem, error) { + return sqlite.GetWorkItemByID(db, id) +} + +var GetLatestClassification = func(db *sql.DB, workItemID int64) (ClassificationRecord, error) { + return sqlite.GetLatestClassification(db, workItemID) +} + +var GetLatestClassificationsForItems = func(db *sql.DB, itemIDs []int64) (map[int64]ClassificationRecord, error) { + return sqlite.GetLatestClassificationsForItems(db, itemIDs) +} + +var GetAllSectionLabels = func(db *sql.DB) (map[string]string, error) { + return sqlite.GetAllSectionLabels(db) +} + +var RenameSectionLabel = func(db *sql.DB, sectionID, newLabel string) error { + return sqlite.RenameSectionLabel(db, sectionID, newLabel) +} + +var GetRecentCorrections = func(db *sql.DB, since time.Time, limit int) ([]ClassificationCorrection, error) { + return sqlite.GetRecentCorrections(db, since, limit) +} + +var ReclassifyItem = func(db *sql.DB, c ClassificationCorrection, newCategory string) error { + return sqlite.ReclassifyItem(db, c, newCategory) +} + +var UpdateWorkItemTextAndStatus = func(db *sql.DB, id int64, desc, status string) error { + return sqlite.UpdateWorkItemTextAndStatus(db, id, desc, status) +} + +var DeleteWorkItemByID = func(db *sql.DB, id int64) error { + return sqlite.DeleteWorkItemByID(db, id) +} + +// Report delegates. +var BuildReportsFromLast = func(cfg Config, items []WorkItem, reportDate time.Time, corrections []ClassificationCorrection, historicalItems []domain.HistoricalItem) (BuildResult, error) { + return report.BuildReportsFromLast(cfg, items, reportDate, corrections, historicalItems) +} + +var RenderMarkdownByMode = func(t *ReportTemplate, mode string) string { + return report.RenderMarkdownByMode(t, mode) +} + +var WriteReportFile = func(content, outputDir string, friday time.Time, teamName string) (string, error) { + return report.WriteReportFile(content, outputDir, friday, teamName) +} + +// Config delegates. +var IsManagerID = func(cfg Config, userID string) bool { + return cfg.IsManagerID(userID) +} + +// Domain helpers. +var ReportWeekRange = func(cfg Config, now time.Time) (time.Time, time.Time) { + return domain.ReportWeekRange(cfg, now) +} + +// GetClassifiedItemsWithSections for historical example selection. +var GetClassifiedItemsWithSections = func(db *sql.DB, since time.Time, limit int) ([]domain.HistoricalItem, error) { + return sqlite.GetClassifiedItemsWithSections(db, since, limit) +} diff --git a/internal/web/embed.go b/internal/web/embed.go new file mode 100644 index 0000000..066ff45 --- /dev/null +++ b/internal/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed static +var StaticFiles embed.FS diff --git a/internal/web/handlers/auth.go b/internal/web/handlers/auth.go new file mode 100644 index 0000000..f356dde --- /dev/null +++ b/internal/web/handlers/auth.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "reportbot/internal/web" + "strings" + "time" +) + +// LoginPage renders the Slack OAuth login page. +func LoginPage(cfg web.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + state, err := web.GenerateOAuthState() + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + // Store state in a short-lived cookie for validation on callback + http.SetCookie(w, &http.Cookie{ + Name: "oauth_state", + Value: state, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 300, // 5 minutes + }) + + redirectURI := strings.TrimRight(cfg.WebBaseURL, "/") + "/auth/slack/callback" + oauthURL := fmt.Sprintf( + "https://slack.com/oauth/authorize?scope=identity.basic,identity.avatar&client_id=%s&redirect_uri=%s&state=%s", + url.QueryEscape(cfg.WebClientID), + url.QueryEscape(redirectURI), + url.QueryEscape(state), + ) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` +ReportBot - Sign In + + +
+

ReportBot

+

Sign in with your Slack account to access the report editor.

+Sign in with Slack +
`, oauthURL) + } +} + +// SlackOAuthCallback handles the OAuth redirect from Slack. +func SlackOAuthCallback(cfg web.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Validate state parameter + stateCookie, err := r.Cookie("oauth_state") + if err != nil || stateCookie.Value == "" { + http.Error(w, "Missing OAuth state", http.StatusForbidden) + return + } + if r.URL.Query().Get("state") != stateCookie.Value { + http.Error(w, "Invalid OAuth state", http.StatusForbidden) + return + } + // Clear the state cookie + http.SetCookie(w, &http.Cookie{Name: "oauth_state", Path: "/", MaxAge: -1}) + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Missing authorization code", http.StatusBadRequest) + return + } + + // Exchange code for access token + redirectURI := strings.TrimRight(cfg.WebBaseURL, "/") + "/auth/slack/callback" + resp, err := http.PostForm("https://slack.com/api/oauth.access", url.Values{ + "client_id": {cfg.WebClientID}, + "client_secret": {cfg.WebClientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + }) + if err != nil { + log.Printf("OAuth token exchange failed: %v", err) + http.Error(w, "Authentication failed", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + var oauthResp struct { + OK bool `json:"ok"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"user"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&oauthResp); err != nil { + log.Printf("OAuth response decode failed: %v", err) + http.Error(w, "Authentication failed", http.StatusInternalServerError) + return + } + if !oauthResp.OK { + log.Printf("Slack OAuth error: %s", oauthResp.Error) + http.Error(w, "Slack authentication failed: "+oauthResp.Error, http.StatusForbidden) + return + } + + // Create session cookie (Secure flag derived from base URL scheme) + isHTTPS := strings.HasPrefix(cfg.WebBaseURL, "https://") + cookie, err := web.CreateSessionCookie(cfg.WebSessionSecret, web.SessionPayload{ + UserID: oauthResp.User.ID, + UserName: oauthResp.User.Name, + ExpiresAt: time.Now().Add(24 * time.Hour), + }, isHTTPS) + if err != nil { + log.Printf("Session cookie creation failed: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + http.SetCookie(w, cookie) + http.Redirect(w, r, "/", http.StatusFound) + } +} + +// Logout clears the session cookie. +func Logout() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, web.ClearSessionCookie()) + http.Redirect(w, r, "/login", http.StatusFound) + } +} diff --git a/internal/web/handlers/auth_test.go b/internal/web/handlers/auth_test.go new file mode 100644 index 0000000..a7bcd23 --- /dev/null +++ b/internal/web/handlers/auth_test.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "reportbot/internal/web" + + "github.com/go-chi/chi/v5" +) + +func TestLoginPage_Renders(t *testing.T) { + cfg := web.Config{ + WebClientID: "test-client-id-123", + WebBaseURL: "http://localhost:8080", + } + + r := chi.NewRouter() + r.Get("/login", LoginPage(cfg)) + + req := httptest.NewRequest("GET", "/login", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + body := rr.Body.String() + if !strings.Contains(body, "ReportBot") { + t.Error("expected page to contain 'ReportBot'") + } + if !strings.Contains(body, "Sign in with Slack") { + t.Error("expected page to contain 'Sign in with Slack'") + } + if !strings.Contains(body, "slack.com/oauth/authorize") { + t.Error("expected page to contain Slack OAuth URL") + } + if !strings.Contains(body, "test-client-id-123") { + t.Error("expected page to contain the client ID in the OAuth URL") + } + + // Should set an oauth_state cookie + cookies := rr.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == "oauth_state" && c.Value != "" { + found = true + } + } + if !found { + t.Error("expected an oauth_state cookie to be set") + } +} + +func TestOAuthCallback_ValidCode(t *testing.T) { + // Create a mock Slack OAuth server + mockSlack := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the code was passed + if err := r.ParseForm(); err != nil { + t.Errorf("mock Slack: failed to parse form: %v", err) + } + if r.FormValue("code") != "valid-auth-code" { + w.WriteHeader(http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "ok": true, + "user": map[string]string{ + "id": "U123OAUTH", + "name": "oauth-user", + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer mockSlack.Close() + + // We can't easily redirect the Slack API URL in the handler since it's hardcoded. + // Instead, we test the other aspects of the callback flow. + // For a full integration test, the Slack URL would need to be configurable. + // Here we verify state validation and the code-missing check work correctly. + t.Skip("Full OAuth callback test requires mockable Slack API URL; covered by state/code validation tests") +} + +func TestOAuthCallback_InvalidState(t *testing.T) { + cfg := web.Config{ + WebClientID: "test-client-id", + WebClientSecret: "test-client-secret", + WebBaseURL: "http://localhost:8080", + WebSessionSecret: "test-session-secret-32bytes!!!!", + } + + r := chi.NewRouter() + r.Get("/auth/slack/callback", SlackOAuthCallback(cfg)) + + // Request with mismatched state + req := httptest.NewRequest("GET", "/auth/slack/callback?code=test-code&state=wrong-state", nil) + req.AddCookie(&http.Cookie{Name: "oauth_state", Value: "correct-state"}) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 for invalid state, got %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "Invalid OAuth state") { + t.Errorf("expected error about invalid state, got: %s", body) + } +} + +func TestOAuthCallback_MissingCode(t *testing.T) { + cfg := web.Config{ + WebClientID: "test-client-id", + WebClientSecret: "test-client-secret", + WebBaseURL: "http://localhost:8080", + WebSessionSecret: "test-session-secret-32bytes!!!!", + } + + r := chi.NewRouter() + r.Get("/auth/slack/callback", SlackOAuthCallback(cfg)) + + // Request with correct state but no code + req := httptest.NewRequest("GET", "/auth/slack/callback?state=test-state", nil) + req.AddCookie(&http.Cookie{Name: "oauth_state", Value: "test-state"}) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing code, got %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "Missing authorization code") { + t.Errorf("expected error about missing code, got: %s", body) + } +} + +func TestLogout_ClearsCookie(t *testing.T) { + r := chi.NewRouter() + r.Post("/logout", Logout()) + + req := httptest.NewRequest("POST", "/logout", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusFound { + t.Fatalf("expected 302 redirect, got %d", rr.Code) + } + + loc := rr.Header().Get("Location") + if loc != "/login" { + t.Errorf("expected redirect to /login, got %q", loc) + } + + // Should set a cookie that clears the session + cookies := rr.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == "reportbot_session" && c.MaxAge < 0 { + found = true + } + } + if !found { + t.Error("expected session cookie to be cleared (MaxAge < 0)") + } +} diff --git a/internal/web/handlers/report.go b/internal/web/handlers/report.go new file mode 100644 index 0000000..a37d45f --- /dev/null +++ b/internal/web/handlers/report.go @@ -0,0 +1,1103 @@ +package handlers + +import ( + "database/sql" + "fmt" + "html" + "log" + "math/rand" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "reportbot/internal/domain" + "reportbot/internal/report" + "reportbot/internal/web" + "reportbot/internal/web/middleware" + "reportbot/internal/web/templates" + + "github.com/go-chi/chi/v5" +) + +// buildResultCache caches BuildResult per week to avoid re-running the LLM pipeline +// on every HTMX partial swap. Key: Monday date string, Value: cachedResult. +var buildResultCache sync.Map + +type cachedResult struct { + result web.BuildResult + created time.Time +} + +// generateJobs tracks async report generation jobs. +var generateJobs sync.Map // key: jobID string, value: *generateJob + +type generateJob struct { + mu sync.Mutex + status string // "running", "done", "error" + message string + path string +} + +func (j *generateJob) update(status, message string) { + j.mu.Lock() + defer j.mu.Unlock() + j.status = status + j.message = message +} + +func (j *generateJob) setDone(message, path string) { + j.mu.Lock() + defer j.mu.Unlock() + j.status = "done" + j.message = message + j.path = path +} + +func (j *generateJob) read() (status, message, path string) { + j.mu.Lock() + defer j.mu.Unlock() + return j.status, j.message, j.path +} + +func invalidateCache() { + buildResultCache = sync.Map{} +} + +// ReportEditorPage serves the main editor page. +func ReportEditorPage(cfg web.Config, db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + isManager := middleware.IsManager(r) + userID := middleware.UserID(r) + + weekParam := r.URL.Query().Get("week") + monday, weekLabel, prevWeek, nextWeek := resolveWeek(cfg, weekParam) + + from := monday + to := monday.AddDate(0, 0, 7) + + items, err := web.GetItemsByDateRange(db, from, to) + if err != nil { + log.Printf("Error loading items: %v", err) + http.Error(w, "Failed to load items", http.StatusInternalServerError) + return + } + + // Non-managers see only their own items + if !isManager { + items = filterByAuthorID(items, userID) + } + + // Decide whether to run LLM classification or use existing DB classifications + doClassify := r.URL.Query().Get("classify") == "1" + var sections []templates.SectionData + var avgConf float64 + var hasClassifications bool + + if doClassify { + // Explicit classify request — run LLM pipeline (expensive) + log.Printf("Running LLM classification for week %s (explicit request)", monday.Format("2006-01-02")) + sections, avgConf = classifyWithLLM(cfg, db, items, monday) + hasClassifications = len(sections) > 0 + } else { + // Default — use existing classifications from DB (fast, no LLM) + sections, avgConf, hasClassifications = buildSectionsFromDB(db, items) + } + + // Count unique authors + authorSet := make(map[string]bool) + for _, item := range items { + authorSet[strings.TrimSpace(item.Author)] = true + } + + mode := r.URL.Query().Get("mode") + if mode != "boss" { + mode = "team" + } + + // Build allSections for reclassify dropdown (includes all known sections, even empty ones) + allSections := buildAllSections(db, sections) + + data := templates.EditorData{ + TeamName: cfg.TeamName, + WeekLabel: weekLabel, + WeekParam: monday.Format("2006-01-02"), + PrevWeek: prevWeek, + NextWeek: nextWeek, + ItemCount: len(items), + AuthorCount: len(authorSet), + AvgConf: avgConf, + Sections: sections, + AllSections: allSections, + IsManager: isManager, + CSRFToken: web.GetCSRFToken(r), + Mode: mode, + HasClassifications: hasClassifications, + } + + if err := templates.ReportEditor(data).Render(r.Context(), w); err != nil { + log.Printf("Error rendering report editor: %v", err) + } + } +} + +// PreviewMarkdown renders the report as markdown for the preview panel. +// Moved to manager-only routes to prevent non-manager data leak. +func PreviewMarkdown(cfg web.Config, db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + weekParam := r.URL.Query().Get("week") + monday, _, _, _ := resolveWeek(cfg, weekParam) + + from := monday + to := monday.AddDate(0, 0, 7) + + items, err := web.GetItemsByDateRange(db, from, to) + if err != nil { + log.Printf("Error loading items for preview: %v", err) + renderPreviewError(r, w, "Error loading items. Check server logs for details.") + return + } + + // Non-managers see only their own items in preview too + if !middleware.IsManager(r) { + items = filterByAuthorID(items, middleware.UserID(r)) + } + + corrections, err := web.GetRecentCorrections(db, monday.AddDate(0, -1, 0), 200) + if err != nil { + log.Printf("Warning: failed to load corrections for preview (proceeding without): %v", err) + } + historicalItems, err := web.GetClassifiedItemsWithSections(db, monday.AddDate(0, -3, 0), 500) + if err != nil { + log.Printf("Warning: failed to load historical items for preview (proceeding without): %v", err) + } + + result, err := web.BuildReportsFromLast(cfg, items, monday, corrections, historicalItems) + if err != nil { + log.Printf("Error building report for preview: %v", err) + renderPreviewError(r, w, "Error building report. Check server logs for details.") + return + } + + mode := r.URL.Query().Get("mode") + if mode != "boss" { + mode = "team" + } + + md := web.RenderMarkdownByMode(result.Template, mode) + htmlContent := simpleMarkdownToHTML(md) + if err := templates.MarkdownPreviewHTML(htmlContent).Render(r.Context(), w); err != nil { + log.Printf("Error rendering markdown preview: %v", err) + } + } +} + +func renderPreviewError(r *http.Request, w http.ResponseWriter, msg string) { + if err := templates.MarkdownPreviewRaw(msg).Render(r.Context(), w); err != nil { + log.Printf("Error rendering preview error: %v", err) + } +} + +// ReclassifyItemHandler moves an item to a different section. +func ReclassifyItemHandler(cfg web.Config, db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + itemID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + + newSectionID := r.FormValue("section_id") + if newSectionID == "" { + http.Error(w, "Missing section_id", http.StatusBadRequest) + return + } + + item, err := web.GetWorkItemByID(db, itemID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Item not found", http.StatusNotFound) + } else { + log.Printf("DB error fetching item %d: %v", itemID, err) + http.Error(w, "Failed to load item", http.StatusInternalServerError) + } + return + } + + // Look up section label for the new section + labels, _ := web.GetAllSectionLabels(db) + newLabel := newSectionID + if l, ok := labels[newSectionID]; ok { + newLabel = l + } + + correction := web.ClassificationCorrection{ + WorkItemID: item.ID, + OriginalSectionID: item.Category, + CorrectedSectionID: newSectionID, + CorrectedLabel: newLabel, + Description: item.Description, + CorrectedBy: middleware.UserID(r), + } + + if err := web.ReclassifyItem(db, correction, newSectionID); err != nil { + log.Printf("Reclassify error: %v", err) + http.Error(w, "Failed to reclassify", http.StatusInternalServerError) + return + } + + invalidateCache() + + // Retarget to the item row and remove it (it moved to a different section) + w.Header().Set("HX-Retarget", fmt.Sprintf("#item-%d", itemID)) + w.Header().Set("HX-Reswap", "delete") + w.WriteHeader(http.StatusOK) + } +} + +// UpdateItemHandler updates an item's description and status. +func UpdateItemHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + itemID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + + desc := strings.TrimSpace(r.FormValue("description")) + status := strings.TrimSpace(r.FormValue("status")) + + if desc == "" { + http.Error(w, "Description cannot be empty", http.StatusBadRequest) + return + } + + if err := web.UpdateWorkItemTextAndStatus(db, itemID, desc, status); err != nil { + log.Printf("Update item error: %v", err) + http.Error(w, "Failed to update item", http.StatusInternalServerError) + return + } + + invalidateCache() + + // Re-fetch and render the updated item row in place + item, err := web.GetWorkItemByID(db, itemID) + if err != nil { + w.Header().Set("HX-Redirect", r.Header.Get("HX-Current-URL")) + w.WriteHeader(http.StatusOK) + return + } + cls, _ := web.GetLatestClassification(db, itemID) + allSections := loadAllSectionsForDropdown(db) + itemData := templates.ItemData{ + ID: item.ID, Description: item.Description, Author: item.Author, + Status: item.Status, Source: item.Source, SourceRef: item.SourceRef, + Confidence: cls.Confidence, SectionID: cls.SectionID, TicketIDs: item.TicketIDs, + } + isManager := middleware.IsManager(r) + if err := templates.ItemRow(itemData, allSections, isManager).Render(r.Context(), w); err != nil { + log.Printf("Error rendering updated item row: %v", err) + } + } +} + +// DeleteItemHandler deletes an item. +func DeleteItemHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + itemID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + + if err := web.DeleteWorkItemByID(db, itemID); err != nil { + log.Printf("Delete item error: %v", err) + http.Error(w, "Failed to delete item", http.StatusInternalServerError) + return + } + + invalidateCache() + // Return empty — hx-swap="delete" on the button removes the item from DOM + w.WriteHeader(http.StatusOK) + } +} + +// EditItemForm returns an inline edit form for an item. +func EditItemForm(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + itemID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + + item, err := web.GetWorkItemByID(db, itemID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Item not found", http.StatusNotFound) + } else { + log.Printf("DB error fetching item %d for edit: %v", itemID, err) + http.Error(w, "Failed to load item", http.StatusInternalServerError) + } + return + } + + if err := templates.ItemEditForm(item.ID, item.Description, item.Status).Render(r.Context(), w); err != nil { + log.Printf("Error rendering edit form: %v", err) + } + } +} + +// ViewItemRow returns a single item row (used by cancel edit to restore the row). +func ViewItemRow(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + itemID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + + item, err := web.GetWorkItemByID(db, itemID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Item not found", http.StatusNotFound) + } else { + log.Printf("DB error fetching item %d: %v", itemID, err) + http.Error(w, "Failed to load item", http.StatusInternalServerError) + } + return + } + + // Get classification for confidence score + cls, _ := web.GetLatestClassification(db, itemID) + + itemData := templates.ItemData{ + ID: item.ID, + Description: item.Description, + Author: item.Author, + Status: item.Status, + Source: item.Source, + SourceRef: item.SourceRef, + Confidence: cls.Confidence, + SectionID: cls.SectionID, + TicketIDs: item.TicketIDs, + } + + isManager := middleware.IsManager(r) + // Pass empty sections list — reclassify dropdown won't show on the restored row + // (page reload will restore it fully) + if err := templates.ItemRow(itemData, nil, isManager).Render(r.Context(), w); err != nil { + log.Printf("Error rendering item row: %v", err) + } + } +} + +// GenerateReport starts async report generation. +func GenerateReport(cfg web.Config, db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Extract params before spawning goroutine (don't capture *http.Request in closure) + weekParam := r.URL.Query().Get("week") + monday, _, _, _ := resolveWeek(cfg, weekParam) + + jobID := fmt.Sprintf("gen-%d", rand.Int63()) + job := &generateJob{status: "running", message: "Starting report generation..."} + generateJobs.Store(jobID, job) + + go func() { + report.GenerationMu.Lock() + defer report.GenerationMu.Unlock() + + from := monday + to := monday.AddDate(0, 0, 7) + + job.update("running", "Loading items...") + items, err := web.GetItemsByDateRange(db, from, to) + if err != nil { + job.update("error", "Failed to load items. Check server logs.") + log.Printf("Generate: failed to load items: %v", err) + return + } + + job.update("running", "Classifying items...") + corrections, err := web.GetRecentCorrections(db, monday.AddDate(0, -1, 0), 200) + if err != nil { + log.Printf("Generate: warning: failed to load corrections (proceeding without): %v", err) + } + historicalItems, err := web.GetClassifiedItemsWithSections(db, monday.AddDate(0, -3, 0), 500) + if err != nil { + log.Printf("Generate: warning: failed to load historical items (proceeding without): %v", err) + } + + result, err := web.BuildReportsFromLast(cfg, items, monday, corrections, historicalItems) + if err != nil { + job.update("error", "Classification failed. Check server logs.") + log.Printf("Generate: classification failed: %v", err) + return + } + + job.update("running", "Writing report files...") + md := web.RenderMarkdownByMode(result.Template, "team") + friday := domain.FridayOfWeek(monday) + path, err := web.WriteReportFile(md, cfg.ReportOutputDir, friday, cfg.TeamName) + if err != nil { + job.update("error", "Failed to write report. Check server logs.") + log.Printf("Generate: failed to write report: %v", err) + return + } + + invalidateCache() + job.setDone(fmt.Sprintf("Report generated: %s", path), path) + }() + + // Return polling element with HTML-escaped jobID + w.Header().Set("Content-Type", "text/html; charset=utf-8") + safeID := html.EscapeString(jobID) + fmt.Fprintf(w, `
Starting report generation...
`, safeID) + } +} + +// GenerateStatus returns the current status of a generation job. +func GenerateStatus() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + jobID := chi.URLParam(r, "jobID") + val, ok := generateJobs.Load(jobID) + if !ok { + http.Error(w, "Job not found", http.StatusNotFound) + return + } + + job := val.(*generateJob) + status, message, _ := job.read() + safeID := html.EscapeString(jobID) + safeMsg := html.EscapeString(message) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + switch status { + case "running": + fmt.Fprintf(w, `
%s
`, safeID, safeMsg) + case "done": + generateJobs.Delete(jobID) + fmt.Fprintf(w, `
%s
`, safeMsg) + case "error": + generateJobs.Delete(jobID) + fmt.Fprintf(w, `
%s
`, safeMsg) + } + } +} + +// NewCategoryForm returns the inline form for adding a new category. +func NewCategoryForm() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + weekParam := r.URL.Query().Get("week") + if err := templates.CategoryForm(weekParam).Render(r.Context(), w); err != nil { + log.Printf("Error rendering category form: %v", err) + } + } +} + +// CreateCategory creates a new custom category section. +func CreateCategory(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Category name is required", http.StatusBadRequest) + return + } + + // Generate a unique section ID for the custom category + sectionID := fmt.Sprintf("CUSTOM_%d", time.Now().UnixMilli()) + + // Insert a placeholder classification record so the section appears in DB queries. + // We use work_item_id=0 as a sentinel — it won't match any real item. + _, err := db.Exec( + `INSERT INTO classification_history + (work_item_id, section_id, section_label, confidence, llm_provider, llm_model) + VALUES (0, ?, ?, 1.0, 'manual', 'user')`, + sectionID, name, + ) + if err != nil { + log.Printf("Error creating category: %v", err) + http.Error(w, "Failed to create category", http.StatusInternalServerError) + return + } + + invalidateCache() + + // Return empty section card + clear the form — no full page reload + isManager := middleware.IsManager(r) + section := templates.SectionData{ID: sectionID, Name: name} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + allSections := loadAllSectionsForDropdown(db) + if err := templates.SectionGroup(section, allSections, isManager).Render(r.Context(), w); err != nil { + log.Printf("Error rendering new section: %v", err) + } + } +} + +// RenameCategoryForm returns an inline rename form for a section. +func RenameCategoryForm(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sectionID := chi.URLParam(r, "id") + weekParam := r.URL.Query().Get("week") + + // Look up current name + labels, _ := web.GetAllSectionLabels(db) + currentName := sectionID + if name, ok := labels[sectionID]; ok { + currentName = name + } + + if err := templates.SectionRenameForm(sectionID, currentName, weekParam).Render(r.Context(), w); err != nil { + log.Printf("Error rendering rename form: %v", err) + } + } +} + +// CancelRename returns the original section name span (restores after rename form cancel). +func CancelRename(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sectionID := chi.URLParam(r, "id") + labels, _ := web.GetAllSectionLabels(db) + name := sectionID + if l, ok := labels[sectionID]; ok { + name = l + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `%s`, + html.EscapeString(sectionID), html.EscapeString(name)) + } +} + +// RenameCategory updates a section's display label. +func RenameCategory(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sectionID := chi.URLParam(r, "id") + newName := strings.TrimSpace(r.FormValue("name")) + if newName == "" { + http.Error(w, "Category name is required", http.StatusBadRequest) + return + } + + if err := web.RenameSectionLabel(db, sectionID, newName); err != nil { + log.Printf("Error renaming category %s: %v", sectionID, err) + http.Error(w, "Failed to rename category", http.StatusInternalServerError) + return + } + + invalidateCache() + + // Return just the updated name span — swaps in place without reload + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `%s`, + html.EscapeString(sectionID), html.EscapeString(newName)) + } +} + +// --- Helpers --- + +// buildAllSections merges current sections with all known section labels from DB. +// This ensures the reclassify dropdown includes sections that have no items this week. +// Parent sections that have subcategories (name contains " > ") are hidden from the dropdown. +func buildAllSections(db *sql.DB, currentSections []templates.SectionData) []templates.SectionData { + allLabels, err := web.GetAllSectionLabels(db) + if err != nil { + log.Printf("Warning: failed to load section labels: %v", err) + return currentSections + } + + // Collect all label names to detect parent/child relationships + allNames := make(map[string]bool) + for _, label := range allLabels { + allNames[label] = true + } + for _, s := range currentSections { + allNames[s.Name] = true + } + + // Build set of parent names that have subcategories + parentsWithChildren := make(map[string]bool) + for name := range allNames { + if idx := strings.Index(name, " > "); idx > 0 { + parent := name[:idx] + parentsWithChildren[parent] = true + } + } + + // Filter: skip parent sections that have subcategories + shouldInclude := func(name string) bool { + return !parentsWithChildren[name] + } + + // Start with current sections (filtered) + seen := make(map[string]bool) + var result []templates.SectionData + for _, s := range currentSections { + seen[s.ID] = true + if shouldInclude(s.Name) { + result = append(result, s) + } + } + + // Add any sections from DB that aren't already present (filtered) + var extra []templates.SectionData + for id, label := range allLabels { + if !seen[id] && id != "UND" && shouldInclude(label) { + extra = append(extra, templates.SectionData{ID: id, Name: label}) + seen[id] = true + } + } + + // Sort extra sections + sort.Slice(extra, func(i, j int) bool { + return extra[i].ID < extra[j].ID + }) + + // Deduplicate by name (same name can appear under different section IDs) + combined := append(result, extra...) + seenNames := make(map[string]bool) + var deduped []templates.SectionData + for _, s := range combined { + if !seenNames[s.Name] { + seenNames[s.Name] = true + deduped = append(deduped, s) + } + } + + return deduped +} + +func resolveWeek(cfg web.Config, weekParam string) (monday time.Time, label, prevWeek, nextWeek string) { + now := time.Now() + if cfg.Location != nil { + now = now.In(cfg.Location) + } + + if weekParam != "" { + if parsed, err := time.Parse("2006-01-02", weekParam); err == nil { + // Round to Monday + for parsed.Weekday() != time.Monday { + parsed = parsed.AddDate(0, 0, -1) + } + monday = parsed + } + } + + if monday.IsZero() { + // Use ReportWeekRange to match Slack behavior (including monday_cutoff_time) + start, _ := web.ReportWeekRange(cfg, now) + monday = start + } + + sunday := monday.AddDate(0, 0, 6) + label = fmt.Sprintf("%s - %s, %d", monday.Format("Jan 2"), sunday.Format("Jan 2"), monday.Year()) + prevWeek = monday.AddDate(0, 0, -7).Format("2006-01-02") + nextWeek = monday.AddDate(0, 0, 7).Format("2006-01-02") + return +} + +// buildSectionsFromDB groups items using their existing classifications from the DB. +// No LLM calls — fast page loads. Items without classifications go to "Unclassified". +func buildSectionsFromDB(db *sql.DB, items []web.WorkItem) ([]templates.SectionData, float64, bool) { + if len(items) == 0 { + return nil, 0, false + } + + // Batch-fetch existing classifications + ids := make([]int64, len(items)) + for i, item := range items { + ids[i] = item.ID + } + classifications, err := web.GetLatestClassificationsForItems(db, ids) + if err != nil { + log.Printf("Warning: failed to load classifications from DB: %v", err) + return buildUnclassifiedSection(items), 0, false + } + + hasClassifications := len(classifications) > 0 + + // Group items by section + sectionMap := make(map[string]*templates.SectionData) + var sectionOrder []string + var totalConf float64 + var confCount int + + for _, item := range items { + cls, classified := classifications[item.ID] + sectionID := "UND" + sectionLabel := "Unclassified" + conf := 0.0 + + if classified { + sectionID = cls.SectionID + sectionLabel = cls.SectionLabel + if sectionLabel == "" { + sectionLabel = sectionID + } + conf = cls.Confidence + totalConf += conf + confCount++ + } + + sec, exists := sectionMap[sectionID] + if !exists { + sec = &templates.SectionData{ + ID: sectionID, + Name: sectionLabel, + } + sectionMap[sectionID] = sec + sectionOrder = append(sectionOrder, sectionID) + } + + if conf < 0.7 { + sec.NeedsReview = true + } + + sec.Items = append(sec.Items, templates.ItemData{ + ID: item.ID, + Description: item.Description, + Author: item.Author, + Status: item.Status, + Source: item.Source, + SourceRef: item.SourceRef, + Confidence: conf, + SectionID: sectionID, + TicketIDs: item.TicketIDs, + }) + } + + // Sort sections by section_id to preserve template ordering. + // Section IDs like S0_0, S0_1, S1_0 sort lexicographically in template order. + // "UND" and "CUSTOM_*" go last. + sort.Slice(sectionOrder, func(i, j int) bool { + a, b := sectionOrder[i], sectionOrder[j] + if a == "UND" { + return false + } + if b == "UND" { + return true + } + aCustom := strings.HasPrefix(a, "CUSTOM_") + bCustom := strings.HasPrefix(b, "CUSTOM_") + if aCustom != bCustom { + return !aCustom // template sections before custom + } + return a < b + }) + + var sections []templates.SectionData + for _, id := range sectionOrder { + sections = append(sections, *sectionMap[id]) + } + + // Add empty custom categories so they show as visible section cards + allLabels, err := web.GetAllSectionLabels(db) + if err == nil { + seen := make(map[string]bool) + for _, id := range sectionOrder { + seen[id] = true + } + var customSections []templates.SectionData + for id, label := range allLabels { + if !seen[id] && strings.HasPrefix(id, "CUSTOM_") { + customSections = append(customSections, templates.SectionData{ + ID: id, + Name: label, + }) + } + } + sort.Slice(customSections, func(i, j int) bool { + return customSections[i].ID < customSections[j].ID + }) + // Insert custom sections before UND + if len(sections) > 0 && sections[len(sections)-1].ID == "UND" { + und := sections[len(sections)-1] + sections = append(sections[:len(sections)-1], customSections...) + sections = append(sections, und) + } else { + sections = append(sections, customSections...) + } + } + + avgConf := 0.0 + if confCount > 0 { + avgConf = totalConf / float64(confCount) + } + + return sections, avgConf, hasClassifications +} + +// classifyWithLLM runs the full LLM classification pipeline. +// Only called on explicit "Classify" or "Re-classify" button click. +func classifyWithLLM(cfg web.Config, db *sql.DB, items []web.WorkItem, monday time.Time) ([]templates.SectionData, float64) { + if len(items) == 0 { + return nil, 0 + } + + cacheKey := monday.Format("2006-01-02") + + // Check cache first (from a recent classify action) + if cached, ok := buildResultCache.Load(cacheKey); ok { + cr := cached.(*cachedResult) + if time.Since(cr.created) < 5*time.Minute { + return convertBuildResult(cr.result, items), avgConfidence(cr.result.Decisions) + } + buildResultCache.Delete(cacheKey) + } + + corrections, err := web.GetRecentCorrections(db, monday.AddDate(0, -1, 0), 200) + if err != nil { + log.Printf("Warning: failed to load corrections (proceeding without): %v", err) + } + historicalItems, err := web.GetClassifiedItemsWithSections(db, monday.AddDate(0, -3, 0), 500) + if err != nil { + log.Printf("Warning: failed to load historical items (proceeding without): %v", err) + } + + result, err := web.BuildReportsFromLast(cfg, items, monday, corrections, historicalItems) + if err != nil { + log.Printf("BuildReportsFromLast failed (showing unclassified): %v", err) + return buildUnclassifiedSection(items), 0 + } + + // Cache the result + buildResultCache.Store(cacheKey, &cachedResult{result: result, created: time.Now()}) + + return convertBuildResult(result, items), avgConfidence(result.Decisions) +} + +func buildUnclassifiedSection(items []web.WorkItem) []templates.SectionData { + var itemData []templates.ItemData + for _, item := range items { + itemData = append(itemData, toItemData(item, "UND", 0)) + } + return []templates.SectionData{{ + ID: "UND", + Name: "Unclassified", + Items: itemData, + NeedsReview: true, + }} +} + +func convertBuildResult(result web.BuildResult, items []web.WorkItem) []templates.SectionData { + if result.Template == nil { + return nil + } + + // Build item lookup + itemMap := make(map[string]web.WorkItem) + for _, item := range items { + itemMap[strings.ToLower(strings.TrimSpace(item.Description))] = item + } + + var sections []templates.SectionData + for ci, cat := range result.Template.Categories { + if cat.MarkerLine != "" { + continue + } + sectionID := fmt.Sprintf("S%d", ci) + if len(result.Options) > ci { + sectionID = result.Options[ci].ID + } + + var sectionItems []templates.ItemData + needsReview := false + + for _, sub := range cat.Subsections { + for _, tItem := range sub.Items { + if !tItem.IsNew { + continue // Skip carry-over items from template + } + // Find the original item to get metadata + key := strings.ToLower(strings.TrimSpace(tItem.Description)) + origItem, found := itemMap[key] + + conf := 0.0 + source := "slack" + sourceRef := "" + var id int64 + + if found { + id = origItem.ID + source = origItem.Source + sourceRef = origItem.SourceRef + if d, ok := result.Decisions[origItem.ID]; ok { + conf = d.Confidence + } + } else { + // Item not found in map — skip rendering mutation controls + // by leaving ID as 0 (template checks for this) + log.Printf("Warning: template item %q not found in item map, skipping mutation controls", tItem.Description) + } + + if conf < 0.7 { + needsReview = true + } + + sectionItems = append(sectionItems, templates.ItemData{ + ID: id, + Description: tItem.Description, + Author: tItem.Author, + Status: tItem.Status, + Source: source, + SourceRef: sourceRef, + Confidence: conf, + SectionID: sectionID, + TicketIDs: tItem.TicketIDs, + IsNew: tItem.IsNew, + }) + } + } + + if len(sectionItems) == 0 { + continue + } + + sections = append(sections, templates.SectionData{ + ID: sectionID, + Name: cat.Name, + Items: sectionItems, + NeedsReview: needsReview, + }) + } + + return sections +} + +func toItemData(item web.WorkItem, sectionID string, conf float64) templates.ItemData { + return templates.ItemData{ + ID: item.ID, + Description: item.Description, + Author: item.Author, + Status: item.Status, + Source: item.Source, + SourceRef: item.SourceRef, + Confidence: conf, + SectionID: sectionID, + TicketIDs: item.TicketIDs, + } +} + +func avgConfidence(decisions map[int64]web.LLMSectionDecision) float64 { + if len(decisions) == 0 { + return 0 + } + var total float64 + for _, d := range decisions { + total += d.Confidence + } + return total / float64(len(decisions)) +} + +// loadAllSectionsForDropdown returns all known sections for the reclassify dropdown. +// Excludes parent sections that have subcategories (e.g., "Release and Support" is hidden +// if "Release and Support > FAZ-BD 7.6.2 release" exists). +func loadAllSectionsForDropdown(db *sql.DB) []templates.SectionData { + labels, err := web.GetAllSectionLabels(db) + if err != nil { + return nil + } + + // Find parents that have children + parentsWithChildren := make(map[string]bool) + for _, label := range labels { + if idx := strings.Index(label, " > "); idx > 0 { + parentsWithChildren[label[:idx]] = true + } + } + + // Build filtered, deduped list + seenNames := make(map[string]bool) + var sections []templates.SectionData + for id, label := range labels { + if parentsWithChildren[label] { + continue // skip parent that has subcategories + } + if seenNames[label] { + continue // skip duplicate names + } + seenNames[label] = true + sections = append(sections, templates.SectionData{ID: id, Name: label}) + } + sort.Slice(sections, func(i, j int) bool { return sections[i].ID < sections[j].ID }) + return sections +} + +func filterByAuthorID(items []web.WorkItem, userID string) []web.WorkItem { + var filtered []web.WorkItem + for _, item := range items { + if item.AuthorID == userID { + filtered = append(filtered, item) + } + } + return filtered +} + +// simpleMarkdownToHTML converts report markdown to readable HTML. +// Handles the heading/bullet/bold patterns used by ReportBot reports. +func simpleMarkdownToHTML(md string) string { + var buf strings.Builder + lines := strings.Split(md, "\n") + inList := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Close list if needed + if inList && !strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, " - ") { + buf.WriteString("\n") + inList = false + } + + switch { + case trimmed == "": + if !inList { + buf.WriteString("
\n") + } + case strings.HasPrefix(trimmed, "#### "): + buf.WriteString("

" + html.EscapeString(strings.TrimPrefix(trimmed, "#### ")) + "

\n") + case strings.HasPrefix(trimmed, "### "): + buf.WriteString("

" + html.EscapeString(strings.TrimPrefix(trimmed, "### ")) + "

\n") + case strings.HasPrefix(trimmed, "## "): + buf.WriteString("

" + html.EscapeString(strings.TrimPrefix(trimmed, "## ")) + "

\n") + case strings.HasPrefix(trimmed, "# "): + buf.WriteString("

" + html.EscapeString(strings.TrimPrefix(trimmed, "# ")) + "

\n") + case strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, " - "): + if !inList { + buf.WriteString("\n") + } + + return buf.String() +} + +// inlineBold converts **text** to text in already-escaped HTML. +func inlineBold(s string) string { + result := s + for { + start := strings.Index(result, "**") + if start == -1 { + break + } + end := strings.Index(result[start+2:], "**") + if end == -1 { + break + } + end += start + 2 + inner := result[start+2 : end] + result = result[:start] + "" + inner + "" + result[end+2:] + } + return result +} diff --git a/internal/web/handlers/report_test.go b/internal/web/handlers/report_test.go new file mode 100644 index 0000000..fec54cc --- /dev/null +++ b/internal/web/handlers/report_test.go @@ -0,0 +1,677 @@ +package handlers + +import ( + "database/sql" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "reportbot/internal/domain" + "reportbot/internal/report" + "reportbot/internal/web" + mw "reportbot/internal/web/middleware" + + "github.com/go-chi/chi/v5" +) + +// --- Test helpers --- + +// testConfig returns a minimal Config for tests. +func testConfig() web.Config { + return web.Config{ + TeamName: "TestTeam", + ManagerSlackIDs: []string{"UMGR"}, + WebSessionSecret: "test-secret-32-bytes-long-xxxxx", + } +} + +// authMiddleware returns a chi-compatible middleware that injects auth context. +func authMiddleware(userID, userName string, isManager bool) func(http.Handler) http.Handler { + return mw.Auth( + func(cookie *http.Cookie) (mw.SessionPayload, error) { + return mw.SessionPayload{UserID: userID, UserName: userName}, nil + }, + func() *http.Cookie { + return &http.Cookie{Name: "reportbot_session", Value: "", Path: "/", MaxAge: -1} + }, + func(uid string) bool { + return isManager + }, + ) +} + +// sessionCookie returns a dummy session cookie so the auth middleware doesn't reject for missing cookie. +func sessionCookie() *http.Cookie { + return &http.Cookie{Name: "reportbot_session", Value: "test"} +} + +// saveDeps saves and returns a restore function for all overrideable deps. +type depsSnapshot struct { + GetItemsByDateRange func(*sql.DB, time.Time, time.Time) ([]web.WorkItem, error) + GetWorkItemByID func(*sql.DB, int64) (web.WorkItem, error) + GetRecentCorrections func(*sql.DB, time.Time, int) ([]web.ClassificationCorrection, error) + GetClassifiedItemsWithSections func(*sql.DB, time.Time, int) ([]domain.HistoricalItem, error) + BuildReportsFromLast func(web.Config, []web.WorkItem, time.Time, []web.ClassificationCorrection, []domain.HistoricalItem) (web.BuildResult, error) + RenderMarkdownByMode func(*web.ReportTemplate, string) string + ReclassifyItem func(*sql.DB, web.ClassificationCorrection, string) error + UpdateWorkItemTextAndStatus func(*sql.DB, int64, string, string) error + DeleteWorkItemByID func(*sql.DB, int64) error + WriteReportFile func(string, string, time.Time, string) (string, error) + IsManagerID func(web.Config, string) bool + ReportWeekRange func(web.Config, time.Time) (time.Time, time.Time) + GetLatestClassificationsForItems func(*sql.DB, []int64) (map[int64]web.ClassificationRecord, error) + GetAllSectionLabels func(*sql.DB) (map[string]string, error) +} + +func saveDeps() (restore func()) { + snap := depsSnapshot{ + GetItemsByDateRange: web.GetItemsByDateRange, + GetWorkItemByID: web.GetWorkItemByID, + GetRecentCorrections: web.GetRecentCorrections, + GetClassifiedItemsWithSections: web.GetClassifiedItemsWithSections, + BuildReportsFromLast: web.BuildReportsFromLast, + RenderMarkdownByMode: web.RenderMarkdownByMode, + ReclassifyItem: web.ReclassifyItem, + UpdateWorkItemTextAndStatus: web.UpdateWorkItemTextAndStatus, + DeleteWorkItemByID: web.DeleteWorkItemByID, + WriteReportFile: web.WriteReportFile, + IsManagerID: web.IsManagerID, + ReportWeekRange: web.ReportWeekRange, + GetLatestClassificationsForItems: web.GetLatestClassificationsForItems, + GetAllSectionLabels: web.GetAllSectionLabels, + } + return func() { + web.GetItemsByDateRange = snap.GetItemsByDateRange + web.GetWorkItemByID = snap.GetWorkItemByID + web.GetRecentCorrections = snap.GetRecentCorrections + web.GetClassifiedItemsWithSections = snap.GetClassifiedItemsWithSections + web.BuildReportsFromLast = snap.BuildReportsFromLast + web.RenderMarkdownByMode = snap.RenderMarkdownByMode + web.ReclassifyItem = snap.ReclassifyItem + web.UpdateWorkItemTextAndStatus = snap.UpdateWorkItemTextAndStatus + web.DeleteWorkItemByID = snap.DeleteWorkItemByID + web.WriteReportFile = snap.WriteReportFile + web.IsManagerID = snap.IsManagerID + web.ReportWeekRange = snap.ReportWeekRange + web.GetLatestClassificationsForItems = snap.GetLatestClassificationsForItems + web.GetAllSectionLabels = snap.GetAllSectionLabels + } +} + +// stubEmptyBuild stubs out the build pipeline to return empty/no-op results. +func stubEmptyBuild() { + web.GetLatestClassificationsForItems = func(db *sql.DB, ids []int64) (map[int64]web.ClassificationRecord, error) { + return nil, nil + } + web.GetAllSectionLabels = func(db *sql.DB) (map[string]string, error) { + return nil, nil + } + web.GetRecentCorrections = func(db *sql.DB, since time.Time, limit int) ([]web.ClassificationCorrection, error) { + return nil, nil + } + web.GetClassifiedItemsWithSections = func(db *sql.DB, since time.Time, limit int) ([]domain.HistoricalItem, error) { + return nil, nil + } + web.BuildReportsFromLast = func(cfg web.Config, items []web.WorkItem, reportDate time.Time, corrections []web.ClassificationCorrection, historicalItems []domain.HistoricalItem) (web.BuildResult, error) { + return web.BuildResult{ + Template: &report.ReportTemplate{ + Categories: []report.TemplateCategory{ + { + Name: "Engineering", + Subsections: []report.TemplateSubsection{ + { + Name: "General", + Items: []report.TemplateItem{ + {Author: "Alice", Description: "Fix login bug", Status: "done", IsNew: true}, + }, + }, + }, + }, + }, + }, + Decisions: map[int64]web.LLMSectionDecision{ + 1: {SectionID: "S0_0", Confidence: 0.95}, + }, + }, nil + } + web.ReportWeekRange = func(cfg web.Config, now time.Time) (time.Time, time.Time) { + monday := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC) + return monday, monday.AddDate(0, 0, 7) + } +} + +// --- Tests --- + +func TestEditorPage_LoadsItemsGroupedBySection(t *testing.T) { + restore := saveDeps() + defer restore() + + web.GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]web.WorkItem, error) { + return []web.WorkItem{ + {ID: 1, Description: "Fix login bug", Author: "Alice", AuthorID: "UMGR", Source: "slack", Status: "done"}, + {ID: 2, Description: "Add search feature", Author: "Bob", AuthorID: "UMGR", Source: "gitlab", Status: "in progress"}, + }, nil + } + stubEmptyBuild() + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Get("/", ReportEditorPage(cfg, nil)) + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "Weekly Report") { + t.Error("expected response to contain 'Weekly Report'") + } +} + +func TestEditorPage_EmptyWeek(t *testing.T) { + restore := saveDeps() + defer restore() + + web.GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]web.WorkItem, error) { + return nil, nil + } + stubEmptyBuild() + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Get("/", ReportEditorPage(cfg, nil)) + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + body := rr.Body.String() + // With no items, ItemCount in the rendered template should be 0 + if strings.Contains(body, "Engineering") { + t.Error("expected no sections to be rendered for an empty week") + } +} + +func TestEditorPage_WeekParameter(t *testing.T) { + restore := saveDeps() + defer restore() + + var capturedFrom, capturedTo time.Time + web.GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]web.WorkItem, error) { + capturedFrom = from + capturedTo = to + return nil, nil + } + stubEmptyBuild() + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Get("/", ReportEditorPage(cfg, nil)) + + req := httptest.NewRequest("GET", "/?week=2026-03-16", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + expectedMonday := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC) + if !capturedFrom.Equal(expectedMonday) { + t.Errorf("expected from=%v, got %v", expectedMonday, capturedFrom) + } + expectedTo := expectedMonday.AddDate(0, 0, 7) + if !capturedTo.Equal(expectedTo) { + t.Errorf("expected to=%v, got %v", expectedTo, capturedTo) + } +} + +func TestEditorPage_InvalidWeekParam(t *testing.T) { + restore := saveDeps() + defer restore() + + web.GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]web.WorkItem, error) { + return nil, nil + } + stubEmptyBuild() + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Get("/", ReportEditorPage(cfg, nil)) + + req := httptest.NewRequest("GET", "/?week=garbage", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + // Should not fail; should fall back to the current week + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 for invalid week param, got %d", rr.Code) + } +} + +func TestReclassifyItem_Success(t *testing.T) { + restore := saveDeps() + defer restore() + + var reclassifyCalled bool + var capturedNewSection string + web.GetWorkItemByID = func(db *sql.DB, id int64) (web.WorkItem, error) { + return web.WorkItem{ID: 42, Description: "Test item", Category: "S0_0"}, nil + } + web.ReclassifyItem = func(db *sql.DB, c web.ClassificationCorrection, newCategory string) error { + reclassifyCalled = true + capturedNewSection = newCategory + return nil + } + web.GetAllSectionLabels = func(db *sql.DB) (map[string]string, error) { + return map[string]string{"S0_0": "Infra", "S1_0": "Backend"}, nil + } + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Post("/items/{id}/reclassify", ReclassifyItemHandler(cfg, nil)) + + body := strings.NewReader("section_id=S1_0") + req := httptest.NewRequest("POST", "/items/42/reclassify", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !reclassifyCalled { + t.Error("expected ReclassifyItem to be called") + } + if capturedNewSection != "S1_0" { + t.Errorf("expected new section 'S1_0', got %q", capturedNewSection) + } +} + +func TestReclassifyItem_InvalidSectionID(t *testing.T) { + restore := saveDeps() + defer restore() + + web.GetWorkItemByID = func(db *sql.DB, id int64) (web.WorkItem, error) { + return web.WorkItem{ID: 42}, nil + } + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Post("/items/{id}/reclassify", ReclassifyItemHandler(cfg, nil)) + + body := strings.NewReader("section_id=") + req := httptest.NewRequest("POST", "/items/42/reclassify", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty section_id, got %d", rr.Code) + } +} + +func TestReclassifyItem_NonManagerForbidden(t *testing.T) { + restore := saveDeps() + defer restore() + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UDEV1", "dev", false)) + r.Use(mw.RequireManager) + r.Post("/items/{id}/reclassify", ReclassifyItemHandler(cfg, nil)) + + body := strings.NewReader("section_id=S1_0") + req := httptest.NewRequest("POST", "/items/42/reclassify", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-manager, got %d", rr.Code) + } +} + +func TestUpdateItem_Success(t *testing.T) { + restore := saveDeps() + defer restore() + + var capturedID int64 + var capturedDesc, capturedStatus string + web.UpdateWorkItemTextAndStatus = func(db *sql.DB, id int64, desc, status string) error { + capturedID = id + capturedDesc = desc + capturedStatus = status + return nil + } + web.GetWorkItemByID = func(db *sql.DB, id int64) (web.WorkItem, error) { + return web.WorkItem{ID: id, Description: "Updated description", Author: "Test", Status: "in progress"}, nil + } + web.GetLatestClassification = func(db *sql.DB, id int64) (web.ClassificationRecord, error) { + return web.ClassificationRecord{SectionID: "S0_0", Confidence: 0.9}, nil + } + web.GetAllSectionLabels = func(db *sql.DB) (map[string]string, error) { + return map[string]string{"S0_0": "Test Section"}, nil + } + + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Post("/items/{id}", UpdateItemHandler(nil)) + + body := strings.NewReader("description=Updated+description&status=in+progress") + req := httptest.NewRequest("POST", "/items/99", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if capturedID != 99 { + t.Errorf("expected item ID 99, got %d", capturedID) + } + if capturedDesc != "Updated description" { + t.Errorf("expected description 'Updated description', got %q", capturedDesc) + } + if capturedStatus != "in progress" { + t.Errorf("expected status 'in progress', got %q", capturedStatus) + } +} + +func TestUpdateItem_EmptyDescription(t *testing.T) { + restore := saveDeps() + defer restore() + + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Post("/items/{id}", UpdateItemHandler(nil)) + + body := strings.NewReader("description=&status=done") + req := httptest.NewRequest("POST", "/items/99", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for empty description, got %d", rr.Code) + } +} + +func TestDeleteItem_Success(t *testing.T) { + restore := saveDeps() + defer restore() + + var deletedID int64 + web.DeleteWorkItemByID = func(db *sql.DB, id int64) error { + deletedID = id + return nil + } + + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Delete("/items/{id}", DeleteItemHandler(nil)) + + req := httptest.NewRequest("DELETE", "/items/55", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if deletedID != 55 { + t.Errorf("expected deleted ID 55, got %d", deletedID) + } +} + +func TestDeleteItem_NonManagerForbidden(t *testing.T) { + restore := saveDeps() + defer restore() + + r := chi.NewRouter() + r.Use(authMiddleware("UDEV1", "dev", false)) + r.Use(mw.RequireManager) + r.Delete("/items/{id}", DeleteItemHandler(nil)) + + req := httptest.NewRequest("DELETE", "/items/55", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-manager, got %d", rr.Code) + } +} + +func TestPreviewMarkdown_TeamMode(t *testing.T) { + restore := saveDeps() + defer restore() + + web.GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]web.WorkItem, error) { + return []web.WorkItem{ + {ID: 1, Description: "Test item", Author: "Alice", Source: "slack", Status: "done"}, + }, nil + } + web.GetRecentCorrections = func(db *sql.DB, since time.Time, limit int) ([]web.ClassificationCorrection, error) { + return nil, nil + } + web.GetClassifiedItemsWithSections = func(db *sql.DB, since time.Time, limit int) ([]domain.HistoricalItem, error) { + return nil, nil + } + web.BuildReportsFromLast = func(cfg web.Config, items []web.WorkItem, reportDate time.Time, corrections []web.ClassificationCorrection, historicalItems []domain.HistoricalItem) (web.BuildResult, error) { + return web.BuildResult{ + Template: &report.ReportTemplate{}, + }, nil + } + var capturedMode string + web.RenderMarkdownByMode = func(t *web.ReportTemplate, mode string) string { + capturedMode = mode + return "# Team Report\n- Test item (done)" + } + web.ReportWeekRange = func(cfg web.Config, now time.Time) (time.Time, time.Time) { + monday := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC) + return monday, monday.AddDate(0, 0, 7) + } + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Get("/preview", PreviewMarkdown(cfg, nil)) + + req := httptest.NewRequest("GET", "/preview?mode=team&week=2026-03-16", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if capturedMode != "team" { + t.Errorf("expected mode 'team', got %q", capturedMode) + } + body := rr.Body.String() + if !strings.Contains(body, "Team Report") { + t.Error("expected rendered markdown to contain 'Team Report'") + } +} + +func TestPreviewMarkdown_BossMode(t *testing.T) { + restore := saveDeps() + defer restore() + + web.GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]web.WorkItem, error) { + return []web.WorkItem{ + {ID: 1, Description: "Test item", Author: "Alice", Source: "slack", Status: "done"}, + }, nil + } + web.GetRecentCorrections = func(db *sql.DB, since time.Time, limit int) ([]web.ClassificationCorrection, error) { + return nil, nil + } + web.GetClassifiedItemsWithSections = func(db *sql.DB, since time.Time, limit int) ([]domain.HistoricalItem, error) { + return nil, nil + } + web.BuildReportsFromLast = func(cfg web.Config, items []web.WorkItem, reportDate time.Time, corrections []web.ClassificationCorrection, historicalItems []domain.HistoricalItem) (web.BuildResult, error) { + return web.BuildResult{ + Template: &report.ReportTemplate{}, + }, nil + } + var capturedMode string + web.RenderMarkdownByMode = func(t *web.ReportTemplate, mode string) string { + capturedMode = mode + return "# Boss Report\n- Summary" + } + web.ReportWeekRange = func(cfg web.Config, now time.Time) (time.Time, time.Time) { + monday := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC) + return monday, monday.AddDate(0, 0, 7) + } + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Get("/preview", PreviewMarkdown(cfg, nil)) + + req := httptest.NewRequest("GET", "/preview?mode=boss&week=2026-03-16", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if capturedMode != "boss" { + t.Errorf("expected mode 'boss', got %q", capturedMode) + } + body := rr.Body.String() + if !strings.Contains(body, "Boss Report") { + t.Error("expected rendered markdown to contain 'Boss Report'") + } +} + +func TestGenerateReport_StartsJob(t *testing.T) { + // NOTE: GenerateReport spawns a background goroutine that uses deps. + // We must NOT defer restore() before the goroutine completes, or the + // goroutine will call real deps with nil db and panic. + // Instead, we keep deps overridden for the lifetime of this test. + restore := saveDeps() + defer restore() + + stubEmptyBuild() + web.GetItemsByDateRange = func(db *sql.DB, from, to time.Time) ([]web.WorkItem, error) { + return nil, nil + } + web.GetRecentCorrections = func(db *sql.DB, since time.Time, limit int) ([]web.ClassificationCorrection, error) { + return nil, nil + } + web.GetClassifiedItemsWithSections = func(db *sql.DB, since time.Time, limit int) ([]domain.HistoricalItem, error) { + return nil, nil + } + web.WriteReportFile = func(content, outputDir string, friday time.Time, teamName string) (string, error) { + return "/tmp/test_report.md", nil + } + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Post("/generate", GenerateReport(cfg, nil)) + + req := httptest.NewRequest("POST", "/generate?week=2026-03-16", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + body := rr.Body.String() + // The response should contain a polling div with hx-get for status + if !strings.Contains(body, "hx-get") { + t.Error("expected response to contain hx-get polling element") + } + if !strings.Contains(body, "generate-status") { + t.Error("expected response to contain generate-status class") + } + if !strings.Contains(body, "/status") { + t.Error("expected response to contain a /status URL for polling") + } + + // Wait briefly for background goroutine to complete (it uses stubbed deps, so it's fast) + time.Sleep(100 * time.Millisecond) +} + +func TestGenerateReport_PollStatus(t *testing.T) { + // Store a job directly in the generateJobs sync.Map + jobID := "test-job-123" + job := &generateJob{status: "done", message: "Report generated successfully!", path: "/tmp/report.md"} + generateJobs.Store(jobID, job) + defer generateJobs.Delete(jobID) + + r := chi.NewRouter() + r.Use(authMiddleware("UMGR", "manager", true)) + r.Get("/generate/{jobID}/status", GenerateStatus()) + + req := httptest.NewRequest("GET", fmt.Sprintf("/generate/%s/status", jobID), nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "Report generated successfully!") { + t.Errorf("expected success message in body, got: %s", body) + } + if !strings.Contains(body, "flash-success") { + t.Error("expected flash-success class in response") + } + + // Job should be cleaned up after reading "done" status + if _, ok := generateJobs.Load(jobID); ok { + t.Error("expected job to be deleted after returning done status") + } +} + +func TestGenerateReport_NonManagerForbidden(t *testing.T) { + restore := saveDeps() + defer restore() + + cfg := testConfig() + r := chi.NewRouter() + r.Use(authMiddleware("UDEV1", "dev", false)) + r.Use(mw.RequireManager) + r.Post("/generate", GenerateReport(cfg, nil)) + + req := httptest.NewRequest("POST", "/generate", nil) + req.AddCookie(sessionCookie()) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-manager, got %d", rr.Code) + } +} diff --git a/internal/web/handlers/server.go b/internal/web/handlers/server.go new file mode 100644 index 0000000..7892295 --- /dev/null +++ b/internal/web/handlers/server.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "database/sql" + "fmt" + "io/fs" + "log" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + + "reportbot/internal/web" + mw "reportbot/internal/web/middleware" +) + +// NewServer creates and configures the HTTP server with chi router. +func NewServer(cfg web.Config, db *sql.DB) *http.Server { + r := chi.NewRouter() + + // Built-in chi middleware + r.Use(chimw.Logger) + r.Use(chimw.Recoverer) + + // Derive Secure flag from base URL scheme + _ = strings.HasPrefix(cfg.WebBaseURL, "https://") + + // CSRF protection — double-submit cookie (replaces gorilla/csrf) + r.Use(web.CSRFMiddleware) + + // Static files (embedded in web package) + staticFS, err := fs.Sub(web.StaticFiles, "static") + if err != nil { + log.Fatalf("Failed to load embedded static files: %v", err) + } + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + // Auth middleware adapter — pass functions to break middleware <-> web import cycle + authMiddleware := mw.Auth( + func(cookie *http.Cookie) (mw.SessionPayload, error) { + payload, err := web.ValidateSessionCookie(cfg.WebSessionSecret, cookie) + if err != nil { + return mw.SessionPayload{}, err + } + return mw.SessionPayload{UserID: payload.UserID, UserName: payload.UserName}, nil + }, + func() *http.Cookie { return web.ClearSessionCookie() }, + func(userID string) bool { return web.IsManagerID(cfg, userID) }, + ) + + // Public routes + r.Group(func(r chi.Router) { + r.Get("/login", LoginPage(cfg)) + r.Get("/auth/slack/callback", SlackOAuthCallback(cfg)) + }) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + + r.Post("/logout", Logout()) + + // Report editor (read-only for non-managers, preview filters by author) + r.Get("/", ReportEditorPage(cfg, db)) + r.Get("/preview", PreviewMarkdown(cfg, db)) + + // Manager-only mutation routes + r.Group(func(r chi.Router) { + r.Use(mw.RequireManager) + + r.Post("/items/{id}/reclassify", ReclassifyItemHandler(cfg, db)) + r.Post("/items/{id}", UpdateItemHandler(db)) + r.Post("/items/{id}/delete", DeleteItemHandler(db)) + r.Get("/items/{id}/edit", EditItemForm(db)) + r.Get("/items/{id}/view", ViewItemRow(db)) + r.Get("/categories/new", NewCategoryForm()) + r.Post("/categories", CreateCategory(db)) + r.Get("/categories/{id}/rename", RenameCategoryForm(db)) + r.Get("/categories/{id}/cancel", CancelRename(db)) + r.Post("/categories/{id}/rename", RenameCategory(db)) + r.Post("/generate", GenerateReport(cfg, db)) + r.Get("/generate/{jobID}/status", GenerateStatus()) + }) + }) + + return &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.WebPort), + Handler: r, + } +} diff --git a/internal/web/middleware/auth.go b/internal/web/middleware/auth.go new file mode 100644 index 0000000..af9547e --- /dev/null +++ b/internal/web/middleware/auth.go @@ -0,0 +1,95 @@ +package middleware + +import ( + "context" + "net/http" +) + +type contextKey string + +const ( + userIDKey contextKey = "userID" + userNameKey contextKey = "userName" + isManagerKey contextKey = "isManager" +) + +// SessionPayload matches the web.SessionPayload structure. +type SessionPayload struct { + UserID string + UserName string +} + +// ValidateFunc validates a cookie and returns a session payload. +type ValidateFunc func(cookie *http.Cookie) (SessionPayload, error) + +// ClearCookieFunc returns a cookie that clears the session. +type ClearCookieFunc func() *http.Cookie + +// IsManagerFunc checks if a user ID is a manager. +type IsManagerFunc func(userID string) bool + +// Auth checks for a valid session cookie and populates the request context. +// Functions are passed as parameters to avoid import cycle with the web package. +func Auth(validate ValidateFunc, clearCookie ClearCookieFunc, isManager IsManagerFunc) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("reportbot_session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + payload, err := validate(cookie) + if err != nil { + http.SetCookie(w, clearCookie()) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + // Derive role per-request so config changes take effect immediately + manager := isManager(payload.UserID) + + ctx := r.Context() + ctx = context.WithValue(ctx, userIDKey, payload.UserID) + ctx = context.WithValue(ctx, userNameKey, payload.UserName) + ctx = context.WithValue(ctx, isManagerKey, manager) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// UserID returns the authenticated user's Slack ID from the request context. +func UserID(r *http.Request) string { + if v, ok := r.Context().Value(userIDKey).(string); ok { + return v + } + return "" +} + +// UserName returns the authenticated user's name from the request context. +func UserName(r *http.Request) string { + if v, ok := r.Context().Value(userNameKey).(string); ok { + return v + } + return "" +} + +// IsManager returns whether the authenticated user is a manager. +func IsManager(r *http.Request) bool { + if v, ok := r.Context().Value(isManagerKey).(bool); ok { + return v + } + return false +} + +// RequireManager middleware rejects non-manager requests with 403. +func RequireManager(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !IsManager(r) { + http.Error(w, "Forbidden: manager access required", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/web/middleware/auth_test.go b/internal/web/middleware/auth_test.go new file mode 100644 index 0000000..7c1bad9 --- /dev/null +++ b/internal/web/middleware/auth_test.go @@ -0,0 +1,238 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// mockValidateOK returns a valid session payload. +func mockValidateOK(userID, userName string) ValidateFunc { + return func(cookie *http.Cookie) (SessionPayload, error) { + return SessionPayload{UserID: userID, UserName: userName}, nil + } +} + +// mockValidateFail returns an error on validation. +func mockValidateFail() ValidateFunc { + return func(cookie *http.Cookie) (SessionPayload, error) { + return SessionPayload{}, http.ErrNoCookie + } +} + +// mockClearCookie returns a cookie that clears the session. +func mockClearCookie() ClearCookieFunc { + return func() *http.Cookie { + return &http.Cookie{ + Name: "reportbot_session", + Value: "", + Path: "/", + MaxAge: -1, + } + } +} + +// mockIsManager returns true if userID matches the given managerID. +func mockIsManager(managerID string) IsManagerFunc { + return func(userID string) bool { + return userID == managerID + } +} + +// validCookie returns a cookie with the session name. +func validCookie() *http.Cookie { + return &http.Cookie{ + Name: "reportbot_session", + Value: "test-session-value", + Path: "/", + Expires: time.Now().Add(24 * time.Hour), + } +} + +func TestAuthMiddleware_ValidCookie(t *testing.T) { + handlerCalled := false + var capturedUserID, capturedUserName string + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + capturedUserID = UserID(r) + capturedUserName = UserName(r) + w.WriteHeader(http.StatusOK) + }) + + middleware := Auth( + mockValidateOK("U123", "alice"), + mockClearCookie(), + mockIsManager("UMGR"), + ) + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(validCookie()) + rr := httptest.NewRecorder() + + middleware(inner).ServeHTTP(rr, req) + + if !handlerCalled { + t.Fatal("expected inner handler to be called") + } + if capturedUserID != "U123" { + t.Errorf("expected UserID 'U123', got %q", capturedUserID) + } + if capturedUserName != "alice" { + t.Errorf("expected UserName 'alice', got %q", capturedUserName) + } +} + +func TestAuthMiddleware_ExpiredCookie(t *testing.T) { + handlerCalled := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + }) + + middleware := Auth( + mockValidateFail(), + mockClearCookie(), + mockIsManager(""), + ) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(validCookie()) + rr := httptest.NewRecorder() + + middleware(inner).ServeHTTP(rr, req) + + if handlerCalled { + t.Fatal("expected inner handler NOT to be called for expired cookie") + } + if rr.Code != http.StatusFound { + t.Errorf("expected status %d, got %d", http.StatusFound, rr.Code) + } + loc := rr.Header().Get("Location") + if loc != "/login" { + t.Errorf("expected redirect to /login, got %q", loc) + } +} + +func TestAuthMiddleware_MissingCookie(t *testing.T) { + handlerCalled := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + }) + + middleware := Auth( + mockValidateOK("U123", "alice"), + mockClearCookie(), + mockIsManager(""), + ) + + req := httptest.NewRequest("GET", "/protected", nil) + // No cookie added + rr := httptest.NewRecorder() + + middleware(inner).ServeHTTP(rr, req) + + if handlerCalled { + t.Fatal("expected inner handler NOT to be called without cookie") + } + if rr.Code != http.StatusFound { + t.Errorf("expected status %d, got %d", http.StatusFound, rr.Code) + } + loc := rr.Header().Get("Location") + if loc != "/login" { + t.Errorf("expected redirect to /login, got %q", loc) + } +} + +func TestAuthMiddleware_TamperedCookie(t *testing.T) { + handlerCalled := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + }) + + middleware := Auth( + mockValidateFail(), // simulate HMAC mismatch + mockClearCookie(), + mockIsManager(""), + ) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{ + Name: "reportbot_session", + Value: "tampered-value", + }) + rr := httptest.NewRecorder() + + middleware(inner).ServeHTTP(rr, req) + + if handlerCalled { + t.Fatal("expected inner handler NOT to be called for tampered cookie") + } + if rr.Code != http.StatusFound { + t.Errorf("expected status %d, got %d", http.StatusFound, rr.Code) + } + // Should set a clear cookie + cookies := rr.Result().Cookies() + foundClear := false + for _, c := range cookies { + if c.Name == "reportbot_session" && c.MaxAge == -1 { + foundClear = true + } + } + if !foundClear { + t.Error("expected a session-clearing cookie to be set") + } +} + +func TestAuthMiddleware_ManagerRole(t *testing.T) { + var capturedIsManager bool + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedIsManager = IsManager(r) + w.WriteHeader(http.StatusOK) + }) + + middleware := Auth( + mockValidateOK("UMGR", "manager-alice"), + mockClearCookie(), + mockIsManager("UMGR"), // UMGR is a manager + ) + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(validCookie()) + rr := httptest.NewRecorder() + + middleware(inner).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if !capturedIsManager { + t.Error("expected IsManager to be true for manager user") + } +} + +func TestAuthMiddleware_NonManagerReadOnly(t *testing.T) { + var capturedIsManager bool + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedIsManager = IsManager(r) + w.WriteHeader(http.StatusOK) + }) + + middleware := Auth( + mockValidateOK("UDEV1", "developer-bob"), + mockClearCookie(), + mockIsManager("UMGR"), // Only UMGR is a manager + ) + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(validCookie()) + rr := httptest.NewRecorder() + + middleware(inner).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if capturedIsManager { + t.Error("expected IsManager to be false for non-manager user") + } +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 0000000..3faadf5 --- /dev/null +++ b/internal/web/static/style.css @@ -0,0 +1,168 @@ +/* ReportBot Web UI — data-dense dashboard, desktop-first + Design system: Blue data (#2563EB) + Slate text (#1E293B) + Amber highlights (#F97316) + Typography: System stack (Inter-like) for UI density + Spacing: 4/8px rhythm */ + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: #F8FAFC; + color: #1E293B; + line-height: 1.5; + font-size: 14px; +} + +/* Focus states — visible keyboard navigation (a11y critical) */ +:focus-visible { outline: 2px solid #2563EB; outline-offset: 2px; border-radius: 2px; } +button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid #2563EB; outline-offset: 2px; } + +/* Navigation */ +nav { + background: #0F172A; + color: #fff; + padding: 10px 24px; + display: flex; + align-items: center; + gap: 20px; +} + +nav .logo { font-weight: 700; font-size: 16px; letter-spacing: -0.3px; color: #F8FAFC; } +nav .nav-links { display: flex; gap: 4px; } +nav .nav-links a { color: #94A3B8; text-decoration: none; padding: 6px 10px; border-radius: 6px; font-size: 13px; font-weight: 500; transition: color 0.15s, background 0.15s; } +nav .nav-links a:hover { color: #E2E8F0; background: #1E293B; } +nav .nav-links a.active { color: #fff; background: #2563EB; } +nav .role-badge { margin-left: auto; font-size: 11px; padding: 2px 10px; border-radius: 10px; background: #1E293B; color: #94A3B8; font-weight: 500; } +nav .role-badge.manager { background: #166534; color: #DCFCE7; } + +/* Main content */ +main { max-width: 1600px; margin: 0 auto; padding: 20px 24px; } + +/* Header */ +.editor-header { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } +.editor-header h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; color: #0F172A; } +.week-nav { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #64748B; } +.week-nav a { background: none; border: 1px solid #CBD5E1; border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 13px; color: #475569; text-decoration: none; transition: all 0.15s; } +.week-nav a:hover { background: #E2E8F0; border-color: #94A3B8; } +.stats { margin-left: auto; display: flex; gap: 20px; font-size: 13px; color: #64748B; } +.stats .val { font-weight: 700; color: #0F172A; font-variant-numeric: tabular-nums; } + +/* Actions */ +.actions { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; } +.btn { padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 13px; font-weight: 500; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; transition: all 0.15s; } +.btn-primary { background: #2563EB; color: #fff; } +.btn-primary:hover { background: #1D4ED8; } +.btn-secondary { background: #fff; color: #334155; border: 1px solid #CBD5E1; } +.btn-secondary:hover { background: #F1F5F9; border-color: #94A3B8; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* Layout: editor + preview with draggable divider */ +.editor-layout { display: flex; align-items: flex-start; } +.editor-sections { flex: 1; min-width: 200px; overflow: hidden; } +.resize-handle { width: 6px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; margin: 0 2px; align-self: stretch; min-height: 300px; } +.resize-handle::after { content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 4px; height: 40px; background: #CBD5E1; border-radius: 2px; transition: background 0.15s; } +.resize-handle:hover::after, .resize-handle.dragging::after { background: #2563EB; width: 4px; height: 60px; } +.preview-panel { width: 520px; min-width: 200px; max-width: 80vw; flex-shrink: 0; background: #fff; border: 1px solid #E2E8F0; border-radius: 8px; padding: 16px; position: sticky; top: 16px; max-height: 85vh; overflow-y: auto; overflow-x: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); } +.preview-panel h3 { font-size: 11px; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 600; margin-bottom: 12px; } +.preview-panel pre, .preview-raw { white-space: pre-wrap; font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 12px; line-height: 1.7; color: #334155; } +.preview-body { font-size: 13px; line-height: 1.6; color: #1E293B; } +.preview-rendered h1 { font-size: 18px; font-weight: 700; margin: 16px 0 8px; border-bottom: 1px solid #E2E8F0; padding-bottom: 6px; color: #0F172A; } +.preview-rendered h2 { font-size: 16px; font-weight: 600; margin: 12px 0 6px; color: #1E293B; } +.preview-rendered h3 { font-size: 14px; font-weight: 600; margin: 10px 0 4px; text-transform: none; letter-spacing: 0; color: #1E293B; } +.preview-rendered h4 { font-size: 13px; font-weight: 600; margin: 8px 0 4px; color: #334155; } +.preview-rendered ul { margin: 4px 0 8px 16px; padding: 0; } +.preview-rendered li { margin: 3px 0; font-size: 12px; color: #475569; line-height: 1.6; } +.preview-rendered p { margin: 4px 0; } +.preview-rendered br { display: block; margin: 2px 0; } + +/* Section cards */ +.section { background: #fff; border: 1px solid #E2E8F0; border-radius: 8px; margin-bottom: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.03); } +.section.needs-review { border-color: #F59E0B; border-left: 3px solid #F59E0B; } +.section-header { padding: 10px 14px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #F1F5F9; cursor: pointer; transition: background 0.15s; } +.section-header:hover { background: #F8FAFC; } +.section-header .name { font-weight: 600; font-size: 13px; color: #0F172A; } +.section-header .count { font-size: 11px; color: #64748B; background: #F1F5F9; padding: 1px 8px; border-radius: 10px; font-weight: 500; font-variant-numeric: tabular-nums; } +.section-header .rename-btn { background: none; border: none; color: #CBD5E1; cursor: pointer; font-size: 12px; padding: 2px 4px; border-radius: 3px; transition: color 0.15s; } +.section-header .rename-btn:hover { color: #2563EB; } +.section.needs-review .section-header { background: #FFFBEB; } +.section.needs-review .count { background: #FEF3C7; color: #92400E; } + +/* Item rows */ +.item { padding: 8px 14px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #F8FAFC; font-size: 13px; transition: background 0.1s; } +.item:last-child { border-bottom: none; } +.item:hover { background: #F1F5F9; } +.item.needs-review { background: #FFFBEB; } +.item.needs-review:hover { background: #FEF9C3; } + +/* Confidence badge */ +.conf { width: 32px; height: 20px; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; font-variant-numeric: tabular-nums; } +.conf-high { background: #16A34A; } +.conf-med { background: #D97706; } +.conf-low { background: #DC2626; } + +/* Source icon */ +.src { width: 20px; height: 20px; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; flex-shrink: 0; } +.src-slack { background: #EDE9FE; color: #7C3AED; } +.src-gitlab { background: #FFEDD5; color: #EA580C; } +.src-github { background: #DBEAFE; color: #2563EB; } + +/* Item content */ +.item-desc { flex: 1; min-width: 0; overflow: hidden; } +.item-desc .text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #1E293B; } +.item-desc .meta { font-size: 11px; color: #94A3B8; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.item-desc .meta a { color: #94A3B8; transition: color 0.15s; } +.item-desc .meta a:hover { color: #2563EB; } + +/* Status pill */ +.status { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: #F1F5F9; color: #64748B; flex-shrink: 0; font-weight: 500; } +.status-done { background: #DCFCE7; color: #166534; } +.status-progress { background: #DBEAFE; color: #1E40AF; } +.status-review { background: #FEF3C7; color: #92400E; } + +/* Reclassify dropdown */ +.reclassify { font-size: 11px; padding: 4px 8px; border: 1px solid #E2E8F0; border-radius: 4px; background: #fff; color: #475569; max-width: 160px; cursor: pointer; transition: border-color 0.15s; } +.reclassify:hover { border-color: #94A3B8; } +.reclassify:focus { border-color: #2563EB; } + +/* Action buttons */ +.item-actions { display: flex; gap: 2px; flex-shrink: 0; } +.item-actions button { background: none; border: none; color: #CBD5E1; cursor: pointer; padding: 4px 6px; font-size: 14px; border-radius: 4px; transition: all 0.15s; } +.item-actions button:hover { color: #475569; background: #F1F5F9; } + +/* Empty state */ +.empty { padding: 48px; text-align: center; color: #94A3B8; font-size: 14px; } +.empty p:first-child { font-size: 15px; color: #64748B; font-weight: 500; } + +/* Flash messages */ +.flash { padding: 10px 16px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; font-weight: 500; } +.flash-success { background: #DCFCE7; color: #166534; border: 1px solid #BBF7D0; } +.flash-error { background: #FEE2E2; color: #991B1B; border: 1px solid #FECACA; } +.flash-info { background: #DBEAFE; color: #1E40AF; border: 1px solid #BFDBFE; } + +/* Generate progress */ +.generate-status { background: #EFF6FF; border: 1px solid #93C5FD; border-radius: 6px; padding: 12px 16px; margin-bottom: 12px; display: flex; align-items: center; gap: 12px; font-size: 13px; color: #1E40AF; font-weight: 500; } +.spinner { width: 16px; height: 16px; border: 2px solid #93C5FD; border-top-color: #2563EB; border-radius: 50%; animation: spin 0.6s linear infinite; flex-shrink: 0; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* Edit form */ +.edit-form { padding: 12px 14px; background: #F8FAFC; border-bottom: 1px solid #E2E8F0; } +.edit-form input, .edit-form select { font-size: 13px; padding: 6px 10px; border: 1px solid #CBD5E1; border-radius: 6px; transition: border-color 0.15s; } +.edit-form input:focus, .edit-form select:focus { border-color: #2563EB; outline: none; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); } +.edit-form input[type="text"] { width: 100%; margin-bottom: 8px; } +.edit-form .form-actions { display: flex; gap: 8px; margin-top: 8px; } + +/* HTMX indicators */ +.htmx-indicator { display: none; } +.htmx-request .htmx-indicator { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #64748B; } +.htmx-request.htmx-indicator { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #64748B; } +.htmx-request .btn { opacity: 0.6; cursor: wait; } + +/* Reclassify dropdown loading */ +select.htmx-request { opacity: 0.5; pointer-events: none; } + +/* Responsive: stack layout on narrow screens */ +@media (max-width: 1024px) { + .editor-layout { flex-direction: column; } + .preview-panel { width: 100%; max-width: 100%; position: static; resize: none; max-height: none; } + .stats { margin-left: 0; flex-basis: 100%; } +} diff --git a/internal/web/templates/category_form.templ b/internal/web/templates/category_form.templ new file mode 100644 index 0000000..324aab1 --- /dev/null +++ b/internal/web/templates/category_form.templ @@ -0,0 +1,15 @@ +package templates + +templ CategoryForm(weekParam string) { +
+
+ + + +
+
+} diff --git a/internal/web/templates/category_form_templ.go b/internal/web/templates/category_form_templ.go new file mode 100644 index 0000000..81a7aed --- /dev/null +++ b/internal/web/templates/category_form_templ.go @@ -0,0 +1,53 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func CategoryForm(weekParam string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/generate_status.templ b/internal/web/templates/generate_status.templ new file mode 100644 index 0000000..b2a9ace --- /dev/null +++ b/internal/web/templates/generate_status.templ @@ -0,0 +1,23 @@ +package templates + +templ GenerateProgress(message string) { +
+
+ { message } +
+} + +templ GenerateComplete(filePath string) { +
+ Report generated successfully! + if filePath != "" { + Saved to { filePath } + } +
+} + +templ GenerateError(message string) { +
+ Generation failed: { message } +
+} diff --git a/internal/web/templates/generate_status_templ.go b/internal/web/templates/generate_status_templ.go new file mode 100644 index 0000000..fb30b13 --- /dev/null +++ b/internal/web/templates/generate_status_templ.go @@ -0,0 +1,147 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func GenerateProgress(message string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/generate_status.templ`, Line: 6, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func GenerateComplete(filePath string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Report generated successfully! ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if filePath != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Saved to ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(filePath) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/generate_status.templ`, Line: 14, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func GenerateError(message string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Generation failed: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/generate_status.templ`, Line: 21, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/item_edit_form.templ b/internal/web/templates/item_edit_form.templ new file mode 100644 index 0000000..4f3b497 --- /dev/null +++ b/internal/web/templates/item_edit_form.templ @@ -0,0 +1,29 @@ +package templates + +import "fmt" + +templ ItemEditForm(itemID int64, description string, status string) { +
+
+ +
+ + + +
+
+
+} diff --git a/internal/web/templates/item_edit_form_templ.go b/internal/web/templates/item_edit_form_templ.go new file mode 100644 index 0000000..52000b8 --- /dev/null +++ b/internal/web/templates/item_edit_form_templ.go @@ -0,0 +1,160 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "fmt" + +func ItemEditForm(itemID int64, description string, status string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/item_row.templ b/internal/web/templates/item_row.templ new file mode 100644 index 0000000..7b4c5f8 --- /dev/null +++ b/internal/web/templates/item_row.templ @@ -0,0 +1,87 @@ +package templates + +import ( + "fmt" +) + +func confClass(conf float64) string { + if conf >= 0.9 { return "conf conf-high" } + if conf >= 0.7 { return "conf conf-med" } + return "conf conf-low" +} + +func srcClass(source string) string { + switch source { + case "gitlab": return "src src-gitlab" + case "github": return "src src-github" + default: return "src src-slack" + } +} + +func srcLabel(source string) string { + switch source { + case "gitlab": return "GL" + case "github": return "GH" + default: return "S" + } +} + +func statusClass(status string) string { + switch status { + case "done": return "status status-done" + case "in progress": return "status status-progress" + case "in review", "in testing": return "status status-review" + default: return "status" + } +} + +templ ItemRow(item ItemData, allSections []SectionData, isManager bool) { +
+ + { fmt.Sprintf("%.0f", item.Confidence*100) } + + { srcLabel(item.Source) } +
+
{ item.Description }
+
+ { item.Author } + if item.SourceRef != "" { + · + { item.SourceRef } + } + if item.TicketIDs != "" { + · { item.TicketIDs } + } +
+
+ { item.Status } + if isManager && item.ID > 0 { + +
+ + +
+ } +
+} diff --git a/internal/web/templates/item_row_templ.go b/internal/web/templates/item_row_templ.go new file mode 100644 index 0000000..d16af02 --- /dev/null +++ b/internal/web/templates/item_row_templ.go @@ -0,0 +1,453 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" +) + +func confClass(conf float64) string { + if conf >= 0.9 { + return "conf conf-high" + } + if conf >= 0.7 { + return "conf conf-med" + } + return "conf conf-low" +} + +func srcClass(source string) string { + switch source { + case "gitlab": + return "src src-gitlab" + case "github": + return "src src-github" + default: + return "src src-slack" + } +} + +func srcLabel(source string) string { + switch source { + case "gitlab": + return "GL" + case "github": + return "GH" + default: + return "S" + } +} + +func statusClass(status string) string { + switch status { + case "done": + return "status status-done" + case "in progress": + return "status status-progress" + case "in review", "in testing": + return "status status-review" + default: + return "status" + } +} + +func ItemRow(item ItemData, allSections []SectionData, isManager bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var2 = []any{"item", templ.KV("needs-review", item.Confidence < 0.7)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 = []any{confClass(item.Confidence)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", item.Confidence*100)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/item_row.templ`, Line: 41, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 = []any{srcClass(item.Source)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(srcLabel(item.Source)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/item_row.templ`, Line: 43, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/item_row.templ`, Line: 45, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/item_row.templ`, Line: 47, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.SourceRef != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "· ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.SourceRef) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/item_row.templ`, Line: 50, Col: 99} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if item.TicketIDs != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "· ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.TicketIDs) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/item_row.templ`, Line: 53, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 = []any{statusClass(item.Status)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/item_row.templ`, Line: 57, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if isManager && item.ID > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ new file mode 100644 index 0000000..9cfefcd --- /dev/null +++ b/internal/web/templates/layout.templ @@ -0,0 +1,51 @@ +package templates + +templ Layout(title string, csrfToken string, isManager bool, activePage string) { + + + + + + + { title } - ReportBot + + + + + + +
+
+ { children... } +
+ + +} + +templ Flash(message string, level string) { + +} diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go new file mode 100644 index 0000000..de031c8 --- /dev/null +++ b/internal/web/templates/layout_templ.go @@ -0,0 +1,171 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Layout(title string, csrfToken string, isManager bool, activePage string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 10, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " - ReportBot
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Flash(message string, level string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var7 = []any{"flash", "flash-" + level} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 49, Col: 11} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/login.templ b/internal/web/templates/login.templ new file mode 100644 index 0000000..30a632b --- /dev/null +++ b/internal/web/templates/login.templ @@ -0,0 +1,22 @@ +package templates + +templ LoginPage(oauthURL string) { + + + + + + Sign In - ReportBot + + + +
+

ReportBot

+

Sign in with your Slack account to access the report editor.

+ + Sign in with Slack + +
+ + +} diff --git a/internal/web/templates/login_templ.go b/internal/web/templates/login_templ.go new file mode 100644 index 0000000..e337fa3 --- /dev/null +++ b/internal/web/templates/login_templ.go @@ -0,0 +1,53 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func LoginPage(oauthURL string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sign In - ReportBot

ReportBot

Sign in with your Slack account to access the report editor.

Sign in with Slack
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/markdown_preview.templ b/internal/web/templates/markdown_preview.templ new file mode 100644 index 0000000..a15d2a5 --- /dev/null +++ b/internal/web/templates/markdown_preview.templ @@ -0,0 +1,13 @@ +package templates + +// MarkdownPreviewRaw shows raw markdown in a pre block (fallback). +templ MarkdownPreviewRaw(content string) { +
{ content }
+} + +// MarkdownPreviewHTML shows rendered HTML from converted markdown. +templ MarkdownPreviewHTML(htmlContent string) { +
+ @templ.Raw(htmlContent) +
+} diff --git a/internal/web/templates/markdown_preview_templ.go b/internal/web/templates/markdown_preview_templ.go new file mode 100644 index 0000000..53f98a0 --- /dev/null +++ b/internal/web/templates/markdown_preview_templ.go @@ -0,0 +1,92 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// MarkdownPreviewRaw shows raw markdown in a pre block (fallback). +func MarkdownPreviewRaw(content string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var2 string
+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(content)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/markdown_preview.templ`, Line: 5, Col: 35}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// MarkdownPreviewHTML shows rendered HTML from converted markdown. +func MarkdownPreviewHTML(htmlContent string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(htmlContent).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/report_editor.templ b/internal/web/templates/report_editor.templ new file mode 100644 index 0000000..457c33a --- /dev/null +++ b/internal/web/templates/report_editor.templ @@ -0,0 +1,148 @@ +package templates + +import "fmt" + +type EditorData struct { + TeamName string + WeekLabel string + WeekParam string + PrevWeek string + NextWeek string + ItemCount int + AuthorCount int + AvgConf float64 + Sections []SectionData + AllSections []SectionData // all known sections (for reclassify dropdown, includes empty ones) + IsManager bool + CSRFToken string + Mode string + HasClassifications bool +} + +type SectionData struct { + ID string + Name string + Items []ItemData + NeedsReview bool +} + +type ItemData struct { + ID int64 + Description string + Author string + Status string + Source string + SourceRef string + Confidence float64 + SectionID string + TicketIDs string + IsNew bool +} + +templ ReportEditor(data EditorData) { + @Layout("Report Editor", data.CSRFToken, data.IsManager, "editor") { +
+

Weekly Report

+
+ + { data.WeekLabel } + +
+
+ { fmt.Sprintf("%d", data.ItemCount) } items + { fmt.Sprintf("%d", data.AuthorCount) } authors + if data.AvgConf > 0 { + { fmt.Sprintf("%.0f%%", data.AvgConf*100) } avg confidence + } +
+
+
+ if data.IsManager { + + if !data.HasClassifications { + + Classify Items + + } else { + + Re-classify + + } + } + + + Loading preview... + + + if data.IsManager { + + } +
+
+
+ if data.ItemCount == 0 { +
+

No work items reported this week.

+

Items from /report and /fetch will appear here.

+
+ } else { +
+
+ for _, section := range data.Sections { + @SectionGroup(section, data.AllSections, data.IsManager) + } +
+
+
+

Markdown Preview

+
+

Click "Preview Markdown" to see the rendered report.

+
+
+
+ + } + } +} diff --git a/internal/web/templates/report_editor_templ.go b/internal/web/templates/report_editor_templ.go new file mode 100644 index 0000000..31da12f --- /dev/null +++ b/internal/web/templates/report_editor_templ.go @@ -0,0 +1,322 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "fmt" + +type EditorData struct { + TeamName string + WeekLabel string + WeekParam string + PrevWeek string + NextWeek string + ItemCount int + AuthorCount int + AvgConf float64 + Sections []SectionData + AllSections []SectionData // all known sections (for reclassify dropdown, includes empty ones) + IsManager bool + CSRFToken string + Mode string + HasClassifications bool +} + +type SectionData struct { + ID string + Name string + Items []ItemData + NeedsReview bool +} + +type ItemData struct { + ID int64 + Description string + Author string + Status string + Source string + SourceRef string + Confidence float64 + SectionID string + TicketIDs string + IsNew bool +} + +func ReportEditor(data EditorData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Weekly Report

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.WeekLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/report_editor.templ`, Line: 48, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ItemCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/report_editor.templ`, Line: 52, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " items ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.AuthorCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/report_editor.templ`, Line: 53, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " authors ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.AvgConf > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", data.AvgConf*100)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/report_editor.templ`, Line: 55, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " avg confidence") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsManager { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !data.HasClassifications { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Classify Items ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Re-classify ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " Loading preview... ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.IsManager { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.ItemCount == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

No work items reported this week.

Items from /report and /fetch will appear here.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, section := range data.Sections { + templ_7745c5c3_Err = SectionGroup(section, data.AllSections, data.IsManager).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Markdown Preview

Click \"Preview Markdown\" to see the rendered report.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout("Report Editor", data.CSRFToken, data.IsManager, "editor").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/section_group.templ b/internal/web/templates/section_group.templ new file mode 100644 index 0000000..15e5a4c --- /dev/null +++ b/internal/web/templates/section_group.templ @@ -0,0 +1,26 @@ +package templates + +import ( + "fmt" +) + +templ SectionGroup(section SectionData, allSections []SectionData, isManager bool) { +
+
+ { section.Name } + if isManager { + + } + { fmt.Sprintf("%d items", len(section.Items)) } +
+ for _, item := range section.Items { + @ItemRow(item, allSections, isManager) + } +
+} diff --git a/internal/web/templates/section_group_templ.go b/internal/web/templates/section_group_templ.go new file mode 100644 index 0000000..81e5eb6 --- /dev/null +++ b/internal/web/templates/section_group_templ.go @@ -0,0 +1,160 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" +) + +func SectionGroup(section SectionData, allSections []SectionData, isManager bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var2 = []any{"section", templ.KV("needs-review", section.NeedsReview)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/section_group.templ`, Line: 10, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if isManager { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items", len(section.Items))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/section_group.templ`, Line: 20, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, item := range section.Items { + templ_7745c5c3_Err = ItemRow(item, allSections, isManager).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/section_rename_form.templ b/internal/web/templates/section_rename_form.templ new file mode 100644 index 0000000..6cc299a --- /dev/null +++ b/internal/web/templates/section_rename_form.templ @@ -0,0 +1,22 @@ +package templates + +templ SectionRenameForm(sectionID string, currentName string, weekParam string) { + +
+ + + +
+
+} diff --git a/internal/web/templates/section_rename_form_templ.go b/internal/web/templates/section_rename_form_templ.go new file mode 100644 index 0000000..d0eb463 --- /dev/null +++ b/internal/web/templates/section_rename_form_templ.go @@ -0,0 +1,118 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func SectionRenameForm(sectionID string, currentName string, weekParam string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate