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:
@@ -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
127
internal/handlers/events.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,7 @@ type dbServices struct {
|
||||
audit *services.AuditService
|
||||
emailTemplate *services.EmailTemplateService
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
400
internal/services/event_service.go
Normal file
400
internal/services/event_service.go
Normal 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
|
||||
}
|
||||
368
internal/services/event_service_test.go
Normal file
368
internal/services/event_service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user