feat(t-paliad-110): add EventService + /api/events + /api/events/summary

PR-1 of the Fristen+Termine unification (t-paliad-110). Backend layer
only — no frontend changes; the existing /deadlines and /appointments
pages still render the type-specific UIs.

EventService delegates to DeadlineService + AppointmentService for the
actual reads (no duplicate visibility logic, no duplicate event_type
hydration), then projects both into the discriminated EventListItem
union and merges/sorts by event_date asc. The handler exposes:

  GET /api/events?type=deadline|appointment|all&status=…&project_id=…
                  &event_type=…&type_filter=…&from=…&to=…
  GET /api/events/summary?type=…&project_id=…

Bucket model (per t-paliad-110 spec, supersedes t-106):
- four universal cards: Heute · Diese Woche · Nächste Woche · Später
- Überfällig is deadline-only, conditional, alarm-styled when > 0
- Erledigt drops from the card row; stays available as a filter option
- appointments have no completed_at — past appointments aren't bucketed

The deadline-side cutoffs reuse computeDeadlineBucketBounds so
/api/events/summary and /api/deadlines/summary can never disagree.

Existing /api/deadlines and /api/appointments stay untouched —
calendars, project-detail panes, and CalDAV consumers still call them
directly.
This commit is contained in:
m
2026-05-04 13:37:20 +02:00
parent 1def9e86b9
commit 2102dfd07d
6 changed files with 908 additions and 1 deletions

View File

@@ -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")

127
internal/handlers/events.go Normal file
View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -38,6 +38,7 @@ type dbServices struct {
audit *services.AuditService
emailTemplate *services.EmailTemplateService
link *services.LinkService
event *services.EventService
}
var dbSvc *dbServices

View File

@@ -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
}

View File

@@ -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)
}
})
}