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.
This commit is contained in:
439
internal/services/projection_service.go
Normal file
439
internal/services/projection_service.go
Normal file
@@ -0,0 +1,439 @@
|
||||
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 }
|
||||
253
internal/services/projection_service_test.go
Normal file
253
internal/services/projection_service_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration test for ProjectionService — applies migrations,
|
||||
// seeds one project + one deadline + one appointment + one
|
||||
// timeline_kind-tagged project_event, and asserts the merge returns
|
||||
// three rows in the right order. Skipped when TEST_DATABASE_URL is
|
||||
// unset, mirroring the convention of the other live tests in this
|
||||
// package.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestProjectionService_For_MergesActuals_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
deadlineID := uuid.New()
|
||||
apptID := uuid.New()
|
||||
milestoneID := uuid.New()
|
||||
auditOnlyID := uuid.New() // timeline_kind=NULL — must NOT surface in default read
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id = $1`, apptID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id IN ($1, $2)`, milestoneID, auditOnlyID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'projection-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, 'projection-test@hlc.com', 'Projection Test', 'munich', 'global_admin', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
|
||||
VALUES ($1, 'case', $1::text, 'Projection Test Project', '2026/9993', 'active', $2)`,
|
||||
projectID, userID); err != nil {
|
||||
t.Fatalf("seed paliad.projects: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
deadlineDate := now.AddDate(0, 0, 7) // a week from now
|
||||
apptDate := now.AddDate(0, 0, 14) // two weeks from now
|
||||
milestoneDate := now.AddDate(0, 0, -3) // three days ago
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, due_date, source, status, created_by)
|
||||
VALUES ($1, $2, 'Test Deadline', $3::date, 'manual', 'pending', $4)`,
|
||||
deadlineID, projectID, deadlineDate.Format("2006-01-02"), userID); err != nil {
|
||||
t.Fatalf("seed deadline: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.appointments
|
||||
(id, project_id, title, start_at, appointment_type, created_by)
|
||||
VALUES ($1, $2, 'Test Appointment', $3, 'meeting', $4)`,
|
||||
apptID, projectID, apptDate, userID); err != nil {
|
||||
t.Fatalf("seed appointment: %v", err)
|
||||
}
|
||||
// Two project_events: one with timeline_kind set (must surface), one
|
||||
// without (must be filtered out unless include_audit_full).
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, event_date, created_by, metadata,
|
||||
created_at, updated_at, timeline_kind)
|
||||
VALUES ($1, $2, 'custom_milestone', 'Test Milestone', $3, $4,
|
||||
'{}'::jsonb, $5, $5, 'custom_milestone')`,
|
||||
milestoneID, projectID, milestoneDate, userID, now); err != nil {
|
||||
t.Fatalf("seed milestone project_event: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, event_date, created_by, metadata,
|
||||
created_at, updated_at)
|
||||
VALUES ($1, $2, 'project_created', 'Audit-Only Event', $3, $4,
|
||||
'{}'::jsonb, $5, $5)`,
|
||||
auditOnlyID, projectID, milestoneDate.Add(-1*time.Hour), userID, now); err != nil {
|
||||
t.Fatalf("seed audit-only project_event: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
eventTypes := NewEventTypeService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, eventTypes)
|
||||
appointments := NewAppointmentService(pool, projects)
|
||||
projection := NewProjectionService(pool, projects, deadlines, appointments)
|
||||
|
||||
t.Run("default — only timeline_kind milestones surface", func(t *testing.T) {
|
||||
rows, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For: %v", err)
|
||||
}
|
||||
// Filter to seed rows so unrelated rows in the live DB don't
|
||||
// confuse the assertions. We reference rows by provenance ID.
|
||||
seen := map[string]TimelineEvent{}
|
||||
for _, r := range rows {
|
||||
switch {
|
||||
case r.DeadlineID != nil && *r.DeadlineID == deadlineID:
|
||||
seen["deadline"] = r
|
||||
case r.AppointmentID != nil && *r.AppointmentID == apptID:
|
||||
seen["appointment"] = r
|
||||
case r.ProjectEventID != nil && *r.ProjectEventID == milestoneID:
|
||||
seen["milestone"] = r
|
||||
case r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID:
|
||||
t.Errorf("audit-only project_event leaked into default read")
|
||||
}
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("expected 3 seed rows, saw %d: %v", len(seen), seen)
|
||||
}
|
||||
// Sort order: milestone (3 days ago) → deadline (+7d) → appointment (+14d).
|
||||
// Find the indices of our seeded rows in the result and check the
|
||||
// relative ordering.
|
||||
idx := func(id uuid.UUID) int {
|
||||
for i, r := range rows {
|
||||
switch {
|
||||
case r.DeadlineID != nil && *r.DeadlineID == id:
|
||||
return i
|
||||
case r.AppointmentID != nil && *r.AppointmentID == id:
|
||||
return i
|
||||
case r.ProjectEventID != nil && *r.ProjectEventID == id:
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if !(idx(milestoneID) < idx(deadlineID) && idx(deadlineID) < idx(apptID)) {
|
||||
t.Errorf("wrong sort: milestone=%d deadline=%d appt=%d (want asc)",
|
||||
idx(milestoneID), idx(deadlineID), idx(apptID))
|
||||
}
|
||||
|
||||
// Field shape — kind, status, deep-link IDs.
|
||||
dl := seen["deadline"]
|
||||
if dl.Kind != "deadline" {
|
||||
t.Errorf("deadline.Kind = %q, want deadline", dl.Kind)
|
||||
}
|
||||
if dl.Status != "open" {
|
||||
t.Errorf("deadline.Status = %q, want open (future date)", dl.Status)
|
||||
}
|
||||
if dl.Title != "Test Deadline" {
|
||||
t.Errorf("deadline.Title = %q", dl.Title)
|
||||
}
|
||||
ap := seen["appointment"]
|
||||
if ap.Kind != "appointment" || ap.Status != "open" {
|
||||
t.Errorf("appointment kind/status = %q/%q", ap.Kind, ap.Status)
|
||||
}
|
||||
ms := seen["milestone"]
|
||||
if ms.Kind != "milestone" || ms.Status != "off_script" {
|
||||
t.Errorf("milestone kind/status = %q/%q (want milestone/off_script)",
|
||||
ms.Kind, ms.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IncludeAuditFull — both project_events surface", func(t *testing.T) {
|
||||
rows, err := projection.For(ctx, userID, projectID, ProjectionOpts{IncludeAuditFull: true})
|
||||
if err != nil {
|
||||
t.Fatalf("For audit_full: %v", err)
|
||||
}
|
||||
var sawAudit bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID {
|
||||
sawAudit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawAudit {
|
||||
t.Errorf("audit-only project_event should surface with IncludeAuditFull=true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RecordCustomMilestone writes a row with timeline_kind set", func(t *testing.T) {
|
||||
title := "Live-Test Custom Milestone"
|
||||
desc := "from RecordCustomMilestone test"
|
||||
when := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordCustomMilestone: %v", err)
|
||||
}
|
||||
if ev == nil || ev.ProjectEventID == nil {
|
||||
t.Fatalf("RecordCustomMilestone returned nil id")
|
||||
}
|
||||
// Defer cleanup so the row doesn't leak into other tests.
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, *ev.ProjectEventID)
|
||||
|
||||
// Verify the row landed with the expected discriminators.
|
||||
var (
|
||||
eventType string
|
||||
timelineKind *string
|
||||
)
|
||||
if err := pool.QueryRowContext(ctx,
|
||||
`SELECT event_type, timeline_kind FROM paliad.project_events WHERE id = $1`,
|
||||
*ev.ProjectEventID).Scan(&eventType, &timelineKind); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if eventType != "custom_milestone" {
|
||||
t.Errorf("event_type = %q, want custom_milestone", eventType)
|
||||
}
|
||||
if timelineKind == nil || *timelineKind != "custom_milestone" {
|
||||
t.Errorf("timeline_kind = %v, want custom_milestone", timelineKind)
|
||||
}
|
||||
|
||||
// And it must surface in the next read.
|
||||
rows, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For after milestone: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == *ev.ProjectEventID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newly recorded milestone did not surface in For()")
|
||||
}
|
||||
})
|
||||
}
|
||||
153
internal/services/projection_service_unit_test.go
Normal file
153
internal/services/projection_service_unit_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for ProjectionService — no DB required, runs by
|
||||
// default. Validates the deterministic sort order and status-mapping
|
||||
// behaviour; the live integration test in projection_service_test.go
|
||||
// covers the SQL paths.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestSortTimeline_DateAscUndatedLast(t *testing.T) {
|
||||
d1 := uuid.New()
|
||||
d2 := uuid.New()
|
||||
a1 := uuid.New()
|
||||
pe1 := uuid.New()
|
||||
|
||||
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
mar5 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
|
||||
mar10 := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
rows := []TimelineEvent{
|
||||
{Kind: "milestone", Title: "Undated milestone", ProjectEventID: &pe1}, // Date nil
|
||||
{Kind: "deadline", Date: &mar10, Title: "Mar10 deadline", DeadlineID: &d2},
|
||||
{Kind: "deadline", Date: &mar1, Title: "Mar1 deadline", DeadlineID: &d1},
|
||||
{Kind: "appointment", Date: &mar5, Title: "Mar5 appointment", AppointmentID: &a1},
|
||||
}
|
||||
|
||||
sortTimeline(rows)
|
||||
|
||||
// Date ASC (Mar1, Mar5, Mar10), undated last.
|
||||
if rows[0].Title != "Mar1 deadline" {
|
||||
t.Errorf("rows[0] = %q, want Mar1 deadline", rows[0].Title)
|
||||
}
|
||||
if rows[1].Title != "Mar5 appointment" {
|
||||
t.Errorf("rows[1] = %q, want Mar5 appointment", rows[1].Title)
|
||||
}
|
||||
if rows[2].Title != "Mar10 deadline" {
|
||||
t.Errorf("rows[2] = %q, want Mar10 deadline", rows[2].Title)
|
||||
}
|
||||
if rows[3].Title != "Undated milestone" {
|
||||
t.Errorf("rows[3] = %q, want Undated milestone", rows[3].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortTimeline_SameDateTiebreak(t *testing.T) {
|
||||
mar5 := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
d1 := uuid.New()
|
||||
a1 := uuid.New()
|
||||
pe1 := uuid.New()
|
||||
|
||||
rows := []TimelineEvent{
|
||||
{Kind: "milestone", Date: &mar5, Title: "C", ProjectEventID: &pe1},
|
||||
{Kind: "appointment", Date: &mar5, Title: "B", AppointmentID: &a1},
|
||||
{Kind: "deadline", Date: &mar5, Title: "A", DeadlineID: &d1},
|
||||
}
|
||||
|
||||
sortTimeline(rows)
|
||||
|
||||
// Tiebreak: deadline > appointment > milestone (kindOrder).
|
||||
if rows[0].Kind != "deadline" {
|
||||
t.Errorf("rows[0].Kind = %q, want deadline", rows[0].Kind)
|
||||
}
|
||||
if rows[1].Kind != "appointment" {
|
||||
t.Errorf("rows[1].Kind = %q, want appointment", rows[1].Kind)
|
||||
}
|
||||
if rows[2].Kind != "milestone" {
|
||||
t.Errorf("rows[2].Kind = %q, want milestone", rows[2].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineStatus(t *testing.T) {
|
||||
today := time.Now().UTC()
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
due time.Time
|
||||
want string
|
||||
}{
|
||||
{"completed regardless of date", "completed", yesterday, "done"},
|
||||
{"completed even if future", "completed", tomorrow, "done"},
|
||||
{"pending past = overdue", "pending", yesterday, "overdue"},
|
||||
{"pending today = open", "pending", today, "open"},
|
||||
{"pending future = open", "pending", tomorrow, "open"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := deadlineStatus(c.status, c.due)
|
||||
if got != c.want {
|
||||
t.Errorf("deadlineStatus(%q, %v) = %q, want %q",
|
||||
c.status, c.due, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentStatus(t *testing.T) {
|
||||
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add(1 * time.Hour)
|
||||
|
||||
if got := appointmentStatus(past, now); got != "done" {
|
||||
t.Errorf("past appointment status = %q, want done", got)
|
||||
}
|
||||
if got := appointmentStatus(future, now); got != "open" {
|
||||
t.Errorf("future appointment status = %q, want open", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneStatus(t *testing.T) {
|
||||
custom := "custom_milestone"
|
||||
other := "counterclaim_filed"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
timelineKind *string
|
||||
eventType *string
|
||||
want string
|
||||
}{
|
||||
{"custom_milestone via timeline_kind", &custom, nil, "off_script"},
|
||||
{"custom_milestone via event_type fallback", nil, &custom, "off_script"},
|
||||
{"structural milestone = done", nil, &other, "done"},
|
||||
{"both nil = done", nil, nil, "done"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := milestoneStatus(c.timelineKind, c.eventType)
|
||||
if got != c.want {
|
||||
t.Errorf("milestoneStatus = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindOrder(t *testing.T) {
|
||||
// Lock the exact ordering — frontend assumes deadline before
|
||||
// appointment before milestone before projected on same-date ties.
|
||||
if kindOrder("deadline") >= kindOrder("appointment") {
|
||||
t.Error("deadline should sort before appointment")
|
||||
}
|
||||
if kindOrder("appointment") >= kindOrder("milestone") {
|
||||
t.Error("appointment should sort before milestone")
|
||||
}
|
||||
if kindOrder("milestone") >= kindOrder("projected") {
|
||||
t.Error("milestone should sort before projected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user