Files
paliad/internal/handlers/events.go
m db4279d148 fix(t-paliad-152): /api/events honours direct_only — Fristen/Termine subtree toggle works again
The frontend toggle on /projects/{id} Fristen + Termine emitted
`&direct_only=true`, but `handleListEvents` and `handleEventsSummary`
never read the param, so EventListFilter / EventSummaryFilter went out
without DirectOnly and the backend always returned the subtree-aggregated
default (per t-paliad-139). The toggle has been silently dead since the
Fristen/Termine surfaces migrated to /api/events in t-paliad-139.

Backend-only fix, symmetric across endpoints:

- ListFilter (deadlines), AppointmentListFilter, EventListFilter,
  EventSummaryFilter all gain DirectOnly bool.
- When ProjectID != nil && DirectOnly, the SQL predicate swaps from
  projectDescendantPredicate("p") to a direct `<alias>.project_id = :project_id`
  scope on each rail (deadline list, appointment list, deadline+appointment
  bucket counts).
- Handlers parse `direct_only` via the existing parseDirectOnly helper.
- Test extends project_filter_descendants_test.go with three DirectOnly=true
  assertions (events, deadlines, appointments) — each must collapse to the
  one direct seed row.

DirectOnly is a no-op when ProjectID is nil or PersonalOnly is set —
PersonalOnly already nullifies ProjectID.

Verlauf is untouched: it still uses /api/projects/{id}/events, which
already wired direct_only via projects.go:512.
2026-05-07 22:58:44 +02:00

155 lines
4.9 KiB
Go

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=…&personal_only=true&direct_only=true
//
// 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 on the deadline rail;
// bucket values also narrow the appointment rail).
// project_id — single project scope. Ignored when personal_only=true.
// 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.
// personal_only — narrow BOTH rails to rows the caller created
// (t-paliad-128). Mutually exclusive with project_id —
// if both are sent, project_id is ignored.
// direct_only — narrow project_id from "this project + every descendant"
// (t-paliad-139 subtree default) to "this project only"
// (t-paliad-152). No effect when project_id is unset or
// personal_only=true.
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")),
PersonalOnly: parseBoolFlag(q.Get("personal_only")),
DirectOnly: parseDirectOnly(q.Get("direct_only")),
}
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=…&personal_only=true&direct_only=true
//
// direct_only mirrors /api/events — narrows project_id to the direct project
// (no descendants) when truthy. No effect when project_id is unset or
// personal_only=true.
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")),
PersonalOnly: parseBoolFlag(q.Get("personal_only")),
DirectOnly: parseDirectOnly(q.Get("direct_only")),
}
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
}
}
// parseBoolFlag accepts the common truthy spellings used in URL flags.
// Anything else (including the empty string) is false, matching Go's
// usual permissive treatment of optional bool query params.
func parseBoolFlag(raw string) bool {
switch raw {
case "true", "1", "yes", "on":
return true
}
return false
}