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.
237 lines
7.9 KiB
Go
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"})
|
|
}
|