Files
paliad/internal/handlers/deadlines.go
m 04ce6a8bfa feat(t-paliad-088): Event Types for deadlines — schema + service + handlers (PR-1)
Migration 030 adds paliad.event_types and paliad.deadline_event_types
junction. ~43 firm-wide seeds biased toward submissions (25 UPC
submissions + 8 UPC decisions/orders/hearings + 5 EPO + 4 DPMA/DE + 1
cross-jurisdiction). UPC-seeded rows carry a loose trigger_event_id
column (no FK constraint per Q2: event_types leads, trigger_events
follows). RLS policies are defense-in-depth — primary enforcement is
in the Go service layer. Per Q6, any authenticated user can create
firm-wide types; admins moderate via the soft-delete archive lever.

EventTypeService: List (firm-wide ∪ own-private), GetByID, Create
(slug auto-derived, supports diacritics → ASCII), Update (author OR
admin-on-firm-wide), SuggestSimilar (powers the duplicate-warning in
the add modal), AttachToDeadlineTx + ValidateForUser + ListForDeadlines
for the junction.

DeadlineService gains an EventTypeService dependency and now:
- accepts event_type_ids on Create / Update / CreateBulk
- attaches them in the same transaction as the deadline insert
- hydrates EventTypeIDs on every Get / List / ListForProject
- supports the multi-select Typ filter via ListFilter.EventTypeIDs +
  IncludeUntyped (UNION semantics within types, AND-intersected with
  Status/Project)

AgendaService gets the same Typ filter on its deadline side;
appointments are unaffected.

API:
- GET /api/event-types?category=&jurisdiction=
- GET /api/event-types/suggest?q=
- POST /api/event-types
- PATCH /api/event-types/{id}        (set archive=true to hide)
- GET /api/deadlines?event_type=<uuid>,<uuid>,none
- GET /api/agenda?event_type=<uuid>,<uuid>,none
- POST/PATCH /api/deadlines accept event_type_ids: [uuid]

go build / go vet / go test ./... clean.

Frontend (picker + custom-add modal + multi-select filter) follows in
PR-2. Admin moderation panel deferred to t-paliad-089 follow-up.
2026-04-30 12:49:04 +02:00

308 lines
7.6 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/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
}
rows, err := dbSvc.deadline.ListForProject(r.Context(), uid, projectID)
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)
}