Redefines the "Nur persönliche" filter on /events from "appointment with NULL project_id" to "items where created_by = me", applied uniformly to deadlines and appointments. Before: client-side filter dropped every deadline row because the type guard was `x.type === "appointment"`. m saw zero deadlines under "Nur persönliche" even though he created plenty. After: - /api/events?personal_only=true (and /api/events/summary?personal_only=true) narrow BOTH rails to f.created_by / t.created_by = current user. ProjectID is ignored when personal_only is set (the two are contradictory). - DeadlineService.ListFilter and AppointmentService.AppointmentListFilter gain CreatedBy *uuid.UUID — composes with existing visibility (AND), so a row created on a team the user has since left still won't leak. - Frontend drops the client-side filter; sends personal_only=true when projectFilter === PERSONAL. URL ?personal_only=true also accepted on initial load (bookmark-friendly alias for ?project_id=__personal__). Personal option now shows for type=Fristen too — applies uniformly. - 3 new live subtests covering personal_only across type=deadline / appointment / all, with mixed-creator + multi-project + null-project fixtures.
518 lines
18 KiB
Go
518 lines
18 KiB
Go
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").
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
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,
|
|
}
|
|
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
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
type EventSummaryFilter struct {
|
|
Type EventTypeFilter
|
|
ProjectID *uuid.UUID
|
|
PersonalOnly 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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.Deadlines = buckets
|
|
}
|
|
|
|
if wantAppointments {
|
|
buckets, err := s.appointmentBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly)
|
|
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 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 {
|
|
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 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 {
|
|
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
|
|
}
|