Files
paliad/cmd/server/main.go
m b516201110 feat(t-paliad-144 A1): backend substrate + Custom Views API
Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only;
no user-visible change in A1. A2 (frontend) lands separately.

What's new:

- Migration 056: paliad.user_views table with RLS scoped to caller
  (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE
  (user_id, slug). No is_system flag — system defaults stay code-
  resident per Q8 lock-in.

- internal/services/filter_spec.go (+test): structured FilterSpec
  with Sources / Scope / Time / Predicates. Server-side validator
  rejects unknown sources, duplicate sources, conflicting scope
  modes, horizon=all without explicit projects (Q26 clamp), and
  every per-source enum (deadline.status, appointment_types,
  project_event kinds, approval_request status / viewer_role).

- internal/services/render_spec.go (+test): RenderSpec with three
  shapes (list / cards / calendar — Q4 lock-in 2026-05-07).
  Per-shape config kept separately so flipping shapes preserves
  tweaks. Validator over column / sort / density / group_by /
  default_view enums.

- internal/services/system_views.go (+test): code-resident
  SystemView definitions for dashboard / agenda / events / inbox /
  inbox-mine. Reserved-slug list (Q23) prevents user-views from
  colliding with top-level URLs. Case-folded matching.

- internal/services/view_service.go: extends EventService with
  RunSpec — runs a FilterSpec across all four substrate sources
  (deadline + appointment + project_event + approval_request)
  and merges into []ViewRow sorted by event_date. ViewRow is a
  discriminated projection (kind + common header + per-source
  Detail json.RawMessage). Q17 fail-open attribution: returns
  inaccessible_project_ids for explicit-scope queries where the
  caller can't see some IDs.

- internal/services/user_view_service.go (+test): CRUD on
  paliad.user_views — Create (server-assigns sort_order MAX+1
  in tx), GetBySlug, GetByID, Update (partial), Delete, Touch
  (last_used_at), MostRecent. Reserved-slug + slug-format
  validators on every write.

- internal/handlers/views.go: nine HTTP handlers wiring the
  endpoints (GET/POST/PATCH/DELETE /api/user-views/...,
  POST /api/user-views/{id}/touch, POST /api/views/run,
  POST /api/views/{slug}/run, GET /api/views/system).

- main.go + handlers.go + projects.go: wire UserViewService
  into the bundle; conditional route registration when both
  UserView + Event services are present.

Pure-Go tests (no DB): 32 cases pass — filter spec validators,
render spec validators, system view registry, reserved slugs.

Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases
covering create / list / get / uniqueness / update / delete /
touch / most-recent / reserved-slug / bad-slug / empty-name /
invalid-spec.

Coexists with t-139 (in-flight on noether's other branch) and
t-138 (shipped) without coordination commits — RunSpec uses the
existing visibility predicate that t-139's migration 055 will
extend with derivation. Approval-request source delegates to
ApprovalService.ListPendingForApprover / ListSubmittedByUser
(both already extended for derived_peer authority in t-139 Phase 3).

Files: 15 changed, 3134 insertions. Build clean. Tests green.
2026-05-07 12:51:37 +02:00

202 lines
8.1 KiB
Go

package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
// zoneinfo — without this import, every reminder timezone lookup fails
// silently and the hourly reminder slot fires in UTC instead of the
// user's chosen tz (t-paliad-064 root cause). Adds ~450KB to the binary.
_ "time/tzdata"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/db"
"mgit.msbls.de/m/paliad/internal/handlers"
"mgit.msbls.de/m/paliad/internal/services"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Surface the firm name in the boot log so a deployer can confirm
// FIRM_NAME took effect without curl-ing a rendered page.
log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name)
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
if supabaseURL == "" || supabaseAnonKey == "" {
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
}
jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
if jwtSecret == "" {
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
}
client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))
giteaToken := os.Getenv("GITEA_TOKEN")
if giteaToken == "" {
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
}
// MailService is wired regardless of DB availability — it no-ops when
// SMTP env vars are unset, so the server stays runnable for knowledge-
// platform-only deployments. Template-parse errors at boot are fatal.
mailSvc, err := services.NewMailService()
if err != nil {
log.Fatalf("mail service init: %v", err)
}
// Shared context for background goroutines (CalDAV sync + reminder job).
bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// DATABASE_URL is optional during the Phase A → Phase D transition. The
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
// without a DB. matter-management endpoints return 503 until DATABASE_URL is set.
dbURL := os.Getenv("DATABASE_URL")
var svcBundle *handlers.Services
var caldavSvc *services.CalDAVService
if dbURL != "" {
log.Println("applying database migrations…")
if err := db.ApplyMigrations(dbURL); err != nil {
log.Fatalf("migration failed: %v", err)
}
log.Println("database migrations applied")
pool, err := db.OpenPool(dbURL)
if err != nil {
log.Fatalf("open db pool: %v", err)
}
// Refresh paliad.deadline_search whenever migrations run, so
// search reflects any newly-seeded rule / concept / trigger.
// Migration 047 created the matview already-populated; this
// is only a no-op for the boot that introduced it. CONCURRENTLY
// keeps reads online and stays well under 100 ms at < 1k rows.
if err := services.RefreshSearchView(bgCtx, pool); err != nil {
log.Printf("refresh deadline_search: %v", err)
}
holidays := services.NewHolidayService(pool)
courts := services.NewCourtService(pool)
users := services.NewUserService(pool)
projectSvc := services.NewProjectService(pool, users)
teamSvc := services.NewTeamService(pool, projectSvc)
partnerUnitSvc := services.NewPartnerUnitService(pool, users)
rules := services.NewDeadlineRuleService(pool)
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
// the service exists but Enabled() reports false; handlers return 501.
// If the env var is malformed, fail fast — silently skipping would
// leave plaintext-credential bugs hidden.
cipher, err := services.LoadCalDAVCipher()
if err != nil {
log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
}
if cipher == nil {
log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
} else {
log.Println("CalDAV encryption configured (AES-256-GCM)")
}
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
// Wire the push hook so user-driven mutations sync to the external
// calendar without waiting for the next 60-second tick.
appointmentSvc.SetCalDAVPusher(caldavSvc)
baseURL := os.Getenv("PALIAD_BASE_URL")
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet
// at the point we build mailSvc above.
emailTemplateSvc := services.NewEmailTemplateService(pool)
mailSvc.SetTemplateService(emailTemplateSvc)
eventTypeSvc := services.NewEventTypeService(pool, users)
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
svcBundle = &handlers.Services{
Project: projectSvc,
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: services.NewPartyService(pool, projectSvc),
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
Rules: rules,
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
Courts: courts,
DeadlineSearch: services.NewDeadlineSearchService(pool),
EventCategory: nil, // wired below; cross-link order matters
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
Mail: mailSvc,
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
Audit: services.NewAuditService(pool),
EmailTemplate: emailTemplateSvc,
Link: services.NewLinkService(pool),
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
Approval: services.NewApprovalService(pool, users),
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
UserView: services.NewUserViewService(pool),
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
// Without this wiring, the policies and request tables exist but no
// mutation path consults them — paliad behaves as before.
deadlineSvc.SetApprovalService(svcBundle.Approval)
appointmentSvc.SetApprovalService(svcBundle.Approval)
// v3 (t-paliad-133): wire EventCategoryService and cross-link
// it into DeadlineSearchService so ?event_category_slug= can
// resolve to a concept-id allow-list during search.
eventCategorySvc := services.NewEventCategoryService(pool)
svcBundle.EventCategory = eventCategorySvc
svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
log.Println("Phase B services initialised")
// Spawn background goroutines: CalDAV sync (one per enabled user)
// and the hourly reminder scanner. Both live for the process
// lifetime; the signal-scoped context cleans them up on SIGTERM.
if err := caldavSvc.Start(bgCtx); err != nil {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")
caldavSvc.Stop()
}()
} else {
log.Println("DATABASE_URL not set — matter-management endpoints will return 503")
}
mux := http.NewServeMux()
handlers.Register(mux, client, giteaToken, svcBundle)
log.Printf("paliad server starting on :%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}