Files
paliad/internal/handlers/appointments.go
m d41fc49809 feat(t-paliad-139): Phase 1 — /projects/{id} aggregation bug fix
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.
2026-05-06 16:24:31 +02:00

355 lines
8.9 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// requireCalDAV reports the CalDAV-capable service or writes 501. Used by
// every CalDAV endpoint so misconfigured deployments return a clean error
// instead of crashing.
func requireCalDAV(w http.ResponseWriter) bool {
if !requireDB(w) {
return false
}
if dbSvc.caldav == nil || !dbSvc.caldav.Enabled() {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "CalDAV-Synchronisation derzeit nicht verf\u00fcgbar \u2014 Administrator kontaktieren (CALDAV_ENCRYPTION_KEY nicht gesetzt).",
})
return false
}
return true
}
// GET /api/appointments?project_id=&from=&to=&type=
func handleListAppointments(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
q := r.URL.Query()
filter := services.AppointmentListFilter{}
raw := q.Get("project_id")
if raw == "" {
raw = q.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
}
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
}
if raw := q.Get("type"); raw != "" {
filter.Type = &raw
}
rows, err := dbSvc.appointment.ListVisibleForUser(r.Context(), uid, filter)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/appointments/summary
func handleAppointmentsSummary(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
c, err := dbSvc.appointment.SummaryCounts(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, c)
}
// GET /api/projects/{id}/appointments
func handleListAppointmentsForProject(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.appointment.ListForProject(r.Context(), uid, projectID, directOnly)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/appointments
func handleCreateAppointment(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateAppointmentInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.appointment.Create(r.Context(), uid, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
// GET /api/appointments/{id}
func handleGetAppointment(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
}
t, err := dbSvc.appointment.GetByID(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// PATCH /api/appointments/{id}
func handleUpdateAppointment(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.UpdateAppointmentInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.appointment.Update(r.Context(), uid, id, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// DELETE /api/appointments/{id}
func handleDeleteAppointment(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.appointment.Delete(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/caldav-config
func handleGetCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
// 501 only when CalDAV is fully unavailable. GET still works without
// the cipher so the UI can show "no config" without failing — but if
// the cipher is missing the user can't actually do anything, so return
// 501 for consistency with PUT/DELETE.
if dbSvc.caldav == nil || !dbSvc.caldav.Enabled() {
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": "CalDAV-Synchronisation derzeit nicht verf\u00fcgbar \u2014 Administrator kontaktieren (CALDAV_ENCRYPTION_KEY nicht gesetzt).",
"enabled": false,
})
return
}
cfg, err := dbSvc.caldav.GetConfig(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if cfg == nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"url": cfg.URL,
"username": cfg.Username,
"calendar_path": cfg.CalendarPath,
"enabled": cfg.Enabled,
"last_sync_at": cfg.LastSyncAt,
"last_sync_error": cfg.LastSyncError,
"updated_at": cfg.UpdatedAt,
})
}
// PUT /api/caldav-config
func handlePutCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.SaveConfigInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
cfg, err := dbSvc.caldav.SaveConfig(r.Context(), uid, input)
if err != nil {
writeCalDAVError(w, err)
return
}
writeJSON(w, http.StatusOK, cfg)
}
// DELETE /api/caldav-config
func handleDeleteCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if err := dbSvc.caldav.DeleteConfig(r.Context(), uid); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/caldav-config/test — performs PROPFIND with the supplied
// credentials, no persistence.
func handleTestCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.SaveConfigInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
ctx := services.WithCalDAVUser(r.Context(), uid)
if err := dbSvc.caldav.TestConnection(ctx, input); err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// GET /api/caldav-config/log — last 5 sync attempts.
func handleCalDAVSyncLog(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.caldav.SyncLog(r.Context(), uid, 5)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// writeCalDAVError maps services.ErrCalDAVNoKey + invalid input to the
// right HTTP status; defers everything else to writeServiceError.
func writeCalDAVError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrCalDAVNoKey):
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "CalDAV-Synchronisation derzeit nicht verf\u00fcgbar \u2014 Administrator kontaktieren.",
})
default:
writeServiceError(w, err)
}
}
// parseDateOrTime accepts "2026-04-16" (interpreted as midnight UTC) or
// any RFC3339 timestamp.
func parseDateOrTime(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UTC(), nil
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t.UTC(), nil
}
return time.Time{}, errors.New("expected RFC3339 or YYYY-MM-DD")
}