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.
440 lines
14 KiB
Go
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 }
|