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`.
479 lines
14 KiB
Go
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)
|
|
}
|