diff --git a/cmd/server/main.go b/cmd/server/main.go index 70908c2..23455ab 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -119,12 +119,13 @@ func main() { mailSvc.SetTemplateService(emailTemplateSvc) eventTypeSvc := services.NewEventTypeService(pool, users) + deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc) svcBundle = &handlers.Services{ Project: projectSvc, Team: teamSvc, PartnerUnit: partnerUnitSvc, Party: services.NewPartyService(pool, projectSvc), - Deadline: services.NewDeadlineService(pool, projectSvc, eventTypeSvc), + Deadline: deadlineSvc, Appointment: appointmentSvc, CalDAV: caldavSvc, Rules: rules, @@ -142,6 +143,7 @@ func main() { Audit: services.NewAuditService(pool), EmailTemplate: emailTemplateSvc, Link: services.NewLinkService(pool), + Event: services.NewEventService(pool, deadlineSvc, appointmentSvc), } log.Println("Phase B services initialised") diff --git a/internal/handlers/events.go b/internal/handlers/events.go new file mode 100644 index 0000000..e659818 --- /dev/null +++ b/internal/handlers/events.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "net/http" + + "github.com/google/uuid" + + "mgit.msbls.de/m/paliad/internal/services" +) + +// GET /api/events?type=deadline|appointment|all&status=…&project_id=…&event_type=…&type_filter=…&from=…&to=… +// +// type — discriminator for the union; default "all" (Beides on the +// front-end). When "deadline" or "appointment", the matching +// type-specific filters take effect; the others are ignored. +// status — DeadlineStatusFilter (deadline-only). +// project_id — single project scope. +// event_type — comma-separated event_type uuids; the literal "none" +// toggles include-untyped (deadlines only). +// type_filter — appointment_type filter (hearing/meeting/...). Named +// `type_filter` in the query string to avoid clashing with +// the `type` discriminator above. +// from / to — date window applied to the canonical event_date. +func handleListEvents(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + q := r.URL.Query() + + filter := services.EventListFilter{ + Type: parseEventTypeDiscriminator(q.Get("type")), + Status: services.DeadlineStatusFilter(q.Get("status")), + } + + if raw := q.Get("project_id"); raw != "" { + projectID, err := uuid.Parse(raw) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"}) + return + } + filter.ProjectID = &projectID + } + + ids, untyped, err := parseEventTypeFilter(q.Get("event_type")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + filter.EventTypeIDs = ids + filter.IncludeUntyped = untyped + + if raw := q.Get("type_filter"); raw != "" { + filter.AppointmentType = &raw + } + if raw := q.Get("from"); raw != "" { + t, err := parseDateOrTime(raw) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid from"}) + return + } + filter.From = &t + } + if raw := q.Get("to"); raw != "" { + t, err := parseDateOrTime(raw) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid to"}) + return + } + filter.To = &t + } + + rows, err := dbSvc.event.ListVisibleForUser(r.Context(), uid, filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, rows) +} + +// GET /api/events/summary?type=deadline|appointment|all&project_id=… +func handleEventsSummary(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + q := r.URL.Query() + + filter := services.EventSummaryFilter{ + Type: parseEventTypeDiscriminator(q.Get("type")), + } + if raw := q.Get("project_id"); raw != "" { + projectID, err := uuid.Parse(raw) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"}) + return + } + filter.ProjectID = &projectID + } + + c, err := dbSvc.event.SummaryCounts(r.Context(), uid, filter) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, c) +} + +// parseEventTypeDiscriminator maps the user-facing `type=` query value +// onto the EventTypeFilter constants. Empty / "all" / unknown all degrade +// to "both" so a typo or older client doesn't 4xx. +func parseEventTypeDiscriminator(raw string) services.EventTypeFilter { + switch raw { + case "deadline": + return services.EventTypeDeadline + case "appointment": + return services.EventTypeAppointment + default: + return services.EventTypeAll + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 08c8991..e5f829a 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -58,6 +58,7 @@ type Services struct { Audit *services.AuditService EmailTemplate *services.EmailTemplateService Link *services.LinkService + Event *services.EventService } func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) { @@ -88,6 +89,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc audit: svc.Audit, emailTemplate: svc.EmailTemplate, link: svc.Link, + event: svc.Event, } } @@ -223,6 +225,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("POST /api/event-types", handleCreateEventType) protected.HandleFunc("PATCH /api/event-types/{id}", handleUpdateEventType) + // t-paliad-110 — unified events endpoint backing the shared EventsPage + // rendered on /deadlines and /appointments. Coexists with the + // type-specific /api/deadlines + /api/appointments endpoints (calendars, + // project-detail panes, mobile/PWA still call those directly). + protected.HandleFunc("GET /api/events", handleListEvents) + protected.HandleFunc("GET /api/events/summary", handleEventsSummary) + // Phase E — Deadlines (persistent deadlines) protected.HandleFunc("GET /api/deadlines", handleListDeadlines) protected.HandleFunc("GET /api/deadlines/summary", handleDeadlinesSummary) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 4036e23..f16240c 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -38,6 +38,7 @@ type dbServices struct { audit *services.AuditService emailTemplate *services.EmailTemplateService link *services.LinkService + event *services.EventService } var dbSvc *dbServices diff --git a/internal/services/event_service.go b/internal/services/event_service.go new file mode 100644 index 0000000..f4e4643 --- /dev/null +++ b/internal/services/event_service.go @@ -0,0 +1,400 @@ +package services + +// EventService is the unified read facade over Deadlines + Appointments. +// /deadlines and /appointments both render one EventsPage that calls +// /api/events?type=deadline|appointment|all — this service is what backs +// that endpoint and the matching summary counts. +// +// Visibility, validation, and event_type hydration are all delegated to +// DeadlineService / AppointmentService — this layer adds nothing on top +// other than the projection to EventListItem and the bucket math used by +// SummaryCounts. Mutations stay on the type-specific services; the +// handlers call them directly. See docs/design-events-unification-2026-05-04.md +// (t-paliad-109) for the design rationale. + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/paliad/internal/models" +) + +// EventTypeFilter selects which side of the union ListVisibleForUser +// returns. Empty string means "both"; the constants spell out the +// allowed values. +type EventTypeFilter string + +const ( + EventTypeAll EventTypeFilter = "" + EventTypeDeadline EventTypeFilter = "deadline" + EventTypeAppointment EventTypeFilter = "appointment" +) + +// EventService wraps the deadline + appointment services. +type EventService struct { + db *sqlx.DB + deadlines *DeadlineService + appointments *AppointmentService +} + +func NewEventService(db *sqlx.DB, deadlines *DeadlineService, appointments *AppointmentService) *EventService { + return &EventService{db: db, deadlines: deadlines, appointments: appointments} +} + +// EventListFilter narrows ListVisibleForUser. Most fields are type-specific; +// passing them with Type=Appointment (or vice versa) is a no-op rather than +// an error so the handler can stay shape-stable across type switches. +type EventListFilter struct { + Type EventTypeFilter + + // Deadline-only. AppointmentType applies only to appointments. + Status DeadlineStatusFilter + EventTypeIDs []uuid.UUID + IncludeUntyped bool + AppointmentType *string + + // Common. + ProjectID *uuid.UUID + From *time.Time + To *time.Time +} + +// EventListItem is one row of the unified events list. Type-specific +// columns are pointers so the JSON shape carries only the fields that +// apply; the frontend type-narrows on `type`. +type EventListItem struct { + Type string `json:"type"` // "deadline" | "appointment" + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + EventDate time.Time `json:"event_date"` // canonical sort key (deadline: due_date 00:00 UTC; appointment: start_at) + ProjectID *uuid.UUID `json:"project_id,omitempty"` + ProjectReference *string `json:"project_reference,omitempty"` + ProjectTitle *string `json:"project_title,omitempty"` + ProjectType *string `json:"project_type,omitempty"` + CreatedBy *uuid.UUID `json:"created_by,omitempty"` + + // Deadline-only. + DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD + Status *string `json:"status,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Source *string `json:"source,omitempty"` + RuleID *uuid.UUID `json:"rule_id,omitempty"` + RuleCode *string `json:"rule_code,omitempty"` + RuleName *string `json:"rule_name,omitempty"` + RuleNameEN *string `json:"rule_name_en,omitempty"` + EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"` + + // Appointment-only. + StartAt *time.Time `json:"start_at,omitempty"` + EndAt *time.Time `json:"end_at,omitempty"` + Location *string `json:"location,omitempty"` + AppointmentType *string `json:"appointment_type,omitempty"` +} + +// ListVisibleForUser returns events the user can see, sorted by event_date +// ascending. Deadlines and appointments are merged when Type is "" / "all". +func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter EventListFilter) ([]EventListItem, error) { + wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline + wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment + + out := make([]EventListItem, 0, 64) + + if wantDeadlines { + df := ListFilter{ + Status: filter.Status, + ProjectID: filter.ProjectID, + EventTypeIDs: filter.EventTypeIDs, + IncludeUntyped: filter.IncludeUntyped, + } + rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df) + if err != nil { + return nil, err + } + for _, r := range rows { + if !inDateWindow(r.DueDate, filter.From, filter.To) { + continue + } + out = append(out, projectDeadline(r)) + } + } + + if wantAppointments { + af := AppointmentListFilter{ + ProjectID: filter.ProjectID, + From: filter.From, + To: filter.To, + Type: filter.AppointmentType, + } + rows, err := s.appointments.ListVisibleForUser(ctx, userID, af) + if err != nil { + return nil, err + } + for _, r := range rows { + out = append(out, projectAppointment(r)) + } + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].EventDate.Equal(out[j].EventDate) { + // Stable tiebreaker: deadlines before appointments on the same + // instant, then alphabetic by title — matches AgendaService. + if out[i].Type != out[j].Type { + return out[i].Type == "deadline" + } + return out[i].Title < out[j].Title + } + return out[i].EventDate.Before(out[j].EventDate) + }) + + return out, nil +} + +// projectDeadline projects a DeadlineWithProject row into the union shape. +func projectDeadline(d models.DeadlineWithProject) EventListItem { + pid := d.ProjectID + pt := d.ProjectTitle + ptype := d.ProjectType + due := d.DueDate.Format("2006-01-02") + status := d.Status + src := d.Source + + return EventListItem{ + Type: "deadline", + ID: d.ID, + Title: d.Title, + Description: d.Description, + EventDate: time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC), + ProjectID: &pid, + ProjectReference: d.ProjectReference, + ProjectTitle: &pt, + ProjectType: &ptype, + CreatedBy: d.CreatedBy, + DueDate: &due, + Status: &status, + CompletedAt: d.CompletedAt, + Source: &src, + RuleID: d.RuleID, + RuleCode: d.RuleCode, + RuleName: d.RuleName, + RuleNameEN: d.RuleNameEN, + EventTypeIDs: d.EventTypeIDs, + } +} + +// projectAppointment projects an AppointmentWithProject row into the union shape. +func projectAppointment(a models.AppointmentWithProject) EventListItem { + startCopy := a.StartAt + return EventListItem{ + Type: "appointment", + ID: a.ID, + Title: a.Title, + Description: a.Description, + EventDate: a.StartAt, + ProjectID: a.ProjectID, + ProjectReference: a.ProjectReference, + ProjectTitle: a.ProjectTitle, + ProjectType: a.ProjectType, + CreatedBy: a.CreatedBy, + StartAt: &startCopy, + EndAt: a.EndAt, + Location: a.Location, + AppointmentType: a.AppointmentType, + } +} + +// inDateWindow returns true when due is inside [from, to]. Both ends are +// optional. The deadline ListFilter has no date-range support today, so we +// post-filter in memory — fine because the per-user deadline set is small. +func inDateWindow(due time.Time, from, to *time.Time) bool { + if from != nil && due.Before(from.UTC()) { + return false + } + if to != nil && due.After(to.UTC()) { + return false + } + return true +} + +// EventSummaryFilter narrows SummaryCounts. Today only `Type` and +// `ProjectID` matter; status/event_type filters intentionally don't shape +// the bucket counts (the cards are global "what's coming?" indicators). +type EventSummaryFilter struct { + Type EventTypeFilter + ProjectID *uuid.UUID +} + +// EventSummary is the response shape of /api/events/summary. Either side +// is omitted when the matching Type filter excludes it; the frontend reads +// presence and renders the appropriate rail. +// +// The four universal cards are Heute / Diese Woche / Nächste Woche / +// Später. Überfällig is deadline-only and conditional (count > 0). Erledigt +// stays in the response so the dropdown filter can render the unread badge +// but is no longer rendered as a card (t-paliad-110, supersedes t-106). +type EventSummary struct { + Deadlines *DeadlineBuckets `json:"deadlines,omitempty"` + Appointments *AppointmentBuckets `json:"appointments,omitempty"` +} + +// DeadlineBuckets counts deadlines across the five disjoint pending +// buckets plus the all-time completed total. +type DeadlineBuckets struct { + Overdue int `json:"overdue" db:"overdue"` + Today int `json:"today" db:"today"` + ThisWeek int `json:"this_week" db:"this_week"` + NextWeek int `json:"next_week" db:"next_week"` + Later int `json:"later" db:"later"` + Completed int `json:"completed" db:"completed"` + Total int `json:"total" db:"total"` +} + +// AppointmentBuckets counts appointments by start-date bucket. Past +// appointments do not get a bucket (per t-paliad-110 §F Q14: appointments +// have no completed_at; past ones are reachable via filter / pagination +// but don't contribute to a card). +type AppointmentBuckets struct { + Today int `json:"today" db:"today"` + ThisWeek int `json:"this_week" db:"this_week"` + NextWeek int `json:"next_week" db:"next_week"` + Later int `json:"later" db:"later"` + Total int `json:"total" db:"total"` +} + +// SummaryCounts returns the bucket counts for the user's visible events. +// +// The five disjoint buckets share their cutoffs with computeDeadlineBucketBounds +// (deadline_service.go) so /api/events/summary, /api/deadlines/summary, and +// the dashboard's deadline rail can never disagree. +// +// Overdue — pending AND due_date < today (deadlines only) +// Today — pending AND due_date = today (deadlines) +// start_at within [today, tomorrow) (appointments) +// ThisWeek — pending AND tomorrow <= due_date <= upcoming Sunday (deadlines) +// tomorrow <= start_at < Mon-next-week (appointments) +// NextWeek — Mon-next-week <= due_date < Mon-week-after (deadlines) +// Mon-next-week <= start_at < Mon-week-after (appointments) +// Later — due_date >= Mon-week-after (deadlines) +// start_at >= Mon-week-after (appointments) +// Completed — status='completed' (deadlines only; all-time count) +func (s *EventService) SummaryCounts(ctx context.Context, userID uuid.UUID, filter EventSummaryFilter) (*EventSummary, error) { + user, err := s.deadlines.users().GetByID(ctx, userID) + if err != nil { + return nil, err + } + if user == nil { + return &EventSummary{}, nil + } + + out := &EventSummary{} + wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline + wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment + + bounds := computeDeadlineBucketBounds(time.Now().UTC()) + + if wantDeadlines { + buckets, err := s.deadlineBuckets(ctx, userID, filter.ProjectID, bounds) + if err != nil { + return nil, err + } + out.Deadlines = buckets + } + + if wantAppointments { + buckets, err := s.appointmentBuckets(ctx, userID, filter.ProjectID, bounds) + if err != nil { + return nil, err + } + out.Appointments = buckets + } + + return out, nil +} + +func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds) (*DeadlineBuckets, error) { + conds := []string{visibilityPredicate("p")} + args := map[string]any{ + "user_id": userID, + "today": b.today, + "tomorrow": b.tomorrow, + "next_monday": b.nextMonday, + "week_after": b.weekAfter, + } + if projectID != nil { + conds = append(conds, `f.project_id = :project_id`) + args["project_id"] = *projectID + } + + query := ` + SELECT + COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue, + COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date = :today) AS today, + COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :tomorrow AND f.due_date < :next_monday) AS this_week, + COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after) AS next_week, + COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :week_after) AS later, + COUNT(*) FILTER (WHERE f.status = 'completed') AS completed, + COUNT(*) AS total + FROM paliad.deadlines f + JOIN paliad.projects p ON p.id = f.project_id + WHERE ` + strings.Join(conds, " AND ") + + stmt, err := s.db.PrepareNamedContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("prepare deadline summary: %w", err) + } + defer stmt.Close() + + var c DeadlineBuckets + if err := stmt.GetContext(ctx, &c, args); err != nil { + return nil, fmt.Errorf("event deadline summary: %w", err) + } + return &c, nil +} + +func (s *EventService) appointmentBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds) (*AppointmentBuckets, error) { + visibility := `( + (t.project_id IS NULL AND t.created_by = :user_id) + OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `) + )` + conds := []string{visibility} + args := map[string]any{ + "user_id": userID, + "today": b.today, + "tomorrow": b.tomorrow, + "next_monday": b.nextMonday, + "week_after": b.weekAfter, + } + if projectID != nil { + conds = append(conds, `t.project_id = :project_id`) + args["project_id"] = *projectID + } + + query := ` + SELECT + COUNT(*) FILTER (WHERE t.start_at >= :today AND t.start_at < :tomorrow) AS today, + COUNT(*) FILTER (WHERE t.start_at >= :tomorrow AND t.start_at < :next_monday) AS this_week, + COUNT(*) FILTER (WHERE t.start_at >= :next_monday AND t.start_at < :week_after) AS next_week, + COUNT(*) FILTER (WHERE t.start_at >= :week_after) AS later, + COUNT(*) FILTER (WHERE t.start_at >= :today) AS total + FROM paliad.appointments t + LEFT JOIN paliad.projects p ON p.id = t.project_id + WHERE ` + strings.Join(conds, " AND ") + + stmt, err := s.db.PrepareNamedContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("prepare appointment summary: %w", err) + } + defer stmt.Close() + + var c AppointmentBuckets + if err := stmt.GetContext(ctx, &c, args); err != nil { + return nil, fmt.Errorf("event appointment summary: %w", err) + } + return &c, nil +} diff --git a/internal/services/event_service_test.go b/internal/services/event_service_test.go new file mode 100644 index 0000000..add0128 --- /dev/null +++ b/internal/services/event_service_test.go @@ -0,0 +1,368 @@ +package services + +import ( + "context" + "os" + "sort" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/internal/db" + "mgit.msbls.de/m/paliad/internal/models" +) + +// TestProjectDeadline_ShapeStable spot-checks projectDeadline so a future +// addition to DeadlineWithProject doesn't silently drop a field from the +// EventListItem JSON shape the frontend relies on. +func TestProjectDeadline_ShapeStable(t *testing.T) { + due := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) + descr := "Reply to Defence" + src := "fristenrechner" + rcode := "RoP.029" + rname := "Replik" + rnameEN := "Reply" + d := models.DeadlineWithProject{ + Deadline: models.Deadline{ + ID: uuid.New(), + ProjectID: uuid.New(), + Title: "Statement of Defence", + Description: &descr, + DueDate: due, + Source: src, + Status: "pending", + EventTypeIDs: []uuid.UUID{uuid.New()}, + }, + ProjectTitle: "Acme v. Foo", + ProjectType: "case", + RuleCode: &rcode, + RuleName: &rname, + RuleNameEN: &rnameEN, + } + out := projectDeadline(d) + + if out.Type != "deadline" { + t.Fatalf("type = %q, want deadline", out.Type) + } + if !out.EventDate.Equal(due) { + t.Errorf("event_date = %v, want %v", out.EventDate, due) + } + if out.DueDate == nil || *out.DueDate != "2026-08-31" { + t.Errorf("due_date = %v, want 2026-08-31", out.DueDate) + } + if out.Status == nil || *out.Status != "pending" { + t.Errorf("status = %v, want pending", out.Status) + } + if out.RuleCode == nil || *out.RuleCode != "RoP.029" { + t.Errorf("rule_code = %v, want RoP.029", out.RuleCode) + } + if out.StartAt != nil || out.EndAt != nil || out.Location != nil || out.AppointmentType != nil { + t.Errorf("appointment-only fields leaked onto deadline projection: %+v", out) + } + if len(out.EventTypeIDs) != 1 { + t.Errorf("event_type_ids length = %d, want 1", len(out.EventTypeIDs)) + } +} + +// TestProjectAppointment_ShapeStable does the same for the appointment side. +func TestProjectAppointment_ShapeStable(t *testing.T) { + start := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC) + end := start.Add(2 * time.Hour) + loc := "UPC LD München" + atype := "hearing" + pid := uuid.New() + ptitle := "Acme v. Foo" + ptype := "case" + a := models.AppointmentWithProject{ + Appointment: models.Appointment{ + ID: uuid.New(), + ProjectID: &pid, + Title: "Mündliche Verhandlung", + StartAt: start, + EndAt: &end, + Location: &loc, + AppointmentType: &atype, + }, + ProjectTitle: &ptitle, + ProjectType: &ptype, + } + out := projectAppointment(a) + + if out.Type != "appointment" { + t.Fatalf("type = %q, want appointment", out.Type) + } + if !out.EventDate.Equal(start) { + t.Errorf("event_date = %v, want %v", out.EventDate, start) + } + if out.StartAt == nil || !out.StartAt.Equal(start) { + t.Errorf("start_at = %v, want %v", out.StartAt, start) + } + if out.EndAt == nil || !out.EndAt.Equal(end) { + t.Errorf("end_at = %v, want %v", out.EndAt, end) + } + if out.AppointmentType == nil || *out.AppointmentType != "hearing" { + t.Errorf("appointment_type = %v, want hearing", out.AppointmentType) + } + if out.DueDate != nil || out.Status != nil || out.RuleCode != nil || len(out.EventTypeIDs) != 0 { + t.Errorf("deadline-only fields leaked onto appointment projection: %+v", out) + } +} + +// TestInDateWindow checks the inclusive-on-both-ends window math used to +// post-filter deadlines in ListVisibleForUser when the caller passes from/to. +func TestInDateWindow(t *testing.T) { + due := time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC) + before := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + after := time.Date(2026, 5, 14, 0, 0, 0, 0, time.UTC) + + if !inDateWindow(due, nil, nil) { + t.Error("nil/nil window must include any date") + } + if !inDateWindow(due, &before, &after) { + t.Error("expected due ∈ [before, after]") + } + if inDateWindow(due, &after, nil) { + t.Error("date strictly before `from` must be excluded") + } + if inDateWindow(due, nil, &before) { + t.Error("date strictly after `to` must be excluded") + } + // boundary inclusivity + if !inDateWindow(due, &due, &due) { + t.Error("from == to == due must be inclusive on both ends") + } +} + +// TestEventService_ListAndSummary_Live exercises the union list + bucket +// counts against a real database. Skipped when TEST_DATABASE_URL is unset. +func TestEventService_ListAndSummary_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() + adminID := uuid.New() + projectID := uuid.New() + d1 := uuid.New() // overdue pending + d2 := uuid.New() // today pending + d3 := uuid.New() // next week pending + d4 := uuid.New() // completed + a1 := uuid.New() // today appointment + a2 := uuid.New() // later appointment + + cleanup := func() { + pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id IN ($1, $2)`, a1, a2) + pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id IN ($1, $2, $3, $4)`, d1, d2, d3, d4) + pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID) + 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`, adminID) + pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, adminID) + } + cleanup() + defer cleanup() + + if _, err := pool.ExecContext(ctx, + `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, + adminID, "vis-events@hlc.com"); 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, $2, 'Vis Events', 'munich', 'global_admin', 'de')`, + adminID, "vis-events@hlc.com"); 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, 'project', $1::text, 'Vis Events Project', '2026/9994', 'active', $2)`, + projectID, adminID); err != nil { + t.Fatalf("seed paliad.projects: %v", err) + } + + now := time.Now().UTC() + today := now.Truncate(24 * time.Hour) + yesterday := today.AddDate(0, 0, -1) + nextMon := today.AddDate(0, 0, ((7-int(today.Weekday()))%7)+1) // Mon-of-next-week + farFuture := today.AddDate(0, 1, 0) + + // Four deadlines spanning each bucket-shape: overdue, today, next_week, completed. + for _, d := range []struct { + id uuid.UUID + due time.Time + status string + }{ + {d1, yesterday, "pending"}, + {d2, today, "pending"}, + {d3, nextMon, "pending"}, + {d4, today, "completed"}, + } { + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.deadlines + (id, project_id, title, due_date, source, status, completed_at, created_by) + VALUES ($1, $2, 'D', $3::date, 'manual', $4, + CASE WHEN $4 = 'completed' THEN $5::timestamptz END, $6)`, + d.id, projectID, d.due.Format("2006-01-02"), d.status, now, adminID); err != nil { + t.Fatalf("seed deadline %s: %v", d.id, err) + } + } + + // Two appointments: one today, one far in the future. + for _, a := range []struct { + id uuid.UUID + start time.Time + }{ + {a1, today.Add(13 * time.Hour)}, + {a2, farFuture}, + } { + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.appointments + (id, project_id, title, start_at, appointment_type, created_by) + VALUES ($1, $2, 'A', $3, 'meeting', $4)`, + a.id, projectID, a.start, adminID); err != nil { + t.Fatalf("seed appointment %s: %v", a.id, err) + } + } + + users := NewUserService(pool) + projects := NewProjectService(pool, users) + eventTypes := NewEventTypeService(pool, users) + deadlines := NewDeadlineService(pool, projects, eventTypes) + appointments := NewAppointmentService(pool, projects) + events := NewEventService(pool, deadlines, appointments) + + t.Run("ListVisibleForUser type=all merges + sorts", func(t *testing.T) { + rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeAll}) + if err != nil { + t.Fatalf("List all: %v", err) + } + // Filter to seed rows so unrelated projects in the live DB don't + // confuse the assertions. + seedSet := map[uuid.UUID]string{ + d1: "deadline", d2: "deadline", d3: "deadline", d4: "deadline", + a1: "appointment", a2: "appointment", + } + seen := []EventListItem{} + for _, r := range rows { + if _, ok := seedSet[r.ID]; ok { + seen = append(seen, r) + } + } + if len(seen) != len(seedSet) { + t.Fatalf("merged rows = %d, want %d", len(seen), len(seedSet)) + } + // Confirm sort: every neighbour pair is non-decreasing on EventDate. + if !sort.SliceIsSorted(seen, func(i, j int) bool { + return seen[i].EventDate.Before(seen[j].EventDate) + }) { + for _, r := range seen { + t.Logf(" %s %s %s", r.Type, r.ID, r.EventDate.Format(time.RFC3339)) + } + t.Fatal("merged rows not sorted by event_date asc") + } + // Type discriminator must round-trip. + for _, r := range seen { + if want := seedSet[r.ID]; want != r.Type { + t.Errorf("row %s: type = %q, want %q", r.ID, r.Type, want) + } + } + }) + + t.Run("ListVisibleForUser type=deadline excludes appointments", func(t *testing.T) { + rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeDeadline}) + if err != nil { + t.Fatalf("List deadline: %v", err) + } + for _, r := range rows { + if r.Type != "deadline" { + t.Errorf("type=deadline returned %q row", r.Type) + } + if r.ID == a1 || r.ID == a2 { + t.Errorf("type=deadline leaked appointment %s", r.ID) + } + } + }) + + t.Run("ListVisibleForUser type=appointment excludes deadlines", func(t *testing.T) { + rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeAppointment}) + if err != nil { + t.Fatalf("List appointment: %v", err) + } + for _, r := range rows { + if r.Type != "appointment" { + t.Errorf("type=appointment returned %q row", r.Type) + } + } + }) + + t.Run("SummaryCounts type=all has both rails populated", func(t *testing.T) { + s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeAll, ProjectID: &projectID}) + if err != nil { + t.Fatalf("Summary all: %v", err) + } + if s.Deadlines == nil { + t.Fatal("deadlines bucket missing") + } + if s.Appointments == nil { + t.Fatal("appointments bucket missing") + } + // The seed has 1 overdue, 1 today, 1 next-week, 1 completed. + if s.Deadlines.Overdue < 1 { + t.Errorf("Deadlines.Overdue = %d, want >= 1", s.Deadlines.Overdue) + } + if s.Deadlines.Today < 1 { + t.Errorf("Deadlines.Today = %d, want >= 1", s.Deadlines.Today) + } + if s.Deadlines.NextWeek < 1 { + t.Errorf("Deadlines.NextWeek = %d, want >= 1", s.Deadlines.NextWeek) + } + if s.Deadlines.Completed < 1 { + t.Errorf("Deadlines.Completed = %d, want >= 1", s.Deadlines.Completed) + } + // Appointments: 1 today, 1 later. + if s.Appointments.Today < 1 { + t.Errorf("Appointments.Today = %d, want >= 1", s.Appointments.Today) + } + if s.Appointments.Later < 1 { + t.Errorf("Appointments.Later = %d, want >= 1", s.Appointments.Later) + } + }) + + t.Run("SummaryCounts type=deadline omits appointments rail", func(t *testing.T) { + s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeDeadline, ProjectID: &projectID}) + if err != nil { + t.Fatalf("Summary deadline: %v", err) + } + if s.Deadlines == nil { + t.Fatal("deadlines bucket missing on type=deadline") + } + if s.Appointments != nil { + t.Errorf("appointments rail must be omitted on type=deadline (got %+v)", s.Appointments) + } + }) + + t.Run("SummaryCounts type=appointment omits deadlines rail", func(t *testing.T) { + s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeAppointment, ProjectID: &projectID}) + if err != nil { + t.Fatalf("Summary appointment: %v", err) + } + if s.Appointments == nil { + t.Fatal("appointments bucket missing on type=appointment") + } + if s.Deadlines != nil { + t.Errorf("deadlines rail must be omitted on type=appointment (got %+v)", s.Deadlines) + } + }) +}