m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.
Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.
Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
LEFT JOIN paliad.projects to surface project_title for the Verlauf
attribution chip on /projects/{id}. Inner subquery aliased pp to
avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
enrichment. Other readers leave it nil and the JSON serialiser omits
it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
?direct_only=true|false and passes through to the service. New
handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
DeadlineService.ListForProject + AppointmentService.ListForProject
+ ProjectService.ListEvents (live-DB test, skipped without
TEST_DATABASE_URL).
Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
/api/projects/{id}/deadlines + /appointments (legacy narrow) to
/api/events?type=deadline|appointment&project_id={id} (the union
endpoints, already aggregating + enriching with project_title). The
Verlauf still uses /api/projects/{id}/events but with the new
direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
subtree (true). persistSubtreeMode replaceState keeps back-button
friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
Appointments sections. Shared state across the three; clicking any
toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
.aggregation-chip — small additive styling.
No schema. No migration. Phases 2 + 3 stack on top per design §7.
309 lines
7.7 KiB
Go
309 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// GET /api/deadlines?status=overdue|this_week|upcoming|completed|pending|all&project_id=UUID&event_type=<uuid>,<uuid>,none
|
|
func handleListDeadlines(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
filter := services.ListFilter{
|
|
Status: services.DeadlineStatusFilter(r.URL.Query().Get("status")),
|
|
}
|
|
// Accept both project_id (new) and project_id (legacy alias).
|
|
raw := r.URL.Query().Get("project_id")
|
|
if raw == "" {
|
|
raw = r.URL.Query().Get("project_id")
|
|
}
|
|
if 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(r.URL.Query().Get("event_type"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
filter.EventTypeIDs = ids
|
|
filter.IncludeUntyped = untyped
|
|
|
|
rows, err := dbSvc.deadline.ListVisibleForUser(r.Context(), uid, filter)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// parseEventTypeFilter parses the comma-separated `event_type` query
|
|
// parameter used by both /api/deadlines and /api/agenda. The literal
|
|
// keyword "none" is the toggle for "deadlines without any Event Type
|
|
// attached" (filter.IncludeUntyped). All other tokens must be valid
|
|
// UUIDs of visible event_types — the service layer validates visibility.
|
|
//
|
|
// Returns (ids, includeUntyped, err). Empty/blank input returns
|
|
// (nil, false, nil) — interpret as "no filter".
|
|
func parseEventTypeFilter(raw string) ([]uuid.UUID, bool, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, false, nil
|
|
}
|
|
ids := []uuid.UUID{}
|
|
includeUntyped := false
|
|
for tok := range strings.SplitSeq(raw, ",") {
|
|
t := strings.TrimSpace(tok)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if t == "none" {
|
|
includeUntyped = true
|
|
continue
|
|
}
|
|
id, err := uuid.Parse(t)
|
|
if err != nil {
|
|
return nil, false, &agendaErr{msg: "invalid event_type id " + t}
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, includeUntyped, nil
|
|
}
|
|
|
|
// GET /api/deadlines/summary?project_id=UUID
|
|
func handleDeadlinesSummary(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var projectIDPtr *uuid.UUID
|
|
raw := r.URL.Query().Get("project_id")
|
|
if raw == "" {
|
|
raw = r.URL.Query().Get("project_id")
|
|
}
|
|
if raw != "" {
|
|
projectID, err := uuid.Parse(raw)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
|
|
return
|
|
}
|
|
projectIDPtr = &projectID
|
|
}
|
|
c, err := dbSvc.deadline.SummaryCounts(r.Context(), uid, projectIDPtr)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, c)
|
|
}
|
|
|
|
// GET /api/projects/{id}/deadlines
|
|
func handleListDeadlinesForProject(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
|
|
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID, directOnly)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// POST /api/projects/{id}/deadlines
|
|
func handleCreateDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var input services.CreateDeadlineInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
f, err := dbSvc.deadline.Create(r.Context(), uid, projectID, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, f)
|
|
}
|
|
|
|
// POST /api/projects/{id}/deadlines/bulk — Fristenrechner "save to Project".
|
|
func handleBulkCreateDeadlines(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Deadlines []services.CreateDeadlineInput `json:"deadlines"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.deadline.CreateBulk(r.Context(), uid, projectID, body.Deadlines)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, rows)
|
|
}
|
|
|
|
// GET /api/deadlines/{id}
|
|
func handleGetDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
f, err := dbSvc.deadline.GetByID(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, f)
|
|
}
|
|
|
|
// PATCH /api/deadlines/{id}
|
|
func handleUpdateDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var input services.UpdateDeadlineInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
f, err := dbSvc.deadline.Update(r.Context(), uid, id, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, f)
|
|
}
|
|
|
|
// PATCH /api/deadlines/{id}/complete — convenience endpoint for the list-row checkbox.
|
|
func handleCompleteDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
f, err := dbSvc.deadline.Complete(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, f)
|
|
}
|
|
|
|
// PATCH /api/deadlines/{id}/reopen — admin/project-lead-only undo of complete.
|
|
func handleReopenDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
f, err := dbSvc.deadline.Reopen(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, f)
|
|
}
|
|
|
|
// DELETE /api/deadlines/{id}
|
|
func handleDeleteDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := dbSvc.deadline.Delete(r.Context(), uid, id); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|