Files
paliad/internal/services/projection_service.go
m afd3aab2b2 feat(t-paliad-171): SmartTimeline backend skeleton — ProjectionService + /timeline endpoint
Slice 1 of the SmartTimeline (Verlauf-tab redesign). Adds a new service
layer + two HTTP endpoints; no projection logic yet (Slice 2). The wire
shape (TimelineEvent) is frozen so future slices add Kind="projected"
rows additively without breaking the frontend consumer.

ProjectionService.For composes three actuals streams for one project:
  - paliad.deadlines           → Kind="deadline"
  - paliad.appointments        → Kind="appointment"
  - paliad.project_events with
    timeline_kind IS NOT NULL  → Kind="milestone"

Visibility goes through the existing inline mirror of
paliad.can_see_project on each underlying service — no new RLS surface.
DirectOnly mirrors the existing "Inkl. Unterprojekte" toggle on
/projects/{id}; IncludeAuditFull broadens project_events to the full
audit log behind the upcoming "Audit-Log anzeigen" header toggle.

ProjectionService.RecordCustomMilestone backs POST /timeline/milestone
("Eigener Meilenstein") — the only write path in Slice 1.

Tests: unit (sort order, status mapping, kind tiebreak — runs by default)
plus a live integration test that seeds one project + dl + appt +
milestone and asserts the merge surfaces all three with the right
ordering. Live test gated on TEST_DATABASE_URL per the existing
convention.

Design ref: docs/design-smart-timeline-2026-05-08.md §2.3 + §9.2 + §10.
2026-05-08 23:34:06 +02:00

440 lines
14 KiB
Go

