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.
155 lines
4.9 KiB
Go
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
|
|
}
|