Files
paliad/internal/handlers/checklists.go
mAi 3d85ce5444 hotfix(checklists): serve detail page for authored slugs (u-a-…)
m's 2026-05-22 report: user-authored checklists appear in the overview
list but clicking through to /checklists/u-a-<id> 404s.

Root cause: handleChecklistDetailPage only consulted
checklists.Find(slug), which is the STATIC compile-time catalog.
Authored checklists (t-paliad-225) live in the DB and never appear
there, so every authored slug fell into the http.NotFound branch even
though /api/checklists returned them in the overview.

Fix: when the static lookup misses AND a DB-backed catalog is wired,
ask checklistCatalog.Find(ctx, uid, slug). The catalog enforces
visibility — slugs the caller can't see still return 404 (via
ErrNotVisible), so this doesn't open a leak. The static path is
unchanged.
2026-05-22 23:32:12 +02:00

237 lines
7.9 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/checklists"
)
type ChecklistFeedback struct {
FeedbackType string `json:"feedback_type"`
Checklist string `json:"checklist"`
Message string `json:"message"`
}
func handleChecklistsPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists.html")
}
// handleChecklistsAuthorPage serves the authoring wizard (new + edit
// share the same bundle; the client reads location.pathname to decide
// create vs edit mode).
func handleChecklistsAuthorPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists-author.html")
}
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
// Static catalog match → serve unconditionally; the static templates
// are always visible.
if _, ok := checklists.Find(slug); ok {
http.ServeFile(w, r, "dist/checklists-detail.html")
return
}
// Otherwise fall back to the DB-backed catalog (authored templates,
// slug shape "u-a-..." from t-paliad-225). The catalog enforces
// visibility per-user; a slug the caller can't see returns
// ErrNotVisible and the user gets the same 404 they'd see for an
// unknown slug. Without this branch authored checklists 404'd at the
// page level even though they showed up in the overview, which is
// exactly m's 2026-05-22 report.
if dbSvc != nil && dbSvc.checklistCatalog != nil {
uid, ok := auth.UserIDFromContext(r.Context())
if ok {
if _, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug); err == nil {
http.ServeFile(w, r, "dist/checklists-detail.html")
return
}
}
}
http.NotFound(w, r)
}
func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/checklists-instance.html")
}
// handleChecklistsAPI returns the merged catalog: static templates
// (always) plus authored DB templates the caller can see (mig 114).
// Each entry carries origin + visibility + author metadata so the
// frontend can render provenance.
//
// Falls back to the bare static catalog when DB is unavailable so the
// knowledge-platform-only deploy stays functional without DATABASE_URL.
func handleChecklistsAPI(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.checklistCatalog == nil {
// Fall back to static summaries shape so the existing frontend
// keeps working in the no-DB deploy.
writeJSON(w, http.StatusOK, checklists.Summaries())
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
entries, err := dbSvc.checklistCatalog.ListVisible(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
// Frontend expects the existing Summary shape on the index list; map
// the merged entries to Summary + origin/visibility/author fields.
type Summary struct {
checklists.Summary
Origin string `json:"origin"`
Visibility string `json:"visibility"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
}
out := make([]Summary, 0, len(entries))
for _, e := range entries {
out = append(out, Summary{
Summary: checklists.Summary{
Slug: e.Template.Slug,
TitleDE: e.Template.TitleDE,
TitleEN: e.Template.TitleEN,
DescriptionDE: e.Template.DescriptionDE,
DescriptionEN: e.Template.DescriptionEN,
Regime: e.Template.Regime,
CourtDE: e.Template.CourtDE,
CourtEN: e.Template.CourtEN,
ItemCount: checklists.TotalItems(e.Template),
},
Origin: e.Origin,
Visibility: e.Visibility,
OwnerEmail: e.OwnerEmail,
OwnerDisplayName: e.OwnerDisplayName,
})
}
writeJSON(w, http.StatusOK, out)
}
// handleChecklistAPI returns one template by slug. Looks up static
// catalog first (always visible), then authored DB rows via the
// catalog with visibility check.
func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
// Static-first path keeps the no-DB deploy functional and is the
// common case for the curated templates.
if c, ok := checklists.Find(slug); ok {
writeJSON(w, http.StatusOK, c)
return
}
if dbSvc == nil || dbSvc.checklistCatalog == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
entry, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
return
}
// Re-render as the bilingual Template shape plus a thin meta block.
// Version is included so the instance detail page can decide whether
// to show the "template updated since this instance was created"
// badge (Slice C).
type templateWithMeta struct {
checklists.Template
Origin string `json:"origin"`
Visibility string `json:"visibility"`
OwnerEmail string `json:"owner_email,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
Version int `json:"version"`
}
writeJSON(w, http.StatusOK, templateWithMeta{
Template: entry.Template,
Origin: entry.Origin,
Visibility: entry.Visibility,
OwnerEmail: entry.OwnerEmail,
OwnerDisplayName: entry.OwnerDisplayName,
Version: entry.Version,
})
}
func handleChecklistsFeedback(w http.ResponseWriter, r *http.Request) {
var feedback ChecklistFeedback
if err := json.NewDecoder(r.Body).Decode(&feedback); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage."})
return
}
feedback.FeedbackType = strings.TrimSpace(feedback.FeedbackType)
feedback.Checklist = strings.TrimSpace(feedback.Checklist)
feedback.Message = strings.TrimSpace(feedback.Message)
if feedback.Message == "" || feedback.FeedbackType == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Nachricht und Art sind erforderlich."})
return
}
accessToken := ""
email := ""
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
accessToken = cookie.Value
email = extractEmailFromJWT(cookie.Value)
}
payload := map[string]string{
"feedback_type": feedback.FeedbackType,
"checklist": feedback.Checklist,
"message": feedback.Message,
"submitted_by": email,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
log.Printf("checklists feedback marshal error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Interner Fehler."})
return
}
endpoint := fmt.Sprintf("%s/rest/v1/checklist_feedback", authClient.URL)
req2, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody))
if err != nil {
log.Printf("checklists feedback request error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Interner Fehler."})
return
}
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("apikey", authClient.AnonKey)
if accessToken != "" {
req2.Header.Set("Authorization", "Bearer "+accessToken)
} else {
req2.Header.Set("Authorization", "Bearer "+authClient.AnonKey)
}
req2.Header.Set("Prefer", "return=minimal")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req2)
if err != nil {
log.Printf("checklists feedback supabase error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Fehler beim Speichern."})
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
log.Printf("checklists feedback supabase status %d: %s", resp.StatusCode, string(body))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Fehler beim Speichern."})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"ok": "true"})
}