package services
// ProjectionService composes the SmartTimeline read view for a project —
// the merged stream of past actuals (deadlines + appointments + opted-in
// project_events) plus, in later slices, future projections from the
// fristenrechner.
//
// Slice 1 (t-paliad-171) returns ONLY actuals — the four-zone vision in
// the design doc lands incrementally. The wire shape (TimelineEvent) is
// frozen so Slice 2 just adds Kind="projected" rows without breaking
// frontend consumers. See docs/design-smart-timeline-2026-05-08.md
// §2.3 + §10.
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// TimelineEvent is one row in the SmartTimeline merge. The struct is
// the wire contract of GET /api/projects/{id}/timeline; new slices
// MUST extend it additively (new fields, never rename / repurpose).
//
// Provenance fields (DeadlineID, AppointmentID, ProjectEventID) — exactly
// one is non-nil for actual rows. All three are nil for Kind="projected"
// rows (Slice 2). Frontend deep-links via the populated id.
type TimelineEvent struct {
Kind string `json:"kind"` // "deadline" | "appointment" | "milestone" | "projected"
Status string `json:"status"` // "done" | "open" | "overdue" | "court_set" | "predicted" | "off_script"
Track string `json:"track"` // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
// Date is nil for undated rows (court-set decisions, counterclaim-pending
// milestones). Undated rows sort to the end.
Date *time.Time `json:"date,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
RuleCode string `json:"rule_code,omitempty"`
DeadlineID *uuid.UUID `json:"deadline_id,omitempty"`
AppointmentID *uuid.UUID `json:"appointment_id,omitempty"`
ProjectEventID *uuid.UUID `json:"project_event_id,omitempty"`
// Reserved for Slice 2 (projected rows + click-to-anchor affordance);
// always empty / nil in Slice 1, kept here so the wire shape doesn't
// change between slices.
DeadlineRuleID *uuid.UUID `json:"deadline_rule_id,omitempty"`
DeadlineRuleParty string `json:"deadline_rule_party,omitempty"`
// Reserved for Slice 3 (counterclaim sub-projects); populated when
// the row belongs to a child project rendered alongside the parent.
SubProjectID *uuid.UUID `json:"sub_project_id,omitempty"`
SubProjectTitle string `json:"sub_project_title,omitempty"`
}
// ProjectionOpts narrows the SmartTimeline read.
//
// IncludeAuditFull — when true, project_events are loaded WITHOUT the
// timeline_kind filter (i.e. every audit row, the legacy Verlauf list
// behaviour). Backs the "Audit-Log anzeigen" toggle in the timeline
// header.
//
// DirectOnly — when true, narrows to rows whose project_id exactly
// matches; default (false) aggregates the project + every descendant,
// matching the existing "Inkl. Unterprojekte" toggle behaviour on
// /projects/{id}.
type ProjectionOpts struct {
IncludeAuditFull bool
DirectOnly bool
}
// ProjectionService composes the SmartTimeline. Read-only in Slice 1
// (RecordCustomMilestone is the one write path — for "Eigener
// Meilenstein" entries from the timeline header).
type ProjectionService struct {
db *sqlx.DB
projects *ProjectService
deadlines *DeadlineService
appointments *AppointmentService
}
// NewProjectionService wires the read-side dependencies. Slice 2 will
// add *FristenrechnerService here; we keep the constructor minimal
// today so the call site in cmd/server/main.go stays small.
func NewProjectionService(
db *sqlx.DB,
projects *ProjectService,
deadlines *DeadlineService,
appointments *AppointmentService,
) *ProjectionService {
return &ProjectionService{
db: db,
projects: projects,
deadlines: deadlines,
appointments: appointments,
}
}
// For builds a SmartTimeline for one project. Visibility is delegated
// to the underlying services (DeadlineService / AppointmentService /
// the project_events query reuses the project visibility predicate),
// so this layer adds no new RLS surface.
//
// Returns rows sorted by Date ASC, undated rows last, with a stable
// title-then-id tiebreaker.
func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
// Visibility check — GetByID returns ErrNotVisible when the user
// can't see the project, which the handler maps to a 404. After
// this, the deadline / appointment list calls also gate on
// visibility, so any descendant rows surfaced via DirectOnly=false
// are similarly scoped.
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
out := make([]TimelineEvent, 0, 32)
// --- Deadlines -----------------------------------------------------
deadlineRows, err := s.deadlines.ListVisibleForUser(ctx, userID, ListFilter{
ProjectID: &projectID,
DirectOnly: opts.DirectOnly,
})
if err != nil {
return nil, fmt.Errorf("projection: deadlines: %w", err)
}
for _, d := range deadlineRows {
ev := TimelineEvent{
Kind: "deadline",
Status: deadlineStatus(d.Status, d.DueDate),
Track: "parent",
Date: timePtr(time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC)),
Title: d.Title,
DeadlineID: &d.ID,
}
if d.Description != nil {
ev.Description = *d.Description
}
if d.RuleCode != nil {
ev.RuleCode = *d.RuleCode
}
out = append(out, ev)
}
// --- Appointments --------------------------------------------------
apptRows, err := s.appointments.ListVisibleForUser(ctx, userID, AppointmentListFilter{
ProjectID: &projectID,
DirectOnly: opts.DirectOnly,
})
if err != nil {
return nil, fmt.Errorf("projection: appointments: %w", err)
}
now := time.Now().UTC()
for _, a := range apptRows {
startCopy := a.StartAt
ev := TimelineEvent{
Kind: "appointment",
Status: appointmentStatus(startCopy, now),
Track: "parent",
Date: &startCopy,
Title: a.Title,
AppointmentID: &a.ID,
}
if a.Description != nil {
ev.Description = *a.Description
}
out = append(out, ev)
}
// --- project_events (timeline-opted-in by default; full audit when
// the IncludeAuditFull toggle is on) ---------------------------------
milestoneRows, err := s.listProjectEvents(ctx, userID, projectID, opts)
if err != nil {
return nil, fmt.Errorf("projection: milestones: %w", err)
}
out = append(out, milestoneRows...)
sortTimeline(out)
return out, nil
}
// listProjectEvents reads the audit table directly so we can apply the
// timeline_kind filter. We can't go through ProjectService.ListEvents
// because that path is paginated for the legacy Verlauf list and has
// no way to express "kind IS NOT NULL"; replicating its descendant
// aggregation predicate keeps this service self-contained.
//
// Visibility is enforced inline via the same pattern the rest of the
// services use (visibility.go) — application-layer mirror of
// paliad.can_see_project, since the service-role connection has no
// auth.uid() the SQL RLS function would need.
func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
// $1 = projectID, $2 = userID. The visibility predicate references
// $2 twice (admin shortcut + team-membership existence) — that is
// the standard Postgres positional-binding pattern, see
// visibilityPredicatePositional doc for the rationale.
var projectFilter string
if opts.DirectOnly {
projectFilter = `pe.project_id = $1 AND ` + visibilityPredicatePositional("p", 2)
} else {
// Aggregate self + descendants, gated by visibility on each
// row's joined project. p.path is materialised so the
// descendant check is a path-membership predicate.
projectFilter = `$1 = ANY(string_to_array(p.path, '.')::uuid[]) AND ` + visibilityPredicatePositional("p", 2)
}
kindFilter := `AND pe.timeline_kind IS NOT NULL`
if opts.IncludeAuditFull {
kindFilter = ``
}
type row struct {
ID uuid.UUID `db:"id"`
ProjectID uuid.UUID `db:"project_id"`
EventType *string `db:"event_type"`
Title string `db:"title"`
Description *string `db:"description"`
EventDate *time.Time `db:"event_date"`
CreatedAt time.Time `db:"created_at"`
Metadata json.RawMessage `db:"metadata"`
TimelineKind *string `db:"timeline_kind"`
}
query := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
pe.event_date, pe.created_at, pe.metadata, pe.timeline_kind
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
WHERE ` + projectFilter + ` ` + kindFilter + `
ORDER BY COALESCE(pe.event_date, pe.created_at) DESC, pe.id DESC`
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, projectID, userID); err != nil {
return nil, fmt.Errorf("list project_events: %w", err)
}
out := make([]TimelineEvent, 0, len(rows))
for _, r := range rows {
// Prefer event_date when set; otherwise fall back to created_at
// so the audit row still anchors somewhere on the timeline.
var when time.Time
if r.EventDate != nil {
when = *r.EventDate
} else {
when = r.CreatedAt
}
whenCopy := when
ev := TimelineEvent{
Kind: "milestone",
Status: milestoneStatus(r.TimelineKind, r.EventType),
Track: "parent",
Date: &whenCopy,
Title: r.Title,
ProjectEventID: &r.ID,
}
if r.Description != nil {
ev.Description = *r.Description
}
out = append(out, ev)
}
return out, nil
}
// RecordCustomMilestone writes a "Eigener Meilenstein" project_event
// (event_type='custom_milestone', timeline_kind='custom_milestone')
// and returns the resulting TimelineEvent so the caller can append it
// directly to the rendered list without a re-fetch.
//
// The user must already have visibility on the project — checked via
// ProjectService.GetByID. occurredAt is optional; when nil, the event
// surfaces as undated (sorts to the end) but is still findable via
// created_at on the audit log.
func (s *ProjectionService) RecordCustomMilestone(
ctx context.Context,
userID, projectID uuid.UUID,
title string,
description *string,
occurredAt *time.Time,
) (*TimelineEvent, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
id := uuid.New()
now := time.Now().UTC()
var eventDate *time.Time
if occurredAt != nil {
ts := occurredAt.UTC()
eventDate = &ts
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', $3, $4, $5, $6, '{}'::jsonb, $7, $7, 'custom_milestone')`,
id, projectID, title, description, eventDate, userID, now)
if err != nil {
return nil, fmt.Errorf("insert custom_milestone: %w", err)
}
when := now
if eventDate != nil {
when = *eventDate
}
whenCopy := when
ev := &TimelineEvent{
Kind: "milestone",
Status: "off_script",
Track: "parent",
Date: &whenCopy,
Title: title,
ProjectEventID: &id,
}
if description != nil {
ev.Description = *description
}
return ev, nil
}
// ----------------------------------------------------------------------
// Pure helpers — kept package-private and testable without DB.
// ----------------------------------------------------------------------
// deadlineStatus maps the deadline row's status + due date into the
// SmartTimeline status vocabulary. "completed" → done; pending →
// overdue (past) or open (today/future).
func deadlineStatus(rawStatus string, due time.Time) string {
if rawStatus == "completed" {
return "done"
}
today := startOfUTCDay(time.Now().UTC())
dueDay := startOfUTCDay(due)
if dueDay.Before(today) {
return "overdue"
}
return "open"
}
// appointmentStatus is "done" once the start has passed, "open" otherwise.
// Appointments don't carry a completion bit (CalDAV-source-of-truth) so
// the timeline classifies past ones as done by date alone — same model
// the dashboard uses today.
func appointmentStatus(start, now time.Time) string {
if start.Before(now) {
return "done"
}
return "open"
}
// milestoneStatus picks a status for project_event timeline rows. The
// "off_script" status applies to user-added free-form milestones; all
// other audit rows get "done" because they record something that already
// happened. Slice 1 doesn't surface predicted milestones — that lands
// with the projected-row Kind in Slice 2.
func milestoneStatus(timelineKind, eventType *string) string {
if timelineKind != nil && *timelineKind == "custom_milestone" {
return "off_script"
}
if eventType != nil && *eventType == "custom_milestone" {
return "off_script"
}
return "done"
}
// sortTimeline orders rows by Date ASC with undated rows pinned at the
// end; ties break on title then provenance id so the order is fully
// deterministic across requests.
func sortTimeline(rows []TimelineEvent) {
sort.SliceStable(rows, func(i, j int) bool {
di, dj := rows[i].Date, rows[j].Date
if di == nil && dj == nil {
return timelineTiebreak(rows[i], rows[j])
}
if di == nil {
return false
}
if dj == nil {
return true
}
if di.Equal(*dj) {
return timelineTiebreak(rows[i], rows[j])
}
return di.Before(*dj)
})
}
// timelineTiebreak: deadlines before appointments before milestones
// before projected; same-kind ties fall back to title then to the
// stringified provenance UUID (which is always non-empty for actuals).
func timelineTiebreak(a, b TimelineEvent) bool {
if a.Kind != b.Kind {
return kindOrder(a.Kind) < kindOrder(b.Kind)
}
if a.Title != b.Title {
return a.Title < b.Title
}
return timelineRowID(a) < timelineRowID(b)
}
func kindOrder(kind string) int {
switch kind {
case "deadline":
return 0
case "appointment":
return 1
case "milestone":
return 2
case "projected":
return 3
}
return 4
}
func timelineRowID(ev TimelineEvent) string {
switch {
case ev.DeadlineID != nil:
return ev.DeadlineID.String()
case ev.AppointmentID != nil:
return ev.AppointmentID.String()
case ev.ProjectEventID != nil:
return ev.ProjectEventID.String()
}
return ""
}
func startOfUTCDay(t time.Time) time.Time {
t = t.UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
func timePtr(t time.Time) *time.Time { return &t }