Files
paliad/internal/handlers/projection.go
mAi 216abbfc98 feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes
Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
2026-05-18 12:13:24 +02:00

479 lines
14 KiB
Go

package handlers
// HTTP surface for the SmartTimeline (t-paliad-171, design doc
// docs/design-smart-timeline-2026-05-08.md). Two endpoints:
//
// GET /api/projects/{id}/timeline — read the merged timeline
// POST /api/projects/{id}/timeline/milestone — write a custom milestone
//
// Both go through ProjectionService, which delegates visibility + RLS
// to DeadlineService / AppointmentService and enforces the project_events
// gate inline. No new RLS surface here.
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/projects/{id}/timeline
//
// Query parameters:
//
// ?include=audit_full — when present, project_events are returned
// without the timeline_kind filter (legacy
// Verlauf chronological view, behind the
// "Audit-Log anzeigen" toggle).
// ?direct_only=1|true — narrow to events whose project_id exactly
// matches; default is project + descendants.
func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
q := r.URL.Query()
opts := services.ProjectionOpts{
IncludeAuditFull: q.Get("include") == "audit_full",
DirectOnly: parseDirectOnly(q.Get("direct_only")),
LookaheadCap: parseLookahead(q.Get("lookahead")),
Lang: q.Get("lang"),
}
rows, meta, err := dbSvc.projection.For(r.Context(), uid, id, opts)
if err != nil {
writeServiceError(w, err)
return
}
// Always return [], never null — the frontend reads .length on the
// result and would crash on a JSON null.
if rows == nil {
rows = []services.TimelineEvent{}
}
lanes := meta.Lanes
if lanes == nil {
lanes = []services.LaneInfo{}
}
// Surface projection meta via headers — Slice 1-3 frontends still
// read X-Projection-Total / Lookahead / Tracks for the lookahead
// toggle and Track chip.
w.Header().Set("X-Projection-Has", boolStr(meta.HasProjection))
w.Header().Set("X-Projection-Total", itoa(meta.ProjectedTotal))
w.Header().Set("X-Projection-Shown", itoa(meta.ProjectedShown))
w.Header().Set("X-Projection-Overdue", itoa(meta.PredictedOverdue))
w.Header().Set("X-Projection-Lookahead", itoa(meta.Lookahead))
if len(meta.AvailableTracks) > 0 {
// Comma-separated list of track tags ("parent", "counterclaim:<id>",
// "parent_context:<id>"). Track ids are UUIDs — safe in headers.
w.Header().Set("X-Projection-Tracks", strings.Join(meta.AvailableTracks, ","))
}
// Slice 4 changed the wire shape from []TimelineEvent to an envelope
// {events, lanes} so lane metadata can ride alongside the rows
// without exceeding header-size limits when a Client-level
// projection has many lanes. The frontend reads .events for the
// per-row contract and .lanes for parallel-column rendering.
writeJSON(w, http.StatusOK, services.ResponseEnvelope{
Events: rows,
Lanes: lanes,
})
}
// GET /api/projects/{id}/timeline.ics
//
// t-paliad-177 Slice 2 — iCal feed export. Returns a VCALENDAR with one
// VEVENT per deadline + appointment row (faraday-Q6: NO projected — a
// calendar feed must never carry predicted dates the user never
// confirmed). Reuses the formatter from caldav_ical.go so future
// CalDAV sync work and chart exports share one source of truth.
//
// Visibility piggybacks on ProjectionService.For (same gate as
// /timeline). Project title is fetched via ProjectService.GetByID and
// passed as the X-WR-CALNAME for Outlook / Apple Calendar display.
func handleGetProjectTimelineICS(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
rows, _, err := dbSvc.projection.For(r.Context(), uid, id, services.ProjectionOpts{})
if err != nil {
writeServiceError(w, err)
return
}
proj, err := dbSvc.projects.GetByID(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
body := services.FormatTimelineICS(rows, proj.Title)
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
// Sanitise the project title for the filename — RFC-7230 disallows
// many bytes in header values, and Outlook truncates non-ASCII
// disposition filenames inconsistently. ASCII slug + date is portable.
w.Header().Set(
"Content-Disposition",
`attachment; filename="paliad-`+filenameSlug(proj.Title)+`-`+
time.Now().UTC().Format("2006-01-02")+`.ics"`,
)
_, _ = w.Write([]byte(body))
}
func filenameSlug(s string) string {
if s == "" {
return "timeline"
}
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '.':
out = append(out, c)
default:
if len(out) > 0 && out[len(out)-1] != '_' {
out = append(out, '_')
}
}
}
for len(out) > 0 && (out[0] == '_' || out[len(out)-1] == '_') {
if out[0] == '_' {
out = out[1:]
} else {
out = out[:len(out)-1]
}
}
if len(out) > 60 {
out = out[:60]
}
if len(out) == 0 {
return "timeline"
}
return string(out)
}
// POST /api/projects/{id}/timeline/anchor
//
// Body: {"rule_code":"inf.sod","actual_date":"2026-08-31","kind":"deadline"}
//
// 200 → AnchorResult JSON.
// 409 → predecessor_missing payload (m/paliad#31 layer 3 sequence guard).
// The frontend renders the message in the active language as an
// inline error and offers a "Stattdessen <predecessor> erfassen"
// link that pre-fills the editor for the parent rule.
func handleProjectTimelineAnchor(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
RuleCode string `json:"rule_code"`
ActualDate string `json:"actual_date"`
Kind string `json:"kind,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
d, err := time.Parse("2006-01-02", body.ActualDate)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid actual_date — expected YYYY-MM-DD",
})
return
}
res, err := dbSvc.projection.RecordAnchor(r.Context(), uid, id, services.AnchorInput{
RuleCode: body.RuleCode,
ActualDate: d,
Kind: body.Kind,
})
if err != nil {
if pme, ok := services.IsPredecessorMissing(err); ok {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "predecessor_missing",
"missing_rule_code": pme.MissingRuleCode,
"missing_rule_name_de": pme.MissingRuleNameDE,
"missing_rule_name_en": pme.MissingRuleNameEN,
"requested_rule_code": pme.RequestedRuleCode,
"requested_rule_name_de": pme.RequestedRuleNameDE,
"requested_rule_name_en": pme.RequestedRuleNameEN,
"message_de": "Bitte zuerst „" + pme.MissingRuleNameDE +
"“ (" + pme.MissingRuleCode + ") erfassen — daraus folgt die Frist „" +
pme.RequestedRuleNameDE + "“.",
"message_en": "Anchor „" + pme.MissingRuleNameEN +
"“ (" + pme.MissingRuleCode + ") first — „" +
pme.RequestedRuleNameEN + "“ flows from it.",
})
return
}
writeServiceError(w, err)
return
}
out := map[string]any{"updated": res.Updated}
if res.DeadlineID != nil {
out["deadline_id"] = res.DeadlineID.String()
out["kind"] = "deadline"
}
if res.AppointmentID != nil {
out["appointment_id"] = res.AppointmentID.String()
out["kind"] = "appointment"
}
writeJSON(w, http.StatusOK, out)
}
// POST /api/projects/{id}/timeline/skip
//
// Body: {"rule_code":"inf.prelim","reason":"Beklagter hat keinen PO eingelegt"}
//
// Marks the rule as "ist nicht eingetreten / wurde verschoben" — the
// projected row drops out of future reads until the user clears the
// rule_skipped event (admin / audit-log path).
func handleProjectTimelineSkip(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
RuleCode string `json:"rule_code"`
Reason string `json:"reason,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if err := dbSvc.projection.RecordRuleSkipped(r.Context(), uid, id, body.RuleCode, body.Reason); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
// parseLookahead reads the ?lookahead=N query parameter; clamps to
// [1, MaxLookaheadCap] in the service. Returns 0 to mean "default" when
// the parameter is missing or malformed.
func parseLookahead(s string) int {
if s == "" {
return 0
}
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
if n > 1000 {
return 1000
}
}
return n
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// POST /api/projects/{id}/counterclaim
//
// Body: {
// "proceeding_type_id": 9, // optional, defaults to upc.rev.cfi
// "flip_our_side": false, // optional, default-flip otherwise
// "title": "EP3456789 — Widerklage (CCR)", // optional, auto-suggested
// "case_number": "ACT_xxx_2026" // optional CCR case number
// }
//
// Creates the CCR sub-project, writes audit rows on parent + child,
// returns the new project's id + canonical URL.
func handleCreateProjectCounterclaim(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
parentID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
FlipOurSide *bool `json:"flip_our_side,omitempty"`
Title *string `json:"title,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
}
// Empty body is fine — full default behaviour.
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
}
opts := services.CounterclaimOpts{
ProceedingTypeID: body.ProceedingTypeID,
FlipOurSide: body.FlipOurSide,
Title: body.Title,
CaseNumber: body.CaseNumber,
}
child, err := dbSvc.projects.CreateCounterclaim(r.Context(), uid, parentID, opts)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": child.ID,
"url": "/projects/" + child.ID.String(),
"counterclaim_of": child.CounterclaimOf,
"parent_id": child.ParentID,
"title": child.Title,
"our_side": child.OurSide,
"proceeding_type": child.ProceedingTypeID,
"case_number": child.CaseNumber,
})
}
// POST /api/projects/{id}/timeline/milestone
//
// Body shape: {"title": "...", "description": "...", "occurred_at": "YYYY-MM-DD"}
//
// Writes a paliad.project_events row with event_type='custom_milestone'
// and timeline_kind='custom_milestone'. Returns the resulting
// TimelineEvent so the caller can append it without a re-fetch.
func handleCreateProjectTimelineMilestone(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
Title string `json:"title"`
Description *string `json:"description,omitempty"`
OccurredAt *string `json:"occurred_at,omitempty"`
BubbleUp bool `json:"bubble_up,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
var occurred *time.Time
if body.OccurredAt != nil && *body.OccurredAt != "" {
t, err := time.Parse("2006-01-02", *body.OccurredAt)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid occurred_at — expected YYYY-MM-DD",
})
return
}
occurred = &t
}
ev, err := dbSvc.projection.RecordCustomMilestone(r.Context(), uid, id,
body.Title, body.Description, occurred, body.BubbleUp)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, ev)
}