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)
This commit is contained in:
174
internal/services/holidays.go
Normal file
174
internal/services/holidays.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Package services holds the Paliad domain services backed by paliad.* tables.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Holiday is a non-working day. Mirrors paliad.holidays + the German federal
|
||||
// hardcoded set used as a fallback when the DB lookup misses.
|
||||
type Holiday struct {
|
||||
Date time.Time
|
||||
Name string
|
||||
IsVacation bool // part of court vacation period
|
||||
IsClosure bool // single-day closure (public holiday)
|
||||
}
|
||||
|
||||
// HolidayService loads and caches per-year holidays from paliad.holidays,
|
||||
// merging with German federal holidays as a safety net.
|
||||
//
|
||||
// Concurrency: cache is guarded by a sync.Map of *sync.Once per year, which
|
||||
// fixes audit §1.6 (the original KanzlAI version had an unprotected map).
|
||||
// Each year is loaded at most once across all concurrent callers.
|
||||
type HolidayService struct {
|
||||
db *sqlx.DB
|
||||
cache sync.Map // year (int) → *yearEntry
|
||||
}
|
||||
|
||||
type yearEntry struct {
|
||||
once sync.Once
|
||||
holidays []Holiday
|
||||
err error
|
||||
}
|
||||
|
||||
// NewHolidayService creates a new holiday service.
|
||||
func NewHolidayService(db *sqlx.DB) *HolidayService {
|
||||
return &HolidayService{db: db}
|
||||
}
|
||||
|
||||
type dbHoliday struct {
|
||||
ID int `db:"id"`
|
||||
Date time.Time `db:"date"`
|
||||
Name string `db:"name"`
|
||||
Country string `db:"country"`
|
||||
State *string `db:"state"`
|
||||
HolidayType string `db:"holiday_type"`
|
||||
}
|
||||
|
||||
// LoadHolidaysForYear loads holidays for a year (cached, race-safe).
|
||||
func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) {
|
||||
v, _ := s.cache.LoadOrStore(year, &yearEntry{})
|
||||
entry := v.(*yearEntry)
|
||||
entry.once.Do(func() {
|
||||
entry.holidays, entry.err = s.loadYear(year)
|
||||
})
|
||||
return entry.holidays, entry.err
|
||||
}
|
||||
|
||||
func (s *HolidayService) loadYear(year int) ([]Holiday, error) {
|
||||
holidays := make([]Holiday, 0, 30)
|
||||
|
||||
if s.db != nil {
|
||||
var rows []dbHoliday
|
||||
err := s.db.SelectContext(context.Background(), &rows,
|
||||
`SELECT id, date, name, country, state, holiday_type
|
||||
FROM paliad.holidays
|
||||
WHERE EXTRACT(YEAR FROM date) = $1
|
||||
ORDER BY date`, year)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load holidays for %d: %w", year, err)
|
||||
}
|
||||
for _, h := range rows {
|
||||
holidays = append(holidays, Holiday{
|
||||
Date: h.Date,
|
||||
Name: h.Name,
|
||||
IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure",
|
||||
IsVacation: h.HolidayType == "vacation",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with German federal holidays so a misconfigured DB never silently
|
||||
// returns a working day for, say, Christmas.
|
||||
seen := make(map[string]bool, len(holidays))
|
||||
for _, h := range holidays {
|
||||
seen[h.Date.Format("2006-01-02")] = true
|
||||
}
|
||||
for _, h := range germanFederalHolidays(year) {
|
||||
key := h.Date.Format("2006-01-02")
|
||||
if !seen[key] {
|
||||
holidays = append(holidays, h)
|
||||
}
|
||||
}
|
||||
return holidays, nil
|
||||
}
|
||||
|
||||
// IsHoliday returns the matching Holiday entry if the date is a holiday, else nil.
|
||||
func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
|
||||
holidays, err := s.LoadHolidaysForYear(date.Year())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
key := date.Format("2006-01-02")
|
||||
for i := range holidays {
|
||||
if holidays[i].Date.Format("2006-01-02") == key {
|
||||
return &holidays[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsNonWorkingDay returns true on weekends or holidays.
|
||||
func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
|
||||
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
return true
|
||||
}
|
||||
return s.IsHoliday(date) != nil
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays moves the date forward to the next working day.
|
||||
// Returns adjusted date, the original (unmodified) date, and whether any
|
||||
// adjustment was made. Capped at 30 forward iterations as a safety bound.
|
||||
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, 1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// germanFederalHolidays returns the 11 holidays observed in all 16 German Länder.
|
||||
func germanFederalHolidays(year int) []Holiday {
|
||||
em, ed := CalculateEasterSunday(year)
|
||||
easter := time.Date(year, time.Month(em), ed, 0, 0, 0, 0, time.UTC)
|
||||
return []Holiday{
|
||||
{Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", IsClosure: true},
|
||||
{Date: easter, Name: "Ostersonntag", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", IsClosure: true},
|
||||
{Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", IsClosure: true},
|
||||
{Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", IsClosure: true},
|
||||
{Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", IsClosure: true},
|
||||
{Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", IsClosure: true},
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateEasterSunday computes Easter Sunday for the given year using the
|
||||
// Anonymous Gregorian algorithm. Returns (month 1-12, day 1-31).
|
||||
func CalculateEasterSunday(year int) (int, int) {
|
||||
a := year % 19
|
||||
b := year / 100
|
||||
c := year % 100
|
||||
d := b / 4
|
||||
e := b % 4
|
||||
f := (b + 8) / 25
|
||||
g := (b - f + 1) / 3
|
||||
h := (19*a + b - d - g + 15) % 30
|
||||
i := c / 4
|
||||
k := c % 4
|
||||
l := (32 + 2*e + 2*i - h - k) % 7
|
||||
m := (a + 11*h + 22*l) / 451
|
||||
month := (h + l - 7*m + 114) / 31
|
||||
day := ((h + l - 7*m + 114) % 31) + 1
|
||||
return month, day
|
||||
}
|
||||
Reference in New Issue
Block a user