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. // // PersonalOnly narrows BOTH rails to rows whose `created_by` matches the // caller — backs the "Nur persönliche" filter on /events (t-paliad-128). // When set, ProjectID is ignored (the two are contradictory: personal // means "items I created", not "items in some project I picked"). // // DirectOnly narrows the ProjectID filter from "this project + every // descendant" (t-paliad-139 subtree default) to "this project only" // (t-paliad-152). Backs the "direkt / inkl. Unterprojekte" toggle on // /projects/{id} Fristen + Termine. No effect when ProjectID is nil or // PersonalOnly is set. 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 PersonalOnly bool DirectOnly bool } // 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"` // Approval workflow (t-paliad-138). ApprovalStatus is "approved" // (default), "pending" (in-flight 4-eye request — pill rendered on // every list surface), or "legacy" (pre-4-eye row, no pill). ApprovalStatus *string `json:"approval_status,omitempty"` // RequesterKind is the kind of the in-flight approval request when // approval_status='pending': 'user' or 'agent' (Paliadin-drafted — // t-paliad-161). NULL otherwise. Drives the ✨ glyph alongside 👀. RequesterKind *string `json:"requester_kind,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, DirectOnly: filter.DirectOnly, } if filter.PersonalOnly { uid := userID df.CreatedBy = &uid df.ProjectID = nil } 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 { // Status is a deadline-only filter, but it doubles as the bucket // click-through target on the unified events page. So when the // caller set a bucket-style status (today / this_week / next_week // / later), apply the matching date window to the appointment side // too — clicking "Heute" then shows today's deadlines AND today's // appointments. For overdue/completed (no appointment analogue), // skip the appointment query entirely. if shouldExcludeAppointmentsForStatus(filter.Status) { // no-op } else { af := AppointmentListFilter{ ProjectID: filter.ProjectID, From: filter.From, To: filter.To, Type: filter.AppointmentType, DirectOnly: filter.DirectOnly, } if filter.PersonalOnly { uid := userID af.CreatedBy = &uid af.ProjectID = nil } bounds := computeDeadlineBucketBounds(time.Now().UTC()) from, to := bucketAppointmentWindow(filter.Status, bounds) af.From = pickLater(af.From, from) af.To = pickEarlier(af.To, to) 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 approvalStatus := d.ApprovalStatus 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, ApprovalStatus: &approvalStatus, RequesterKind: d.RequesterKind, 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 approvalStatus := a.ApprovalStatus 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, ApprovalStatus: &approvalStatus, RequesterKind: a.RequesterKind, StartAt: &startCopy, EndAt: a.EndAt, Location: a.Location, AppointmentType: a.AppointmentType, } } // shouldExcludeAppointmentsForStatus returns true for deadline-status // values that have no appointment analogue (overdue, completed). When // the user sets one of those, the appointment rail collapses to empty. func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool { switch status { case DeadlineFilterOverdue, DeadlineFilterCompleted: return true } return false } // bucketAppointmentWindow returns the [from, to) date window that // matches a bucket-style deadline status — used to filter the // appointment side when the user clicks a card on the unified events // page. Returns (nil, nil) for non-bucket statuses (pending / all / // upcoming / "" / overdue / completed — those are handled separately). func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) { switch status { case DeadlineFilterToday: t := b.tomorrow return &b.today, &t case DeadlineFilterThisWeek: t := b.nextMonday return &b.tomorrow, &t case DeadlineFilterNextWeek: t := b.weekAfter return &b.nextMonday, &t case DeadlineFilterLater: return &b.weekAfter, nil } return nil, nil } // pickLater returns whichever of the two times is later (nil propagates // the non-nil value; both nil → nil). Used to intersect bucket-derived // windows with caller-supplied from filters. func pickLater(a, b *time.Time) *time.Time { if a == nil { return b } if b == nil { return a } if a.After(*b) { return a } return b } // pickEarlier returns whichever of the two times is earlier (nil // propagates the non-nil value; both nil → nil). func pickEarlier(a, b *time.Time) *time.Time { if a == nil { return b } if b == nil { return a } if a.Before(*b) { return a } return b } // 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`, // `ProjectID`, and `PersonalOnly` matter; status/event_type filters // intentionally don't shape the bucket counts (the cards are global // "what's coming?" indicators). // // PersonalOnly mirrors EventListFilter.PersonalOnly — narrows both // bucket queries to rows the caller created. ProjectID is ignored when // PersonalOnly is set. // // DirectOnly mirrors EventListFilter.DirectOnly — narrows ProjectID from // "this project + every descendant" to "this project only". No effect // when ProjectID is nil or PersonalOnly is set. type EventSummaryFilter struct { Type EventTypeFilter ProjectID *uuid.UUID PersonalOnly bool DirectOnly bool } // 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()) projectID := filter.ProjectID if filter.PersonalOnly { // PersonalOnly is mutually exclusive with ProjectID — see the // EventSummaryFilter doc comment. projectID = nil } if wantDeadlines { buckets, err := s.deadlineBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly) if err != nil { return nil, err } out.Deadlines = buckets } if wantAppointments { buckets, err := s.appointmentBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly) 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, personalOnly, directOnly bool) (*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 { if directOnly { conds = append(conds, `f.project_id = :project_id`) } else { conds = append(conds, projectDescendantPredicate("p")) } args["project_id"] = *projectID } if personalOnly { conds = append(conds, `f.created_by = :user_id`) } 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, personalOnly, directOnly bool) (*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 { if directOnly { conds = append(conds, `t.project_id = :project_id`) } else { conds = append(conds, projectDescendantPredicate("p")) } args["project_id"] = *projectID } if personalOnly { // Narrow to rows the caller created. AND-joined with the visibility // predicate above so an appointment a user created on a team they // have since left still doesn't leak through. conds = append(conds, `t.created_by = :user_id`) } 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 }