Files
paliad/internal/handlers/deadline_rules_db.go
m bcc4939af2 feat(services): Phase B — sqlx pool, services, Akten/Frist endpoints
Implements docs/design-kanzlai-integration.md §8 Phase B.

Pool & infrastructure:
- internal/db/pool.go — sqlx connection pool via DATABASE_URL
  (lazy, sync.Once, returns nil if unset)
- cmd/server/main.go wires pool + services on startup; skips gracefully
  if DATABASE_URL unset (existing endpoints still work)

Services (internal/services/):
- holidays.go — ported from KanzlAI. Audit §1.6 fix: replaces unguarded
  map with sync.Map of *yearEntry (sync.Once per year), race-safe under
  concurrent readers.
- deadline_calculator.go — ported. days/weeks/months + before/after
  timing + holiday/weekend adjustment via HolidayService.
- deadline_rule_service.go — ported, DB-backed. List, GetRuleTree,
  GetFullTimeline (recursive CTE for cross-type spawns), GetByIDs,
  ListProceedingTypes.
- user_service.go — reads paliad.users; GetByID returns (nil, nil) for
  users who haven't onboarded yet (safe default = no visibility).
- akte_service.go — new. Office-scoped visibility enforced at the app
  layer (defense-in-depth alongside RLS). ListVisibleForUser uses the
  visibility predicate directly in SQL so indexes can drive the query.
  Create/Update/Delete enforce role gates:
    * associates can only create in their own office
    * only admins can move an Akte between offices
    * only partners/admins can toggle firm_wide_visible
    * only partners/admins can delete (soft, status='archived')
  Writes an akten_events row on create, status change, firm-wide toggle,
  collaborator change.
- parteien_service.go — ported. Visibility inherited from the parent
  Akte via AkteService.GetByID gate.

Sentinel errors:
- services.ErrNotVisible → handlers return 404 (never leak existence)
- services.ErrForbidden → 403
- services.ErrInvalidInput → 400

Auth context:
- internal/auth/user.go — WithUserID middleware extracts the `sub` claim
  from the Supabase JWT session cookie and injects uuid.UUID into the
  request context. Runs after Client.Middleware (which already validated
  the cookie expiry). Handlers use auth.UserIDFromContext().

Handlers (internal/handlers/):
- akten.go — full CRUD for /api/akten + /api/akten/{id}/parteien.
  All require DB configured (503 otherwise) and authenticated user
  (401 otherwise). Returns 404 for non-visible IDs.
- deadline_rules_db.go — GET /api/deadline-rules, GET
  /api/proceeding-types-db, POST /api/deadlines/calculate.
  The /api/deadlines/calculate endpoint lives alongside the existing
  in-memory /api/tools/fristenrechner; Phase C swaps the UI over and
  deletes the in-memory rule tree.
- handlers.Register now takes an optional *Services bundle; when
  DATABASE_URL unset the DB-backed endpoints return 503 with a clear
  error message.

Tests (internal/services/):
- holidays_test.go — Easter algorithm (5 years spot-checked), German
  federal holidays, weekend + Neujahr adjustment, concurrent cache
  reads under -race.
- deadline_calculator_test.go — days/weeks/months calc, before timing,
  Karfreitag→Ostermontag skip (lands on Tue 2026-04-07), batch with
  zero-duration rule.
- akte_service_test.go — live DB test behind `TEST_DATABASE_URL` (skip
  otherwise). Verifies 4-Akte × 3-user visibility model AND role
  enforcement (associate can't delete, can't cross-office-create,
  invalid office rejected).

Manual verification:
- `go build ./...` + `go vet ./...` clean
- `go test ./internal/services/ -race` passes (DB tests skip without URL)
- With TEST_DATABASE_URL set, all visibility + role tests pass
- Live HTTP smoke test with forged JWT cookie:
  * /api/deadline-rules returns 40 rules
  * /api/proceeding-types-db returns 7 types
  * /api/deadlines/calculate INF + 2026-04-15 returns calculated deadlines
  * /api/akten returns [] (user has no paliad.users row yet — safe default)
  * /login, / still work (no regressions)
2026-04-16 14:25:55 +02:00

97 lines
3.0 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
)
// GET /api/deadline-rules?proceeding_type_id=N
//
// Lists deadline rules from the DB, optionally filtered by proceeding type.
// Returns 503 if the DB is not configured.
func handleListDeadlineRules(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
var ptIDPtr *int
if raw := r.URL.Query().Get("proceeding_type_id"); raw != "" {
ptID, err := strconv.Atoi(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid proceeding_type_id"})
return
}
ptIDPtr = &ptID
}
rules, err := dbSvc.rules.List(r.Context(), ptIDPtr)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list rules"})
return
}
writeJSON(w, http.StatusOK, rules)
}
// GET /api/proceeding-types-db
//
// Lists active proceeding types from the DB.
// (Distinct route name from the existing in-memory /api/tools/proceeding-types
// endpoint to avoid path conflicts during the Phase B → Phase C transition.)
func handleListProceedingTypesDB(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
types, err := dbSvc.rules.ListProceedingTypes(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list proceeding types"})
return
}
writeJSON(w, http.StatusOK, types)
}
// POST /api/deadlines/calculate
//
// Body: { "proceeding_type": "INF", "trigger_date": "2026-04-15" }
// Calculates all deadlines for the proceeding type's rule tree, applying
// holiday/weekend adjustment via the DB-backed HolidayService.
//
// Lives at /api/deadlines/calculate (vs the existing /api/tools/fristenrechner
// which uses the in-memory rule tree). Phase C swaps the Fristenrechner UI
// to this endpoint, then deletes the in-memory rule tree.
func handleCalculateDeadlines(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
var input struct {
ProceedingType string `json:"proceeding_type"`
TriggerDate string `json:"trigger_date"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if input.ProceedingType == "" || input.TriggerDate == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "proceeding_type and trigger_date required"})
return
}
triggerDate, err := time.Parse("2006-01-02", input.TriggerDate)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "trigger_date must be YYYY-MM-DD"})
return
}
rules, pt, err := dbSvc.rules.GetFullTimeline(r.Context(), input.ProceedingType)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown proceeding type"})
return
}
results := dbSvc.calc.CalculateFromRules(triggerDate, rules)
writeJSON(w, http.StatusOK, map[string]any{
"proceeding_type": pt.Code,
"proceeding_name": pt.Name,
"trigger_date": input.TriggerDate,
"deadlines": results,
})
}