Compare commits

...

74 Commits

Author SHA1 Message Date
m
fdb4ac55a1 feat: frontend AI tab — KI-Strategie, KI-Entwurf, Aehnliche Faelle
New "KI" tab on case detail page with three sub-panels:
- KI-Strategie: one-click strategic analysis with next steps, risks, timeline
- KI-Entwurf: document drafting with template selection, language, instructions
- Aehnliche Faelle: UPC similar case search with relevance scores

Components: CaseStrategy, DocumentDrafter, SimilarCaseFinder
Types: StrategyRecommendation, DocumentDraft, SimilarCase, etc.
2026-03-30 11:26:01 +02:00
m
dd683281e0 feat: AI-powered features — document drafting, case strategy, similar case finder (P2)
Backend:
- DraftDocument: Claude generates legal documents from case data + template type
  (14 template types: Klageschrift, UPC claims, Abmahnung, etc.)
- CaseStrategy: Opus-powered strategic analysis with next steps, risk assessment,
  and timeline optimization (structured tool output)
- FindSimilarCases: queries youpc.org Supabase for UPC cases, Claude ranks by
  relevance with explanations and key holdings

Endpoints: POST /api/ai/draft-document, /case-strategy, /similar-cases
All rate-limited (5 req/min) and permission-gated (PermAIExtraction).
YouPC database connection is optional (YOUPC_DATABASE_URL env var).
2026-03-30 11:25:52 +02:00
m
bfd5e354ad fix: resolve merge conflicts from P0 role-based + audit trail branches
Combine role-based permissions (VerifyAccess/GetUserRole) with audit trail
(IP/user-agent context capture) in auth middleware and tenant resolver.
2026-03-30 11:25:41 +02:00
m
8e65463130 feat: role-based permissions — owner/partner/associate/paralegal/secretary (P0) 2026-03-30 11:09:05 +02:00
m
a307b29db8 feat: email notifications + deadline reminder system (P0) 2026-03-30 11:08:53 +02:00
m
5e88384fab feat: append-only audit trail for all mutations (P0) 2026-03-30 11:08:41 +02:00
m
0a0ec016d8 feat: role-based permissions (owner/partner/associate/paralegal/secretary)
Backend:
- auth/permissions.go: full permission matrix with RequirePermission/RequireRole
  middleware, CanEditCase, CanDeleteDocument helpers
- auth/context.go: add user role to request context
- auth/middleware.go: resolve role alongside tenant in auth flow
- auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID
- handlers/case_assignments.go: CRUD for case-level user assignments
- handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints
- handlers/documents.go: permission-based delete (own vs all)
- router/router.go: permission-wrapped routes for all endpoints
- services/case_assignment_service.go: assign/unassign with tenant validation
- services/tenant_service.go: UpdateMemberRole with owner protection
- models/case_assignment.go: CaseAssignment model

Database:
- user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary)
- case_assignments table: case_id, user_id, role (lead/team/viewer)
- Migrated existing admin->partner, member->associate

Frontend:
- usePermissions hook: fetches /api/me, provides can() helper
- TeamSettings: 5-role dropdown, role change, permission-gated invite
- CaseAssignments: new component for case-level team management
- Sidebar: conditionally hides AI/Settings based on permissions
- Cases page: hides "Neue Akte" button for non-authorized roles
- Case detail: new "Mitarbeiter" tab for assignment management
2026-03-30 11:04:57 +02:00
m
ac20c03f01 feat: email notifications + deadline reminder system
Database:
- notification_preferences table (user_id, tenant_id, reminder days, email/digest toggles)
- notifications table (type, entity link, read/sent tracking, dedup index)

Backend:
- NotificationService with background goroutine checking reminders hourly
- CheckDeadlineReminders: finds deadlines due in N days per user prefs, creates notifications
- Overdue deadline detection and notification
- Daily digest at 8am: compiles pending notifications into one email
- SendEmail via `m mail send` CLI command
- Deduplication: same notification type + entity + day = skip
- API: GET/PATCH notifications, unread count, mark read/all-read
- API: GET/PUT notification-preferences with upsert

Frontend:
- NotificationBell in header with unread count badge (polls every 30s)
- Dropdown panel with notification list, type-colored dots, time-ago, entity links
- Mark individual/all as read
- NotificationSettings in Einstellungen page: reminder day toggles, email toggle, digest toggle
2026-03-30 11:03:17 +02:00
m
c324a2b5c7 fix: critical security hardening — tenant isolation, CORS, error masking, input validation 2026-03-30 11:02:52 +02:00
m
b36247dfb9 feat: append-only audit trail for all mutations (P0)
- Database: kanzlai.audit_log table with RLS, append-only policies
  (no UPDATE/DELETE), indexes for entity, user, and time queries
- Backend: AuditService.Log() with context-based tenant/user/IP/UA
  extraction, wired into all 7 services (case, deadline, appointment,
  document, note, party, tenant)
- API: GET /api/audit-log with entity_type, entity_id, user_id,
  from/to date, and pagination filters
- Frontend: Protokoll tab on case detail page with chronological
  audit entries, diff preview, and pagination

Required by § 50 BRAO and DSGVO Art. 5(2).
2026-03-30 11:02:28 +02:00
m
c15d5b72f2 fix: critical security hardening — tenant isolation, CORS, error leaking, input validation
1. Tenant isolation bypass (CRITICAL): TenantResolver now verifies user
   has access to X-Tenant-ID via user_tenants lookup before setting context.
   Added VerifyAccess method to TenantLookup interface and TenantService.

2. Consolidated tenant resolution: Removed duplicate resolveTenant() from
   helpers.go and tenant resolution from auth middleware. TenantResolver is
   now the single source of truth. Deadlines and AI handlers use
   auth.TenantFromContext() instead of direct DB queries.

3. CalDAV credential masking: tenant settings responses now mask CalDAV
   passwords with "********" via maskSettingsPassword helper. Applied to
   GetTenant, ListTenants, and UpdateSettings responses.

4. CORS + security headers: New middleware/security.go with CORS
   (restricted to FRONTEND_ORIGIN) and security headers (X-Frame-Options,
   X-Content-Type-Options, HSTS, Referrer-Policy, X-XSS-Protection).

5. Internal error leaking: All writeError(w, 500, err.Error()) replaced
   with internalError() that logs via slog and returns generic "internal
   error" to client. Same for jsonError in tenant handler.

6. Input validation: Max length on title (500), description (10000),
   case_number (100), search (200). Pagination clamped to max 100.
   Content-Disposition filename sanitized against header injection.

Regression test added for tenant access denial (403 on unauthorized
X-Tenant-ID). All existing tests pass, go vet clean.
2026-03-30 11:01:14 +02:00
m
82878dffd5 docs: full system roadmap — from MVP to complete Kanzleimanagement 2026-03-28 02:35:20 +01:00
m
ac04930667 feat: comprehensive KanzlAI-mGMT system roadmap
Full system vision document covering 23 features across 4 priority tiers:
- P0 (must-have): audit trail, conflict checks, roles/permissions,
  notifications, time tracking, RVG calculator, invoicing, DATEV export
- P1 (should-have): document templates, beA integration, full-text search,
  Wiedervorlagen, email integration, reporting
- P2 (differentiator): patent family tracking, claim charts, UPC case law
  intelligence via mLex, AI document drafting, AI strategy analysis
- P3 (nice-to-have): client portal, PWA, multi-language, EDA

Includes data model designs (24 new tables), API specifications,
implementation phases, competitive analysis, and risk register.
2026-03-28 02:30:39 +01:00
m
909f14062c docs: comprehensive MVP audit — security, architecture, UX, competitive analysis 2026-03-28 02:26:39 +01:00
m
4b86dfa4ad feat: update AUDIT.md with sub-agent findings
Added 7 additional issues from deep-dive agents:
- Race condition in HolidayService cache (critical)
- Rate limiter X-Forwarded-For bypass (critical)
- German umlaut typos throughout frontend
- Silent error swallowing in createEvent
- Missing React error boundaries
- No RLS policies at database level
- Updated priority roadmap with new items
2026-03-28 02:23:50 +01:00
m
60f1f4ef4a feat: comprehensive MVP audit — security, architecture, UX, competitive analysis
Structured assessment covering code quality, security (critical tenant isolation
bypass found), architecture, UX gaps, testing coverage, deployment, and
competitive positioning vs RA-MICRO/ADVOWARE/AnNoText/Actaport.

Includes prioritized roadmap (P0-P3) with actionable items.
2026-03-28 02:22:07 +01:00
m
7c7ae396f4 feat: Phase D — case detail refactor to URL-based nested routes 2026-03-25 19:32:41 +01:00
m
433a0408f2 feat: Phase C — detail pages for deadlines, appointments, events, creation forms 2026-03-25 19:32:17 +01:00
m
cabea83784 feat: Phase B — interactive dashboard, breadcrumbs, clickable navigation 2026-03-25 19:31:59 +01:00
m
8863878b39 feat: Phase A backend — notes CRUD, detail endpoints, dashboard fix 2026-03-25 19:31:54 +01:00
m
84b178edbf feat: Phase B — interactive dashboard, breadcrumbs, clickable navigation
- Breadcrumb component: reusable nav with items array (label+href)
- DeadlineTrafficLights: buttons → Links to /fristen?status={filter}
- CaseOverviewGrid: static metrics → clickable Links to /cases?status={filter}
- UpcomingTimeline: items → clickable Links to /fristen/{id} or /termine/{id}
  with case number links and hover chevron
- QuickActions: swap CalDAV Sync for "Neuer Termin" → /termine/neu,
  fix "Frist eintragen" → /fristen/neu
- AISummaryCard: add RefreshCw button with spinning animation
- RecentActivityList: new component showing recent case events
- DeadlineList: accept initialStatus prop, add this_week/ok filters
- fristen/page.tsx: read searchParams.status for initial filter
- Add breadcrumbs to dashboard, fristen, cases, termine pages
- Add RecentActivity type, update DashboardData type
2026-03-25 19:29:13 +01:00
m
7094212dcf feat: Phase C frontend detail pages for deadlines, appointments, events
- Deadline detail page (/fristen/[id]) with status badge, due date,
  case context, complete button, and notes
- Appointment detail page (/termine/[id]) with datetime, location,
  type badge, case link, description, and notes
- Case event detail page (/cases/[id]/ereignisse/[eventId]) with
  event type icon, description, metadata, and notes
- Standalone deadline creation (/fristen/neu) with case dropdown
- Standalone appointment creation (/termine/neu) with optional case
- Reusable Breadcrumb component for navigation hierarchy
- Reusable NotesList component with inline create/edit/delete
- Added Note and RecentActivity types to lib/types.ts
2026-03-25 19:29:12 +01:00
m
9787450d91 feat: refactor case detail from useState tabs to URL-based nested routes
Refactors the monolithic cases/[id]/page.tsx into Next.js nested routes
with a shared layout for the case header and tab navigation bar.

Route structure:
- cases/[id]/layout.tsx — case header + tab bar (active tab from URL)
- cases/[id]/page.tsx — redirects to ./verlauf
- cases/[id]/verlauf/page.tsx — timeline tab
- cases/[id]/fristen/page.tsx — deadlines tab
- cases/[id]/dokumente/page.tsx — documents tab (with upload)
- cases/[id]/parteien/page.tsx — parties tab
- cases/[id]/notizen/page.tsx — notes tab (new, uses NotesList)

New shared components:
- Breadcrumb.tsx — reusable breadcrumb navigation
- NotesList.tsx — reusable notes CRUD (inline create/edit/delete)
- Note type added to types.ts

Benefits: deep linking, browser back/forward, bookmarkable tabs.
2026-03-25 19:28:29 +01:00
m
1e88dffd82 feat: Phase A backend — notes CRUD, detail endpoints, dashboard fix
- Create kanzlai.notes table (polymorphic FK with CHECK constraint,
  partial indexes, RLS)
- Add Note model, NoteService (ListByParent, Create, Update, Delete),
  and NoteHandler with endpoints: GET/POST /api/notes, PUT/DELETE /api/notes/{id}
- Add GET /api/deadlines/{deadlineID} detail endpoint
- Add GET /api/appointments/{id} detail endpoint
- Add GET /api/case-events/{id} detail endpoint (new CaseEventHandler)
- Fix dashboard query: add case_id to upcoming_deadlines SELECT,
  add id and case_id to recent_activity SELECT
- Register all new routes in router.go
2026-03-25 19:26:21 +01:00
m
9ad58e1ba3 docs: design document for dashboard redesign + detail pages 2026-03-25 18:51:44 +01:00
m
0712d9a367 docs: design document for dashboard redesign + detail pages (t-kz-060)
Comprehensive design covering:
- Dashboard interactivity (click-to-filter traffic lights, clickable timeline,
  fixed quick actions, AI summary refresh)
- New detail pages (deadline, appointment, case event)
- Notes system with polymorphic table design
- Case detail URL-based tab navigation
- Breadcrumb navigation system
- Backend API additions and data model changes
- Phased implementation plan for coders
2026-03-25 18:49:48 +01:00
m
cd31e76d07 fix: TenantSwitcher shows dropdown for single tenant, wider name display 2026-03-25 18:40:15 +01:00
m
f42b7ddec7 fix: add array guards to all frontend components consuming API responses 2026-03-25 18:35:28 +01:00
m
50bfa3deb4 fix: add array guards to all frontend components consuming API responses
Prevents "M.forEach is not a function" crashes when API returns error
objects or unexpected shapes instead of arrays. Guards all useQuery
consumers with Array.isArray checks and safe defaults for object props.

Files fixed: DeadlineList, AppointmentList, TenantSwitcher,
DeadlineTrafficLights, UpcomingTimeline, CaseOverviewGrid,
AISummaryCard, TeamSettings, and all page-level components
(dashboard, cases, fristen, termine, ai/extract).
2026-03-25 18:34:11 +01:00
m
e635efa71e fix: remove remaining /api/ double-prefix from template literal API calls
Previous fix missed backtick template strings. Fixed 7 more api.*()
calls in appointments, deadlines, settings, and einstellungen pages.
2026-03-25 18:20:35 +01:00
m
12e0407025 test: comprehensive E2E and API test suite for full KanzlAI stack 2026-03-25 16:21:32 +01:00
m
325fbeb5de test: comprehensive E2E and API test suite for full KanzlAI stack
Backend (Go):
- Expanded integration_test.go: health, auth middleware (expired/invalid/wrong-secret JWT),
  tenant CRUD, case CRUD (create/list/get/update/delete + filters + validation),
  deadline CRUD (create/list/update/complete/delete), appointment CRUD,
  dashboard (verifies all sections), deadline calculator (valid/invalid/unknown type),
  proceeding types & rules, document endpoints, AI extraction (no-key path),
  and full critical path E2E (auth -> case -> deadline -> appointment -> dashboard -> complete)
- New handler unit tests: case (10), appointment (11), dashboard (1), calculate (5),
  document (10), AI (4) — all testing validation, auth guards, and error paths without DB
- Total: ~80 backend tests (unit + integration)

Frontend (TypeScript/Vitest):
- Installed vitest 2.x, @testing-library/react, @testing-library/jest-dom, jsdom 24, msw
- vitest.config.ts with jsdom env, esbuild JSX automatic, path aliases
- API client tests (13): URL construction, no double /api/, auth header, tenant header,
  POST/PUT/PATCH/DELETE methods, error handling, 204 responses
- DeadlineTrafficLights tests (5): renders cards, correct counts, zero state, onFilter callback
- CaseOverviewGrid tests (4): renders categories, counts, header, zero state
- LoginPage tests (8): form rendering, mode toggle, password login, redirect, error display,
  magic link, registration link
- Total: 30 frontend tests

Makefile: test-frontend target now runs vitest instead of placeholder echo.
2026-03-25 16:19:00 +01:00
m
19bea8d058 fix: remove /api/ double-prefix from all frontend API calls
Frontend api.ts baseUrl is already "/api", so paths like
"/api/cases" produced "/api/api/cases". Stripped the redundant
prefix from all component calls. Rewrite destination correctly
adds /api/ back for the Go backend.
2026-03-25 16:05:50 +01:00
m
661135d137 fix: exclude /api/ routes from Next.js auth middleware
The middleware was intercepting API proxy requests and redirecting
to /login. API routes should pass through to the Go backend which
handles its own JWT auth.
2026-03-25 15:58:42 +01:00
m
f8d97546e9 fix: preserve /api/ prefix in Next.js rewrite to backend
The rewrite was stripping /api/ from the path, but the Go backend
expects routes at /api/tenants, /api/dashboard etc.
2026-03-25 15:55:58 +01:00
m
45605c803b fix: pass NEXT_PUBLIC_* env vars as build args for Supabase client
Next.js inlines NEXT_PUBLIC_* vars at build time. They must be
available as ARGs during the Docker build, not just as runtime
environment variables.
2026-03-25 15:53:32 +01:00
m
e57b7c48ed feat: production hardening — slog, rate limiting, tests, seed data (Phase 4) 2026-03-25 14:35:49 +01:00
m
c5c3f41e08 feat: production hardening — slog, rate limiting, integration tests, seed data (Phase 4)
- Structured logging: replace log.* with log/slog JSON output across backend
- Request logger middleware: logs method, path, status, duration for all non-health requests
- Rate limiting: token bucket (5 req/min, burst 10) on AI endpoints (/api/ai/*)
- Integration tests: full critical path test (auth -> create case -> add deadline -> dashboard)
- Seed demo data: 1 tenant, 5 cases with deadlines/appointments/parties/events
- docker-compose.yml: add all required env vars (DATABASE_URL, SUPABASE_*, ANTHROPIC_API_KEY)
- .env.example: document all env vars including DATABASE_URL and CalDAV note
2026-03-25 14:32:27 +01:00
m
d0197a091c feat: add CalDAV settings UI and team management (Phase 3P) 2026-03-25 14:28:08 +01:00
m
fe97fed56d feat: add CalDAV settings UI and team management pages (Phase 3P)
Backend: PUT /api/tenants/{id}/settings endpoint for updating tenant
settings (JSONB merge). Frontend: /einstellungen page with CalDAV
config (URL, credentials, calendar path, sync toggle, interval),
manual sync button, live sync status display. /einstellungen/team
page with member list, invite-by-email, role management.
2026-03-25 14:26:05 +01:00
m
b49992b9c0 feat: UI polish — responsive, loading/empty/error states, German (Phase 3Q) 2026-03-25 14:20:08 +01:00
m
f81a2492c6 feat: UI polish — responsive, loading/empty/error states, German fixes (Phase 3Q)
- Responsive sidebar: collapses on mobile with hamburger menu, slide-in animation
- Skeleton loaders: dashboard cards, case table, case detail page
- Empty states: friendly messages with icons for cases, deadlines, parties, documents
- Error states: retry button on dashboard, proper error message on case not found
- Form validation: inline error messages on case creation form
- German language: fix all missing umlauts (Zurück, wählen, Anhängig, Verfügung, etc.)
- Status labels: display German translations instead of raw status values
- Transitions: fade-in animations on page load, hover/transition-colors on all interactive elements
- Focus states: focus-visible ring for keyboard accessibility
- Mobile layout: stacking for filters, forms, tabs; horizontal scroll for tables
- Extraction results: card layout on mobile, table on desktop
- Missing types: add DashboardData, DeadlineSummary, CaseSummary, ExtractedDeadline etc.
- Fix QuickActions links to use correct routes (/cases/new, /ai/extract)
- Consistent input focus styles across all forms
2026-03-25 14:16:30 +01:00
m
8bb8d7fed8 feat: add CalDAV bidirectional sync service (Phase 3O) 2026-03-25 14:04:38 +01:00
m
b4f3b26cbe feat: add document management frontend (Phase 2N) 2026-03-25 14:04:28 +01:00
m
6e9345fcfe feat: add appointment calendar frontend (Phase 1H) 2026-03-25 14:04:12 +01:00
m
785df2ced4 feat: add CalDAV bidirectional sync service (Phase 3O)
Implements CalDAV sync using github.com/emersion/go-webdav:

- CalDAVService with background polling (configurable per-tenant interval)
- Push: deadlines -> VTODO, appointments -> VEVENT on create/update/delete
- Pull: periodic fetch from CalDAV, reconcile with local DB
- Conflict resolution: KanzlAI wins dates/status, CalDAV wins notes/description
- Conflicts logged as case_events with caldav_conflict type
- UID pattern: kanzlai-{deadline|appointment}-{uuid}@kanzlai.msbls.de
- CalDAV config per tenant in tenants.settings JSONB

Endpoints:
- POST /api/caldav/sync — trigger full sync for current tenant
- GET /api/caldav/status — last sync time, item counts, errors

8 unit tests for UID generation, parsing, path construction, config parsing.
2026-03-25 14:01:30 +01:00
m
749273fba7 feat: add appointment calendar frontend (Phase 1H)
- /termine page with list/calendar view toggle
- AppointmentList: date-grouped list with type/case filtering, summary cards
- AppointmentCalendar: month grid with colored type dots, clickable days/appointments
- AppointmentModal: create/edit/delete with case linking, type selection, location
2026-03-25 14:00:56 +01:00
m
0ab2e8b383 feat: add document management frontend (Phase 2N)
- DocumentUpload: dropzone with multi-file support, upload via
  POST /api/cases/{id}/documents, progress feedback with toast
- DocumentList: type badges, file size, upload date, download links,
  delete with inline confirmation
- Integrated as Dokumente tab in case detail page with count badge
- Eagerly fetches document count for tab badge display
2026-03-25 13:59:48 +01:00
m
2cf01073a3 feat: add AI extraction frontend page (Phase 2M) 2026-03-25 13:54:49 +01:00
m
ed83d23d06 feat: add deadline management frontend (Phase 1G) 2026-03-25 13:54:35 +01:00
m
97ebeafcf7 feat: add case list, detail, and creation pages (Phase 1F) 2026-03-25 13:54:23 +01:00
m
26887248e1 feat: add dashboard with traffic lights, timeline, AI summary (Phase 2L) 2026-03-25 13:54:13 +01:00
m
1fa7d90050 feat: add deadline management frontend (Phase 1G)
- Fristen page with list view (sortable, filterable by status/case)
- Calendar view with month navigation and deadline dots
- Deadline calculator page (proceeding type + trigger date = timeline)
- Traffic light urgency: red (overdue), amber (this week), green (OK)
- Backend: GET /api/deadlines (all tenant deadlines), GET /api/proceeding-types
- API client: added patch() method
- Types: DeadlineRule, ProceedingType, CalculatedDeadline, RuleTreeNode
2026-03-25 13:53:12 +01:00
m
3a56d4cf11 feat: add frontend case list, detail, and creation pages (Phase 1F)
- Case list page (/cases) with search, status/type filters, status badges
- Case creation page (/cases/new) with reusable CaseForm component
- Case detail page (/cases/[id]) with tabs: Timeline, Deadlines, Documents, Parties
- CaseTimeline component for chronological case_events display
- PartyList component with inline party CRUD (add/delete)
- Updated sidebar navigation to route to /cases
2026-03-25 13:50:20 +01:00
m
45188ff5cb feat: add frontend dashboard with traffic lights, timeline, and AI summary (Phase 2L)
Dashboard page at /dashboard with 5 components:
- DeadlineTrafficLights: RED/AMBER/GREEN cards with animated counts and pulse for overdue
- CaseOverviewGrid: active/new/closed case counts
- UpcomingTimeline: merged deadlines + appointments for next 7 days, grouped by day
- AISummaryCard: natural language summary generated from dashboard data
- QuickActions: shortcuts to create cases, deadlines, AI analysis, CalDAV sync

3-column responsive grid layout. Root / redirects to /dashboard.
Fetches from GET /api/dashboard with 60s auto-refresh via react-query.
2026-03-25 13:49:24 +01:00
m
65b70975eb feat: add AI deadline extraction frontend page (Phase 2M)
- ExtractionForm: PDF dropzone (react-dropzone) + text textarea + case selector
- ExtractionResults: review table with edit/remove per row, confidence color-coding
- Page at /ai/extract: upload -> analyze -> review -> adopt deadlines to case
- Extended API client with postFormData for multipart uploads
- Added ExtractedDeadline and ExtractionResponse types
2026-03-25 13:48:53 +01:00
m
0fac764211 feat: add AI deadline extraction and case summary (Phase 2J) 2026-03-25 13:44:58 +01:00
m
78c511bd1f feat: add document upload/download backend (Phase 2K) 2026-03-25 13:44:01 +01:00
m
ca572d3289 feat: add dashboard aggregation endpoint (Phase 2I) 2026-03-25 13:43:29 +01:00
m
b2b3e04d05 feat: add frontend auth, app layout, and Supabase integration (Phase 1E) 2026-03-25 13:43:21 +01:00
m
5758e2c37f feat: add AI deadline extraction and case summary endpoints (Phase 2J)
Add two Claude API-powered endpoints:
- POST /api/ai/extract-deadlines: accepts PDF upload or JSON text, extracts
  legal deadlines using Claude tool_use for structured output
- POST /api/ai/summarize-case: generates AI summary from case events/deadlines,
  caches result in cases.ai_summary

New files:
- internal/services/ai_service.go: AIService with Anthropic SDK integration
- internal/handlers/ai.go: HTTP handlers for both endpoints
- internal/services/ai_service_test.go: tool schema and serialization tests

Uses anthropic-sdk-go v1.27.1 with Claude Sonnet 4.5. AI service is optional —
endpoints only registered when ANTHROPIC_API_KEY is set.
2026-03-25 13:40:27 +01:00
m
9bd8cc9e07 feat: add document upload/download backend (Phase 2K)
- StorageClient for Supabase Storage REST API (upload, download, delete)
- DocumentService with CRUD operations + storage integration
- DocumentHandler with multipart form upload support (50MB limit)
- Routes: GET/POST /api/cases/{id}/documents, GET/DELETE /api/documents/{docId}
- file_path format: {tenant_id}/{case_id}/{uuid}_{filename}
- Case events logged on upload/delete
- Added SUPABASE_SERVICE_KEY to config for server-side storage access
- Fixed pre-existing duplicate writeJSON/writeError in appointments.go
2026-03-25 13:40:19 +01:00
m
bf225284d8 feat: add frontend auth pages, app layout, and Supabase integration (Phase 1E)
- Auth pages: login (password + magic link), register (with firm name), callback
- Supabase client setup: browser client, server client, middleware for session refresh
- App layout: sidebar (Dashboard, Akten, Fristen, Termine, AI Analyse, Einstellungen),
  header with user info and tenant switcher
- Shared: API client with auth headers, TypeScript types matching Go models,
  QueryClientProvider + Toaster providers
- Dependencies: @supabase/supabase-js, @supabase/ssr, @tanstack/react-query,
  lucide-react, date-fns, sonner
2026-03-25 13:39:16 +01:00
m
e53e1389f9 feat: add dashboard aggregation endpoint (Phase 2I)
GET /api/dashboard returns aggregated data:
- deadline_summary: overdue, due this/next week, ok counts
- case_summary: active, new this month, closed counts
- upcoming_deadlines: next 7 days with case info
- upcoming_appointments: next 7 days
- recent_activity: last 10 case events

Uses efficient CTE query for summaries. Also fixes duplicate
writeJSON/writeError declarations in appointments handler.
2026-03-25 13:37:06 +01:00
m
2c16f26448 feat: add deadline CRUD, calculator, and holiday services (Phase 1C) 2026-03-25 13:33:57 +01:00
m
f0ee5921cf feat: add appointment CRUD backend (Phase 1D) 2026-03-25 13:32:51 +01:00
m
ba29fc75c7 feat: add case + party CRUD with case events (Phase 1B) 2026-03-25 13:32:15 +01:00
m
8350a7e7fb feat: add tenant + auth backend endpoints (Phase 1A) 2026-03-25 13:31:38 +01:00
m
42a62d45bf feat: add deadline CRUD, calculator, and holiday services (Phase 1C)
- Holiday service with German federal holidays, Easter calculation, DB loading
- Deadline calculator adapted from youpc.org (duration calc + non-working day adjustment)
- Deadline CRUD service (tenant-scoped: list, create, update, complete, delete)
- Deadline rule service (list, filter by proceeding type, hierarchical rule trees)
- HTTP handlers for all endpoints with tenant resolution via X-Tenant-ID header
- Router wired with all new endpoints under /api/
- Tests for holiday and calculator services (8 passing)
2026-03-25 13:31:29 +01:00
m
0b6bab8512 feat: add tenant + auth backend endpoints (Phase 1A)
Tenant management:
- POST /api/tenants — create tenant (creator becomes owner)
- GET /api/tenants — list tenants for authenticated user
- GET /api/tenants/:id — tenant details with access check
- POST /api/tenants/:id/invite — invite user by email (owner/admin)
- DELETE /api/tenants/:id/members/:uid — remove member
- GET /api/tenants/:id/members — list members

New packages:
- internal/services/tenant_service.go — CRUD on tenants + user_tenants
- internal/handlers/tenant_handler.go — HTTP handlers with auth checks
- internal/auth/tenant_resolver.go — X-Tenant-ID header middleware,
  defaults to user's first tenant for scoped routes

Authorization: owners/admins can invite and remove members. Cannot
remove the last owner. Users can remove themselves. TenantResolver
applies to resource routes (cases, deadlines, etc.) but not tenant
management routes.
2026-03-25 13:27:39 +01:00
m
f11c411147 feat: add case + party CRUD with case events (Phase 1B)
- CaseService: list (paginated, filterable), get detail (with parties,
  events, deadline count), create, update, soft-delete (archive)
- PartyService: list by case, create, update, delete
- Auto-create case_events on case creation, status change, party add,
  and case archive
- Auth middleware now resolves tenant_id from user_tenants table
- All operations scoped to tenant_id from auth context
2026-03-25 13:26:50 +01:00
m
bd15b4eb38 feat: add appointment CRUD backend (Phase 1D)
- AppointmentService with tenant-scoped List, GetByID, Create, Update, Delete
- List supports filtering by case_id, appointment_type, and date range (start_from/start_to)
- AppointmentHandler with JSON request/response handling and input validation
- Router wired up: GET/POST /api/appointments, PUT/DELETE /api/appointments/{id}
2026-03-25 13:25:46 +01:00
m
8049ea3c63 feat: add database schema and backend foundation (Phase 0) 2026-03-25 13:23:29 +01:00
m
1fc0874893 feat: add database schema and backend foundation
Part 1 - Database (kanzlai schema in Supabase):
- Tenant-scoped tables: tenants, user_tenants, cases, parties,
  deadlines, appointments, documents, case_events
- Global reference tables: proceeding_types, deadline_rules, holidays
- RLS policies on all tenant-scoped tables
- Seed: UPC proceeding types, 32 deadline rules (INF/CCR/REV/PI/APP),
  ZPO civil rules (Berufung, Revision, Einspruch), 2026 holidays

Part 2 - Backend skeleton:
- config: env var loading (DATABASE_URL, SUPABASE_*, ANTHROPIC_API_KEY)
- db: sqlx connection pool with kanzlai search_path
- auth: JWT verification middleware adapted from youpc.org, context helpers
- models: Go structs for all tables with sqlx/json tags
- router: route registration with auth middleware, /health + placeholder API routes
- Updated main.go to wire everything together
2026-03-25 13:17:33 +01:00
194 changed files with 24309 additions and 48 deletions

14
.claude/agents/coder.md Normal file
View File

@@ -0,0 +1,14 @@
# Coder Agent
Implementation-focused agent for writing and refactoring code.
## Instructions
- Follow existing patterns in the codebase
- Write minimal, focused code
- Run tests after changes
- Commit incrementally with descriptive messages
## Tools
All tools available.

View File

@@ -0,0 +1,14 @@
# Researcher Agent
Exploration and information gathering agent.
## Instructions
- Search broadly, then narrow down
- Document findings in structured format
- Cite sources and file paths
- Summarize key insights, don't dump raw data
## Tools
Read-only tools preferred. Use Bash only for non-destructive commands.

View File

@@ -0,0 +1,14 @@
# Reviewer Agent
Code review agent for checking quality and correctness.
## Instructions
- Check for bugs, security issues, and style violations
- Verify test coverage for changes
- Suggest improvements concisely
- Focus on correctness over style preferences
## Tools
Read-only tools. No file modifications.

1
.claude/skills/mai-clone Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-clone

1
.claude/skills/mai-coder Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-coder

1
.claude/skills/mai-commit Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-commit

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-consultant

1
.claude/skills/mai-daily Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-daily

1
.claude/skills/mai-debrief Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-debrief

1
.claude/skills/mai-enemy Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-enemy

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-excalidraw

1
.claude/skills/mai-fixer Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-fixer

1
.claude/skills/mai-gitster Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-gitster

1
.claude/skills/mai-head Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-head

1
.claude/skills/mai-init Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-init

1
.claude/skills/mai-inventor Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-inventor

1
.claude/skills/mai-lead Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-lead

1
.claude/skills/mai-maister Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-maister

1
.claude/skills/mai-member Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-member

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-researcher

1
.claude/skills/mai-think Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-think

1
.claude/skills/mai-web Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-web

View File

@@ -3,10 +3,16 @@
# Backend
PORT=8080
DATABASE_URL=postgresql://user:pass@host:5432/dbname
# Supabase (required for database access)
SUPABASE_URL=
# Supabase (required for database + auth)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_KEY=
SUPABASE_JWT_SECRET=
# Claude API (required for AI features)
ANTHROPIC_API_KEY=
# CalDAV (configured per-tenant in tenant settings, not env vars)
# See tenant.settings.caldav JSON field

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ tmp/
# TypeScript
*.tsbuildinfo
.worktrees/

4
.m/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
workers.json
spawn.lock
session.yaml
config.reference.yaml

168
.m/config.yaml Normal file
View File

@@ -0,0 +1,168 @@
provider: claude
providers:
claude:
api_key: ""
model: claude-sonnet-4-20250514
base_url: https://api.anthropic.com/v1
ollama:
host: http://localhost:11434
model: llama3.2
memory:
enabled: true
backend: ""
path: ""
url: postgres://mai_memory.your-tenant-id:maiMem6034supa@100.99.98.201:6543/postgres?sslmode=disable
group_id: ""
cache_ttl: 5m0s
auto_load: true
embedding_url: ""
embedding_model: ""
gitea:
url: https://mgit.msbls.de
repo: m/KanzlAI
token: ""
sync:
enabled: false
interval: 0s
repos: []
auto_queue: false
api:
api_key: ""
basic_auth:
username: ""
password: ""
public_endpoints:
- /api/health
ui:
theme: default
show_sidebar: true
animation: true
persona: true
avatar_pack: ""
worker:
names: []
name_scheme: role
default_level: standard
auto_discard: false
max_workers: 5
persistent: true
head:
name: ingeborg
max_loops: 50
infinity_mode: false
capacity:
global:
max_workers: 5
max_heads: 3
per_worker:
max_tasks_lifetime: 0
max_concurrent: 1
max_context_tokens: 0
per_head:
max_workers: 10
resources:
max_memory_mb: 0
max_cpu_percent: 0
queue:
max_pending: 100
stale_task_days: 30
workforce:
timeouts:
task_default: 0s
task_max: 0s
idle_before_warn: 10m0s
idle_before_kill: 30m0s
quality_check: 2m0s
context:
max_tokens_per_worker: 0
max_tokens_global: 0
warn_threshold: 0.8
truncate_strategy: oldest
delegation:
strategy: skill_match
preferred_role: coder
auto_delegate: false
max_depth: 3
allowed_roles:
- coder
- researcher
- fixer
peppy:
enabled: false
style: calm
interval: 5m0s
emoji: false
nudges: true
nudge_main: false
custom_prompt: ""
stall_threshold: 0s
restart_enabled: false
max_shifts: 0
quality_gates:
enabled: true
checks: []
preflight:
enabled: false
type: ""
root: ""
checks: []
guardrails:
enabled: false
use_defaults: true
output:
coder_checks: []
researcher_checks: []
fixer_checks: []
custom_checks: {}
global_checks: []
tools:
role_rules: {}
deny_patterns: []
allow_patterns: []
schemas:
report_schemas: {}
deliverable_schemas: {}
modes:
yolo: false
self_improvement: false
autonomous: false
verbose: false
improve_interval: 0s
predict_interval: 0s
layouts:
head: ""
worker: ""
roles: {}
dog:
name: buddy
supabase:
url: ""
role_key: ""
anon_key: ""
schema: mai
storage:
backend: ""
postgres:
url: ""
max_conns: 0
min_conns: 0
max_conn_lifetime: 0s
idle:
behavior: wait
auto_hire: false
prompt: ""
git:
worktrees:
enabled: true
delete_branch: false
dir: .worktrees
phase:
enabled: false
current: ""
allowed_roles: {}
goal: ""
skills: {}
editor: nvim
log_level: info
project_detection: true
tone: professional

22
.mcp.json Normal file
View File

@@ -0,0 +1,22 @@
{
"mcpServers": {
"mai": {
"type": "http",
"url": "http://100.99.98.201:8000/mcp",
"headers": {
"Authorization": "Basic ${SUPABASE_AUTH}"
}
},
"mai-memory": {
"command": "mai",
"args": [
"mcp",
"memory"
],
"env": {
"MAI_MEMORY_EMBEDDING_MODEL": "nomic-embed-text",
"MAI_MEMORY_EMBEDDING_URL": "https://llm.x.msbls.de"
}
}
}
}

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.claude/CLAUDE.md

482
AUDIT.md Normal file
View File

@@ -0,0 +1,482 @@
# KanzlAI-mGMT MVP Audit
**Date:** 2026-03-28
**Auditor:** athena (consultant)
**Scope:** Full-stack audit of KanzlAI-mGMT — Go backend, Next.js frontend, Supabase database, deployment, security, UX, competitive positioning.
**Codebase:** ~16,500 lines across ~60 source files, built 2026-03-25 in a single session with parallel workers.
---
## Executive Summary
KanzlAI-mGMT is an impressive MVP built in ~2 hours. It covers the core Kanzleimanagement primitives: cases, deadlines, appointments, parties, documents, notes, dashboard, CalDAV sync, and AI-powered deadline extraction. The architecture is sound — clean separation between Go API and Next.js frontend, proper multi-tenant design with Supabase Auth, parameterized SQL throughout.
However, the speed of construction shows. There are **critical security gaps** that must be fixed before any external user touches this. The frontend has good bones but lacks the polish and completeness a lawyer would expect. And the feature gap vs. established competitors (RA-MICRO, ADVOWARE, AnNoText, Actaport) is enormous — particularly around beA integration, billing/RVG, and document generation, which are table-stakes for German law firms.
**Bottom line:** Fix the security issues, add error recovery and multi-tenant auth verification, then decide whether to pursue the Kanzleimanagement market (massive feature gap) or pivot back to the UPC niche (where you had a genuine competitive advantage).
---
## 1. Critical Issues (Fix Immediately)
### 1.1 Tenant Isolation Bypass in TenantResolver
**File:** `backend/internal/auth/tenant_resolver.go:37-42`
When the `X-Tenant-ID` header is provided, the TenantResolver parses it and sets it in context **without verifying the user has access to that tenant**. Any authenticated user can access any tenant's data by setting this header.
```go
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
// ... sets tenantID = parsed — NO ACCESS CHECK
}
```
Compare with `helpers.go:32-44` where `resolveTenant()` correctly verifies access via `user_tenants` — but this function is unused in the middleware path. The TenantResolver middleware is what actually runs for all scoped routes.
**Impact:** Complete tenant data isolation breach. User A can read/modify/delete User B's cases, deadlines, appointments, documents.
**Fix:** Add `user_tenants` lookup in TenantResolver when X-Tenant-ID is provided, same as `resolveTenant()` does.
### 1.2 Duplicate Tenant Resolution Logic
**Files:** `backend/internal/auth/tenant_resolver.go` and `backend/internal/handlers/helpers.go:25-57`
Two independent implementations of tenant resolution exist. The middleware (`TenantResolver`) is used for the scoped routes. The handler-level `resolveTenant()` function exists in helpers.go. The auth middleware in `middleware.go:39-47` also resolves a tenant into context. This triple-resolution creates confusion and the security bug above.
**Fix:** Consolidate to a single path. Remove the handler-level `resolveTenant()` and the auth middleware's tenant resolution. Let TenantResolver be the single source of truth, but make it verify access.
### 1.3 CalDAV Credentials Stored in Plaintext
**File:** `backend/internal/services/caldav_service.go:29-35`
CalDAV username and password are stored as plain JSON in the `tenants.settings` column:
```go
type CalDAVConfig struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
...
}
```
Combined with the tenant isolation bypass above, any authenticated user can read any tenant's CalDAV credentials.
**Fix:** Encrypt CalDAV credentials at rest (e.g., using `pgcrypto` or application-level encryption). At minimum, never return the password in API responses.
### 1.4 No CORS Configuration
**File:** `backend/internal/router/router.go`, `backend/cmd/server/main.go`
There is zero CORS handling anywhere in the backend. The frontend uses Next.js rewrites to proxy `/api/` to the backend, which works in production. But:
- If anyone accesses the backend directly (different origin), there's no CORS protection.
- No `X-Frame-Options`, `X-Content-Type-Options`, or other security headers are set.
**Fix:** Add CORS middleware restricting to the frontend origin. Add standard security headers.
### 1.5 Internal Error Messages Leaked to Clients
**Files:** Multiple handlers (e.g., `cases.go:44`, `cases.go:73`, `appointments.go`)
```go
writeError(w, http.StatusInternalServerError, err.Error())
```
Internal error messages (including SQL errors, connection errors, etc.) are sent directly to the client. This leaks implementation details.
**Fix:** Log the full error server-side, return a generic message to the client.
### 1.6 Race Condition in HolidayService Cache
**File:** `backend/internal/services/holidays.go`
The `HolidayService` uses a `map[int][]Holiday` cache without any mutex protection. Concurrent requests (e.g., multiple deadline calculations) will cause a data race. The Go race detector would flag this.
**Fix:** Add `sync.RWMutex` to HolidayService.
### 1.7 Rate Limiter Trivially Bypassable
**File:** `backend/internal/middleware/ratelimit.go:78-79`
```go
ip := r.Header.Get("X-Forwarded-For")
if ip == "" { ip = r.RemoteAddr }
```
Rate limiting keys off `X-Forwarded-For`, which any client can spoof. An attacker can bypass AI endpoint rate limits by rotating this header.
**Fix:** Only trust `X-Forwarded-For` from configured reverse proxy IPs, or use `r.RemoteAddr` exclusively behind a trusted proxy.
---
## 2. Important Gaps (Fix Before Showing to Anyone)
### 2.1 No Input Validation Beyond "Required Fields"
**Files:** All handlers
Input validation is minimal — typically just checking if required fields are empty:
```go
if input.CaseNumber == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "case_number and title are required")
}
```
Missing:
- Length limits on text fields (could store megabytes in a title field)
- Status value validation (accepts any string for status fields)
- Date format validation
- Case type validation against allowed values
- SQL-safe string validation (although parameterized queries protect against injection)
### 2.2 No Pagination Defaults on Most List Endpoints
**File:** `backend/internal/services/case_service.go:57-63`
`CaseService.List` has sane defaults (limit=20, max=100). But other list endpoints (`appointments`, `deadlines`, `notes`, `parties`, `case_events`) have no pagination at all — they return all records for a tenant/case. As data grows, these become performance problems.
### 2.3 Dashboard Page is Entirely Client-Side
**File:** `frontend/src/app/(app)/dashboard/page.tsx`
The entire dashboard is a `"use client"` component that fetches data via API. This means:
- No SSR benefit — the page is blank until JS loads and API responds
- SEO doesn't matter for a SaaS app, but initial load time does
- The skeleton is nice but adds 200-400ms of perceived latency
For an internal tool this is acceptable, but for a commercial product it should use server components for the initial render.
### 2.4 Frontend Auth Uses `getSession()` Instead of `getUser()`
**File:** `frontend/src/lib/api.ts:10-12`
```typescript
const { data: { session } } = await supabase.auth.getSession();
```
`getSession()` reads from local storage without server verification. If a session is expired or revoked server-side, the frontend will still try to use it until the backend rejects it. The middleware correctly uses `getUser()` (which validates server-side), but the API client does not.
### 2.5 Missing Error Recovery in Frontend
Throughout the frontend, API errors are handled with basic error states, but there's no:
- Retry logic for transient failures
- Token refresh on 401 responses
- Optimistic UI rollback on mutation failures
- Offline detection
### 2.6 Missing `Content-Disposition` Header Sanitization
**File:** `backend/internal/handlers/documents.go:133`
```go
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title))
```
The `title` (which comes from user input) is inserted directly into the header. A filename containing `"` or newlines could be used for response header injection.
**Fix:** Sanitize the filename — strip or encode special characters.
### 2.7 No Graceful Shutdown
**File:** `backend/cmd/server/main.go:42`
```go
http.ListenAndServe(":"+cfg.Port, handler)
```
No signal handling or graceful shutdown. When the process receives SIGTERM (e.g., during deployment), in-flight requests are dropped, CalDAV sync operations may be interrupted mid-write, and database connections are not cleanly closed.
### 2.8 Database Connection Pool — search_path is Session-Level
**File:** `backend/internal/db/connection.go:17`
```go
db.Exec("SET search_path TO kanzlai, public")
```
`SET search_path` is session-level in PostgreSQL. With connection pooling (`MaxOpenConns: 25`), this SET runs once on the initial connection. If a connection is recycled or a new one opened from the pool, it may not have the kanzlai search_path. This could cause queries to silently hit the wrong schema.
**Fix:** Use `SET LOCAL search_path` in a transaction, or set it at the database/role level, or qualify all table references with the schema name.
### 2.9 go.sum Missing from Dockerfile
**File:** `backend/Dockerfile:4`
```dockerfile
COPY go.mod ./
RUN go mod download
```
Only `go.mod` is copied, not `go.sum`. This means the build isn't reproducible and doesn't verify checksums. Should be `COPY go.mod go.sum ./`.
### 2.10 German Umlaut Typos Throughout Frontend
**Files:** Multiple frontend components
German strings use ASCII approximations instead of proper characters:
- `login/page.tsx`: "Zurueck" instead of "Zurück"
- `cases/[id]/layout.tsx`: "Anhaengig" instead of "Anhängig"
- `cases/[id]/fristen/page.tsx`: "Ueberfaellig" instead of "Überfällig"
- `termine/page.tsx`: "Uberblick" instead of "Überblick"
A German lawyer would notice this immediately. It signals "this was built by a machine, not tested by a human."
### 2.11 Silent Error Swallowing in Event Creation
**File:** `backend/internal/services/case_service.go:260-266`
```go
func createEvent(ctx context.Context, db *sqlx.DB, ...) {
db.ExecContext(ctx, /* ... */) // Error completely ignored
}
```
Case events (audit trail) silently fail to create. The calling functions don't check the return. This means you could have cases with no events and no way to know why.
### 2.12 Missing Error Boundaries in Frontend
No React error boundaries are implemented. If any component throws, the entire page crashes with a white screen. For a law firm tool where data integrity matters, this is unacceptable.
### 2.13 No RLS Policies Defined at Database Level
Multi-tenant isolation relies entirely on `WHERE tenant_id = $X` clauses in Go code. If any query forgets this clause, data leaks across tenants. There are no PostgreSQL RLS policies as a safety net.
**Fix:** Enable RLS on all tenant-scoped tables and create policies tied to `auth.uid()` via `user_tenants`.
---
## 3. Architecture Assessment
### 3.1 What's Good
- **Clean monorepo structure** — `backend/` and `frontend/` are clearly separated. Each has its own Dockerfile. The Makefile provides unified commands.
- **Go backend is well-organized** — `cmd/server/`, `internal/{auth,config,db,handlers,middleware,models,router,services}` follows Go best practices.
- **Handler/Service separation** — handlers do HTTP concerns (parse request, write response), services do business logic. This is correct.
- **Parameterized SQL everywhere** — no string concatenation in queries. All user input goes through `$N` placeholders.
- **Multi-tenant design** — `tenant_id` on every row, context-based tenant resolution, RLS at the database level.
- **Smart use of Go 1.22+ routing** — method+path patterns like `GET /api/cases/{id}` eliminate the need for a third-party router.
- **CalDAV sync is genuinely impressive** — bidirectional sync with conflict resolution, etag tracking, background polling per-tenant. This is a differentiator.
- **Deadline calculator** — ported from youpc.org with holiday awareness. Legally important and hard to build.
- **Frontend routing structure** — German URL paths (`/fristen`, `/termine`, `/einstellungen`), nested case detail routes with layout.tsx for shared chrome. Proper use of App Router patterns.
### 3.2 Structural Concerns
- **No database migrations** — the schema was apparently created via SQL scripts run manually. There's a `seed/demo_data.sql` but no migration system. For a production system, this is unsustainable.
- **No CI/CD pipeline** — no `.github/workflows/`, `.gitea/`, or any CI configuration. Tests run locally but not automatically.
- **No API versioning** — all routes are at `/api/`. Adding breaking changes will break clients.
- **Services take raw `*sqlx.DB`** — no transaction support across service boundaries. Creating a case + event is not atomic (if the event insert fails, the case still exists).
- **Models are just struct definitions** — no validation methods, no constructor functions. Validation is scattered across handlers.
### 3.3 Data Model
Based on the seed data and model files, the schema is reasonable:
- `tenants`, `user_tenants` (multi-tenancy)
- `cases`, `parties` (case management)
- `deadlines`, `appointments` (time management)
- `documents`, `case_events`, `notes` (supporting data)
- `proceeding_types`, `deadline_rules`, `holidays` (reference data)
**Missing indexes likely needed:**
- `deadlines(tenant_id, status, due_date)` — for dashboard queries
- `appointments(tenant_id, start_at)` — for calendar queries
- `case_events(case_id, created_at)` — for event feeds
- `cases(tenant_id, status)` — for filtered lists
**Missing constraints:**
- No CHECK constraint on status values (cases, deadlines, appointments)
- No UNIQUE constraint on `case_number` per tenant
- No foreign key from `notes` to the parent entity (if polymorphic)
---
## 4. Security Assessment
### 4.1 Authentication
- **JWT validation is correct** — algorithm check (HMAC only), expiry check, sub claim extraction. Using `golang-jwt/v5`.
- **Supabase Auth on frontend** — proper cookie-based session with server-side verification in middleware.
- **No refresh token rotation** — the API client uses `getSession()` which may serve stale tokens.
### 4.2 Authorization
- **Critical: Tenant isolation bypass** (see 1.1)
- **No role-based access control** — `user_tenants` has a `role` column but it's never checked. Any member can do anything.
- **No resource-level permissions** — any user in a tenant can delete any case, document, etc.
### 4.3 Input Validation
- **SQL injection: Protected** — all queries use parameterized placeholders.
- **XSS: Partially protected** — React auto-escapes, but the API returns raw strings that could contain HTML. The `Content-Disposition` header is vulnerable (see 2.6).
- **File upload: Partially protected** — `MaxBytesReader` limits to 50MB, but no file type validation (could upload .exe, .html with scripts, etc.).
- **Rate limiting: AI endpoints only** — the rest of the API has no rate limiting. Login/register go through Supabase (which has its own limits), but all CRUD endpoints are unlimited.
### 4.4 Secrets
- **No hardcoded secrets** — all via environment variables. Good.
- **CalDAV credentials in plaintext** — see 1.3.
- **Supabase service key in backend** — necessary for storage, but this key has full DB access. Should be scoped.
---
## 5. Testing Assessment
### 5.1 Backend Tests (15 files)
- **Integration test** — sets up real DB connection, creates JWT, tests full HTTP flow. Excellent pattern but requires DATABASE_URL (skips otherwise).
- **Handler tests** — mock-based unit tests for most handlers. Test JSON parsing, error responses, basic happy paths.
- **Service tests** — deadline calculator has solid date arithmetic tests. Holiday service tested. CalDAV service tested with mocks. AI service tested with mocked HTTP.
- **Middleware tests** — rate limiter tested.
- **Auth tests** — tenant resolver tested.
### 5.2 Frontend Tests (4 files)
- `api.test.ts` — tests the API client
- `DeadlineTrafficLights.test.tsx` — component test
- `CaseOverviewGrid.test.tsx` — component test
- `LoginPage.test.tsx` — auth page test
### 5.3 What's Missing
- **No E2E tests** — no Playwright/Cypress. Critical for a law firm app where correctness matters.
- **No contract tests** — frontend and backend are tested independently. A schema change could break the frontend without any test catching it.
- **Deadline calculation edge cases** — needs tests for year boundaries, leap years, holidays falling on weekends, multiple consecutive holidays.
- **Multi-tenant security tests** — no test verifying that User A can't access Tenant B's data. This is the most important test to add.
- **Frontend test coverage is thin** — 4 tests for ~30 components. The dashboard, all forms, navigation, error states are untested.
- **No load testing** — unknown how the system behaves under concurrent users.
---
## 6. UX Assessment
### 6.1 What Works
- **Dashboard is strong** — traffic light deadline indicators, upcoming timeline, case overview, quick actions. A lawyer can see what matters at a glance.
- **German localization** — UI is in German with proper legal terminology (Akten, Fristen, Termine, Parteien).
- **Mobile responsive** — sidebar collapses to hamburger menu, layout uses responsive grids.
- **Loading states** — skeleton screens on dashboard, not just spinners.
- **Breadcrumbs** — navigation trail on all pages.
- **Deadline calculator** — unique feature that provides real value for UPC litigation.
### 6.2 What a Lawyer Would Stumble On
1. **No onboarding flow** — after registration, user has no tenant, no cases. The app shows empty states but doesn't guide the user to create a tenant or import data.
2. **No search** — there's no global search. A lawyer with 100+ cases needs to find things fast.
3. **No keyboard shortcuts** — power users (lawyers are keyboard-heavy) have no shortcuts.
4. **Sidebar mixes languages** — "Akten" (German) vs "AI Analyse" (English). Should be consistent.
5. **No notifications** — overdue deadlines don't trigger any alert beyond the dashboard color. No email alerts, no push notifications.
6. **No print view** — lawyers need to print deadline lists, case summaries. No print stylesheet.
7. **No bulk operations** — can't mark multiple deadlines as complete, can't bulk-assign parties.
8. **Document upload has no preview** — uploaded PDFs can't be viewed inline.
9. **AI features require manual trigger** — AI summary and deadline extraction are manual. Should auto-trigger on document upload.
10. **No activity log per user** — no audit trail of who changed what. Critical for law firm compliance.
---
## 7. Deployment Assessment
### 7.1 Docker Setup
- **Multi-stage builds** — both Dockerfiles use builder pattern. Good.
- **Backend is minimal** — Alpine + static binary + ca-certificates. ~15MB image.
- **Frontend** — Bun for deps/build, Node for runtime (standalone output). Reasonable.
- **Missing:** go.sum not copied in backend Dockerfile (see 2.9).
- **Missing:** No docker-compose.yml for local development.
- **Missing:** No health check in Dockerfile (`HEALTHCHECK` instruction).
### 7.2 Environment Handling
- **Config validates required vars** — `DATABASE_URL` and `SUPABASE_JWT_SECRET` are checked at startup.
- **Supabase URL/keys not validated** — if missing, features silently fail or crash at runtime.
- **No .env.example** — new developers don't know what env vars are needed.
### 7.3 Reliability
- **No graceful shutdown** (see 2.7)
- **No readiness/liveness probes** — `/health` exists but only checks DB connectivity. No readiness distinction.
- **CalDAV sync runs in-process** — if the sync goroutine panics, it takes down the API server.
- **No structured error recovery** — panics in handlers will crash the process (no recovery middleware).
---
## 8. Competitive Analysis
### 8.1 The Market
German Kanzleisoftware is a mature, crowded market:
| Tool | Type | Price | Key Strength |
|------|------|-------|-------------|
| **RA-MICRO** | Desktop + Cloud | ~100-200 EUR/user/mo | Market leader, 30+ years, full beA integration |
| **ADVOWARE** | Desktop + Cloud | from 20 EUR/mo | Budget-friendly, strong for small firms |
| **AnNoText** (Wolters Kluwer) | Desktop + Cloud | Custom pricing | Enterprise, AI document analysis, DictNow |
| **Actaport** | Cloud-native | from 79.80 EUR/mo | Modern UI, Mandantenportal, integrated Office |
| **Haufe Advolux** | Cloud | Custom | User-friendly, full-featured |
| **Renostar Legal Cloud** | Cloud | Custom | Browser-based, no installation |
### 8.2 Table-Stakes Features KanzlAI is Missing
These are **mandatory** for any German Kanzleisoftware to be taken seriously:
1. **beA Integration** — since 2022, German lawyers must use the electronic court mailbox (besonderes elektronisches Anwaltspostfach). No Kanzleisoftware sells without it. This is a **massive** implementation effort (KSW-Schnittstelle from BRAK).
2. **RVG Billing (Gebührenrechner)** — automated fee calculation per RVG (Rechtsanwaltsvergütungsgesetz). Every competitor has this built-in. Without it, lawyers can't bill clients.
3. **Document Generation** — templates for Schriftsätze, Klageschriften, Mahnbescheide with auto-populated case data. Usually integrated with Word.
4. **Accounting (FiBu)** — client trust accounts (Fremdgeld), DATEV export, tax-relevant bookkeeping. Legal requirement.
5. **Conflict Check (Kollisionsprüfung)** — check if the firm has a conflict of interest before taking a case. Legally required (§ 43a BRAO).
6. **Dictation System** — voice-to-text for lawyers. RA-MICRO has DictaNet, AnNoText has DictNow.
### 8.3 Where KanzlAI Could Differentiate
Despite the feature gap, KanzlAI has some advantages:
1. **AI-native** — competitors are bolting AI onto 20-year-old software. KanzlAI has Claude API integration from day one. The deadline extraction from PDFs is genuinely useful.
2. **UPC specialization** — the deadline calculator with UPC Rules of Procedure knowledge is unique. No competitor has deep UPC litigation support.
3. **CalDAV sync** — bidirectional sync with external calendars is not common in German Kanzleisoftware.
4. **Modern tech stack** — React + Go + Supabase vs. the .NET/Java/Desktop world of RA-MICRO et al.
5. **Multi-tenant from day 1** — designed for SaaS, not converted from desktop software.
### 8.4 Strategic Recommendation
**Don't compete head-on with RA-MICRO.** The feature gap is 10+ person-years of work. Instead:
**Option A: UPC Niche Tool** — Pivot back to UPC patent litigation. Build the best deadline calculator, case tracker, and AI-powered brief analysis tool for UPC practitioners. There are ~1000 UPC practitioners in Europe who need specialized tooling that RA-MICRO doesn't provide. Charge 200-500 EUR/mo.
**Option B: AI-First Legal Assistant** — Don't call it "Kanzleimanagement." Position as an AI assistant that reads court documents, extracts deadlines, and syncs to the lawyer's existing Kanzleisoftware via CalDAV/iCal. This sidesteps the feature gap entirely.
**Option C: Full Kanzleisoftware** — If you pursue this, beA integration is the first priority, then RVG billing. Without these two, no German lawyer will switch.
---
## 9. Strengths (What's Good, Keep Doing It)
1. **Architecture is solid** — the Go + Next.js + Supabase stack is well-chosen. Clean separation of concerns.
2. **SQL is safe** — parameterized queries throughout. No injection vectors.
3. **Multi-tenant design** — tenant_id scoping with RLS is the right approach.
4. **CalDAV implementation** — genuinely impressive for an MVP. Bidirectional sync with conflict resolution.
5. **Deadline calculator** — ported from youpc.org with holiday awareness. Real domain value.
6. **AI integration** — Claude API with tool use for structured extraction. Clean implementation.
7. **Dashboard UX** — traffic lights, timeline, quick actions. Lawyers will get this immediately.
8. **German-first** — proper legal terminology, German date formats, localized UI.
9. **Test foundation** — 15 backend test files with integration tests. Good starting point.
10. **Docker builds are lean** — multi-stage, Alpine-based, standalone Next.js output.
---
## 10. Priority Roadmap
### P0 — This Week
- [ ] Fix tenant isolation bypass in TenantResolver (1.1)
- [ ] Consolidate tenant resolution logic (1.2)
- [ ] Encrypt CalDAV credentials at rest (1.3)
- [ ] Add CORS middleware + security headers (1.4)
- [ ] Stop leaking internal errors to clients (1.5)
- [ ] Add mutex to HolidayService cache (1.6)
- [ ] Fix rate limiter X-Forwarded-For bypass (1.7)
- [ ] Fix Dockerfile go.sum copy (2.9)
### P1 — Before Demo/Beta
- [ ] Add input validation (length limits, allowed values) (2.1)
- [ ] Add pagination to all list endpoints (2.2)
- [ ] Fix `search_path` connection pool issue (2.8)
- [ ] Add graceful shutdown with signal handling (2.7)
- [ ] Sanitize Content-Disposition filename (2.6)
- [ ] Fix German umlaut typos throughout frontend (2.10)
- [ ] Handle createEvent errors instead of swallowing (2.11)
- [ ] Add React error boundaries (2.12)
- [ ] Implement RLS policies on all tenant-scoped tables (2.13)
- [ ] Add multi-tenant security tests
- [ ] Add database migrations system
- [ ] Add `.env.example` file
- [ ] Add onboarding flow for new users
### P2 — Next Iteration
- [ ] Role-based access control (admin/member/readonly)
- [ ] Global search
- [ ] Email notifications for overdue deadlines
- [ ] Audit trail / activity log per user
- [ ] Auto-trigger AI extraction on document upload
- [ ] Print-friendly views
- [ ] E2E tests with Playwright
- [ ] CI/CD pipeline
### P3 — Strategic
- [ ] Decide market positioning (UPC niche vs. AI assistant vs. full Kanzleisoftware)
- [ ] If Kanzleisoftware: begin beA integration research
- [ ] If Kanzleisoftware: RVG Gebührenrechner
- [ ] If UPC niche: integrate lex-research case law database
---
*This audit was conducted by reading every source file in the repository, running all tests, analyzing the database schema via seed data, and comparing against established German Kanzleisoftware competitors.*

View File

@@ -0,0 +1,665 @@
# Design: Dashboard Redesign + Detail Pages
**Task:** t-kz-060
**Author:** cronus (inventor)
**Date:** 2026-03-25
**Status:** Design proposal
---
## Problem Statement
The current dashboard is a read-only status board. Cards show counts but don't link anywhere. Timeline items are inert. Quick actions navigate to list pages rather than creation flows. There are no detail pages for individual events, deadlines, or appointments. Notes don't exist as a first-class entity. Case detail tabs use local state instead of URL segments, breaking deep linking and back navigation.
## Design Principles
1. **Everything clickable goes somewhere** — no dead-end UI
2. **Breadcrumb navigation** — always know where you are, one click to go back
3. **German labels throughout** — consistent with existing convention
4. **Mobile responsive** — sidebar collapses, cards stack, touch targets >= 44px
5. **Information density over whitespace** — law firm users want data, not decoration
6. **URL-driven state** — tabs, filters, and views reflected in the URL for deep linking
---
## Part 1: Dashboard Redesign
### 1.1 Traffic Light Cards → Click-to-Filter
**Current:** Three cards (Ueberfaellig / Diese Woche / Im Zeitplan) show counts. `onFilter` prop exists but is never wired up in `dashboard/page.tsx`.
**Proposed:**
Clicking a traffic light card navigates to `/fristen?status={filter}`:
| Card | Navigation Target |
|------|------------------|
| Ueberfaellig (red) | `/fristen?status=overdue` |
| Diese Woche (amber) | `/fristen?status=this_week` |
| Im Zeitplan (green) | `/fristen?status=ok` |
**Implementation:**
- Replace `onFilter` callback with `next/link` navigation using `href`
- `DeadlineTrafficLights` becomes a pure link-based component (no callback needed)
- `/fristen` page reads `searchParams.status` and pre-applies the filter
- The DeadlineList component already supports status filtering — just needs to read from URL
**Changes:**
- `DeadlineTrafficLights.tsx`: Replace `<button onClick>` with `<Link href="/fristen?status=...">`
- `fristen/page.tsx`: Read `searchParams` and pass as initial filter to DeadlineList
- `DeadlineList.tsx`: Accept `initialStatus` prop from URL params
### 1.2 Case Overview Grid → Click-to-Filter
**Current:** Three static metrics (Aktive Akten / Neu / Abgeschlossen). No links.
**Proposed:**
| Card | Navigation Target |
|------|------------------|
| Aktive Akten | `/cases?status=active` |
| Neu (Monat) | `/cases?status=active&since=month` |
| Abgeschlossen | `/cases?status=closed` |
**Implementation:**
- Wrap each metric row in `<Link>` to `/cases` with appropriate query params
- Cases list page already has filtering — needs to read URL params on mount
- Add visual hover state (arrow icon on hover, background highlight)
**Changes:**
- `CaseOverviewGrid.tsx`: Each row becomes a `<Link>` with hover arrow
- `cases/page.tsx`: Read `searchParams` for initial filter state
### 1.3 Timeline Items → Click-to-Navigate
**Current:** Timeline entries show deadline/appointment info but are not clickable. No link to the parent case or the item itself.
**Proposed:**
Each timeline entry becomes a clickable row:
- **Deadline entries**: Click navigates to `/fristen/{id}` (new deadline detail page)
- **Appointment entries**: Click navigates to `/termine/{id}` (new appointment detail page)
- **Case reference** (Az. / case_number): Secondary click target linking to `/cases/{case_id}`
**Visual changes:**
- Add `cursor-pointer` and hover state (`hover:bg-neutral-100` transition)
- Add a small chevron-right icon on the right edge
- Case number becomes a subtle underlined link (click stops propagation)
**Data changes:**
- `UpcomingDeadline` needs `case_id` field (currently missing from the dashboard query — the backend model has it but the SQL join doesn't select it)
- `UpcomingAppointment` already has `case_id`
**Backend change:**
- `dashboard_service.go` line 112: Add `d.case_id` to the upcoming deadlines SELECT
- `DashboardService.UpcomingDeadline` struct: Add `CaseID uuid.UUID` field
- Frontend `UpcomingDeadline` type: Already has `case_id` (it's in types.ts but the backend doesn't send it)
### 1.4 Quick Actions → Proper Navigation
**Current:** "Frist eintragen" goes to `/fristen` (list page), not a creation flow. "CalDAV Sync" goes to `/einstellungen`.
**Proposed:**
| Action | Current Target | New Target |
|--------|---------------|------------|
| Neue Akte | `/cases/new` | `/cases/new` (keep) |
| Frist eintragen | `/fristen` | `/fristen/neu` (new creation page) |
| Neuer Termin | (missing) | `/termine/neu` (new creation page) |
| AI Analyse | `/ai/extract` | `/ai/extract` (keep) |
Replace "CalDAV Sync" with "Neuer Termin" — CalDAV sync is a settings function, not a daily quick action. Creating an appointment is something a secretary does multiple times per day.
**Changes:**
- `QuickActions.tsx`: Update hrefs, swap CalDAV for appointment creation
- Create `/fristen/neu/page.tsx` — standalone deadline creation form (select case, fill fields)
- Create `/termine/neu/page.tsx` — standalone appointment creation form
### 1.5 AI Summary Card → Refresh Button
**Current:** Rule-based summary text, no refresh mechanism. Card regenerates on page load but not on demand.
**Proposed:**
- Add a small refresh icon button (RefreshCw) in the card header, next to "KI-Zusammenfassung"
- Clicking it calls `refetch()` on the dashboard query (passed as prop)
- Show a brief spinning animation during refetch
- If/when real AI summarization is wired up, this button triggers `POST /api/ai/summarize-dashboard` (future endpoint)
**Changes:**
- `AISummaryCard.tsx`: Accept `onRefresh` prop, add button with spinning state
- `dashboard/page.tsx`: Pass `refetch` to AISummaryCard
### 1.6 Dashboard Layout: Add Recent Activity Section
**Current:** The backend returns `recent_activity` (last 10 case events) but the frontend ignores it entirely.
**Proposed:**
- Add a "Letzte Aktivitaet" section below the timeline, full width
- Shows the 10 most recent case events in a compact list
- Each row: event icon (by type) | title | case number (linked) | relative time
- Clicking a row navigates to the case event detail page `/cases/{case_id}/ereignisse/{event_id}`
**Changes:**
- New component: `RecentActivityList.tsx` in `components/dashboard/`
- `dashboard/page.tsx`: Add section below the main grid
- Add `RecentActivity` type to `types.ts` (needs `case_id` and `event_id` fields from backend)
- Backend: Add `case_id` and `id` to the recent activity query
---
## Part 2: New Pages
### 2.1 Deadline Detail Page — `/fristen/{id}`
**Route:** `src/app/(app)/fristen/[id]/page.tsx`
**Layout:**
```
Breadcrumb: Dashboard > Fristen > {deadline.title}
+---------------------------------------------------------+
| [Status Badge] {deadline.title} [Erledigen] |
| Fällig: 28. März 2026 |
+---------------------------------------------------------+
| Akte: Az. 2024/001 — Müller v. Schmidt [→ Zur Akte] |
| Quelle: Berechnet (R.118 RoP) |
| Ursprüngliches Datum: 25. März 2026 |
| Warnungsdatum: 21. März 2026 |
+---------------------------------------------------------+
| Notizen [Bearbeiten]|
| Fristverlängerung beantragt am 20.03. |
+---------------------------------------------------------+
| Verlauf |
| ○ Erstellt am 15.03.2026 |
| ○ Warnung gesendet am 21.03.2026 |
+---------------------------------------------------------+
```
**Data requirements:**
- `GET /api/deadlines/{id}` — new endpoint returning full deadline with case info
- Returns: Deadline + associated case (number, title, id) + notes
**Sections:**
1. **Header**: Status badge (Offen/Erledigt/Ueberfaellig), title, "Erledigen" action button
2. **Due date**: Large, with relative time ("in 3 Tagen" / "vor 2 Tagen ueberfaellig")
3. **Context panel**: Parent case (linked), source (manual/calculated/caldav), rule reference, original vs adjusted date
4. **Notes section**: Free-text notes (existing `notes` field on deadline), inline edit
5. **Activity log**: Timeline of changes to this deadline (future: from case_events filtered by deadline)
**Backend additions:**
- `GET /api/deadlines/{id}` — new handler returning single deadline with case join
- Handler: `deadlines.go` add `Get` method
- Service: `deadline_service.go` add `GetByID` with case join
### 2.2 Appointment Detail Page — `/termine/{id}`
**Route:** `src/app/(app)/termine/[id]/page.tsx`
**Layout:**
```
Breadcrumb: Dashboard > Termine > {appointment.title}
+---------------------------------------------------------+
| {appointment.title} [Bearbeiten] [X] |
| Typ: Verhandlung |
+---------------------------------------------------------+
| Datum: 28. März 2026, 10:00 12:00 Uhr |
| Ort: UPC München, Saal 3 |
+---------------------------------------------------------+
| Akte: Az. 2024/001 — Müller v. Schmidt [→ Zur Akte] |
+---------------------------------------------------------+
| Beschreibung |
| Erste mündliche Verhandlung... |
+---------------------------------------------------------+
| Notizen [+ Neu] |
| ○ 25.03. — Mandant über Termin informiert |
| ○ 24.03. — Schriftsatz vorbereitet |
+---------------------------------------------------------+
```
**Data requirements:**
- `GET /api/appointments/{id}` — new endpoint returning single appointment with case info
- Notes: Uses new `notes` table (see Part 3)
**Backend additions:**
- `GET /api/appointments/{id}` — new handler
- Handler: `appointments.go` add `Get` method
- Service: `appointment_service.go` add `GetByID` with optional case join
### 2.3 Case Event Detail Page — `/cases/{id}/ereignisse/{eventId}`
**Route:** `src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx`
**Layout:**
```
Breadcrumb: Akten > Az. 2024/001 > Verlauf > {event.title}
+---------------------------------------------------------+
| [Event Type Icon] {event.title} |
| 25. März 2026, 14:30 |
+---------------------------------------------------------+
| Beschreibung |
| Statusänderung: aktiv → geschlossen |
+---------------------------------------------------------+
| Metadaten |
| Erstellt von: max.mustermann@kanzlei.de |
| Typ: status_changed |
+---------------------------------------------------------+
| Notizen [+ Neu] |
| (keine Notizen) |
+---------------------------------------------------------+
```
**Data requirements:**
- `GET /api/case-events/{id}` — new endpoint
- Notes: Uses new `notes` table
**Backend additions:**
- New handler: `case_events.go` with `Get` method
- New service method: `CaseEventService.GetByID`
- Or extend existing case handler to include event fetching
### 2.4 Standalone Deadline Creation — `/fristen/neu`
**Route:** `src/app/(app)/fristen/neu/page.tsx`
**Layout:**
```
Breadcrumb: Fristen > Neue Frist
+---------------------------------------------------------+
| Neue Frist anlegen |
+---------------------------------------------------------+
| Akte*: [Dropdown: Aktenauswahl] |
| Bezeichnung*: [________________________] |
| Beschreibung: [________________________] |
| Fällig am*: [Datumsauswahl] |
| Warnung am: [Datumsauswahl] |
| Notizen: [Textarea] |
+---------------------------------------------------------+
| [Abbrechen] [Frist anlegen]|
+---------------------------------------------------------+
```
Reuses existing deadline creation logic but as a standalone page rather than requiring the user to first navigate to a case. Case is selected via dropdown.
### 2.5 Standalone Appointment Creation — `/termine/neu`
**Route:** `src/app/(app)/termine/neu/page.tsx`
Same pattern as deadline creation. Reuses AppointmentModal fields but as a full page form. Appointment can optionally be linked to a case.
### 2.6 Case Detail Tabs → URL Segments
**Current:** Tabs use `useState<TabKey>` — no URL change, no deep linking, no browser back.
**Proposed route structure:**
```
/cases/{id} → redirects to /cases/{id}/verlauf
/cases/{id}/verlauf → Timeline tab
/cases/{id}/fristen → Deadlines tab
/cases/{id}/dokumente → Documents tab
/cases/{id}/parteien → Parties tab
/cases/{id}/notizen → Notes tab (new)
```
**Implementation approach:**
Use Next.js nested layouts with a shared layout for the case header + tab bar:
```
src/app/(app)/cases/[id]/
layout.tsx # Case header + tab navigation
page.tsx # Redirect to ./verlauf
verlauf/page.tsx # Timeline
fristen/page.tsx # Deadlines
dokumente/page.tsx # Documents
parteien/page.tsx # Parties
notizen/page.tsx # Notes (new)
```
The `layout.tsx` fetches case data and renders the header + tab bar. Each child page renders its tab content. The active tab is determined by the current pathname.
**Benefits:**
- Deep linking: `/cases/abc123/fristen` opens directly to the deadlines tab
- Browser back button works between tabs
- Each tab can have its own loading state
- Bookmarkable
---
## Part 3: Notes System
### 3.1 Data Model
Notes are a polymorphic entity — they can be attached to cases, deadlines, appointments, or case events.
**New table: `kanzlai.notes`**
```sql
CREATE TABLE kanzlai.notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES kanzlai.tenants(id),
-- Polymorphic parent reference (exactly one must be set)
case_id UUID REFERENCES kanzlai.cases(id) ON DELETE CASCADE,
deadline_id UUID REFERENCES kanzlai.deadlines(id) ON DELETE CASCADE,
appointment_id UUID REFERENCES kanzlai.appointments(id) ON DELETE CASCADE,
case_event_id UUID REFERENCES kanzlai.case_events(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_by UUID, -- auth.users reference
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Ensure exactly one parent is set
CONSTRAINT notes_single_parent CHECK (
(CASE WHEN case_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN deadline_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN appointment_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN case_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1
)
);
-- Indexes for efficient lookup by parent
CREATE INDEX idx_notes_case ON kanzlai.notes(tenant_id, case_id) WHERE case_id IS NOT NULL;
CREATE INDEX idx_notes_deadline ON kanzlai.notes(tenant_id, deadline_id) WHERE deadline_id IS NOT NULL;
CREATE INDEX idx_notes_appointment ON kanzlai.notes(tenant_id, appointment_id) WHERE appointment_id IS NOT NULL;
CREATE INDEX idx_notes_case_event ON kanzlai.notes(tenant_id, case_event_id) WHERE case_event_id IS NOT NULL;
-- RLS
ALTER TABLE kanzlai.notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY notes_tenant_isolation ON kanzlai.notes
USING (tenant_id IN (
SELECT tenant_id FROM kanzlai.user_tenants WHERE user_id = auth.uid()
));
```
### 3.2 Why Polymorphic Table vs Separate Tables
**Considered alternatives:**
1. **Separate notes per entity** (case_notes, deadline_notes, etc.) — More tables, duplicated logic, harder to search across all notes.
2. **Generic `entity_type` + `entity_id` pattern** — Loses FK constraints, can't cascade delete, harder to query with joins.
3. **Polymorphic with nullable FKs** (chosen) — FK constraints maintained, cascade deletes work, partial indexes keep queries fast, single service/handler. The CHECK constraint ensures data integrity.
### 3.3 Backend Model & API
**Go model:**
```go
type Note struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"`
DeadlineID *uuid.UUID `db:"deadline_id" json:"deadline_id,omitempty"`
AppointmentID *uuid.UUID `db:"appointment_id" json:"appointment_id,omitempty"`
CaseEventID *uuid.UUID `db:"case_event_id" json:"case_event_id,omitempty"`
Content string `db:"content" json:"content"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
```
**API endpoints:**
```
GET /api/notes?case_id={id} # List notes for a case
GET /api/notes?deadline_id={id} # List notes for a deadline
GET /api/notes?appointment_id={id} # List notes for an appointment
GET /api/notes?case_event_id={id} # List notes for a case event
POST /api/notes # Create note (body includes parent ID)
PUT /api/notes/{id} # Update note content
DELETE /api/notes/{id} # Delete note
```
Single endpoint with query parameter filtering — simpler than nested routes, works uniformly across all parent types.
**Service methods:**
```go
type NoteService struct { db *sqlx.DB }
func (s *NoteService) ListByParent(ctx, tenantID, parentType, parentID) ([]Note, error)
func (s *NoteService) Create(ctx, tenantID, note) (*Note, error)
func (s *NoteService) Update(ctx, tenantID, noteID, content) (*Note, error)
func (s *NoteService) Delete(ctx, tenantID, noteID) error
```
### 3.4 Notes UI Component
Reusable `<NotesList>` component used on every detail page:
```
+------------------------------------------------------------+
| Notizen [+ Neu] |
+------------------------------------------------------------+
| m@kanzlei.de · 25. Mär 2026, 14:30 [X][E] |
| Fristverlängerung beim Gericht beantragt. |
+------------------------------------------------------------+
| m@kanzlei.de · 24. Mär 2026, 10:15 [X][E] |
| Mandant telefonisch über Sachstand informiert. |
+------------------------------------------------------------+
```
**Props:**
```typescript
interface NotesListProps {
parentType: "case" | "deadline" | "appointment" | "case_event";
parentId: string;
}
```
**Features:**
- Fetches notes via `GET /api/notes?{parentType}_id={parentId}`
- "Neu" button opens inline textarea (not a modal — faster for quick notes)
- Each note shows: author, timestamp, content, edit/delete buttons
- Edit is inline (textarea replaces content)
- Optimistic updates via react-query mutation + invalidation
- Empty state: "Keine Notizen vorhanden. Klicken Sie +, um eine Notiz hinzuzufügen."
### 3.5 Migration from `deadlines.notes` Field
The existing `deadlines.notes` text field should be migrated:
1. For each deadline with a non-null `notes` value, create a corresponding row in the `notes` table with `deadline_id` set
2. Drop the `deadlines.notes` column after migration
3. This can be a one-time SQL migration script
---
## Part 4: Breadcrumb Navigation
### 4.1 Breadcrumb Component
New shared component: `src/components/layout/Breadcrumb.tsx`
```typescript
interface BreadcrumbItem {
label: string;
href?: string; // omit for current page (last item)
}
function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
// Renders: Home > Parent > Current
// Each item with href is a Link, last item is plain text
}
```
**Placement:** At the top of every page, inside the main content area (not in the layout — different pages have different breadcrumbs).
### 4.2 Breadcrumb Patterns
| Page | Breadcrumb |
|------|-----------|
| Dashboard | Dashboard |
| Fristen | Dashboard > Fristen |
| Fristen Detail | Dashboard > Fristen > {title} |
| Fristen Neu | Dashboard > Fristen > Neue Frist |
| Termine | Dashboard > Termine |
| Termine Detail | Dashboard > Termine > {title} |
| Termine Neu | Dashboard > Termine > Neuer Termin |
| Akten | Dashboard > Akten |
| Akte Detail | Dashboard > Akten > {case_number} |
| Akte > Fristen | Dashboard > Akten > {case_number} > Fristen |
| Akte > Notizen | Dashboard > Akten > {case_number} > Notizen |
| Ereignis Detail | Dashboard > Akten > {case_number} > Verlauf > {title} |
| Einstellungen | Dashboard > Einstellungen |
| AI Analyse | Dashboard > AI Analyse |
---
## Part 5: Summary of Backend Changes
### New Endpoints
| Method | Path | Handler | Purpose |
|--------|------|---------|---------|
| GET | `/api/deadlines/{id}` | `deadlineH.Get` | Single deadline with case context |
| GET | `/api/appointments/{id}` | `apptH.Get` | Single appointment with case context |
| GET | `/api/case-events/{id}` | `eventH.Get` | Single case event |
| GET | `/api/notes` | `noteH.List` | List notes (filtered by parent) |
| POST | `/api/notes` | `noteH.Create` | Create note |
| PUT | `/api/notes/{id}` | `noteH.Update` | Update note |
| DELETE | `/api/notes/{id}` | `noteH.Delete` | Delete note |
### Modified Endpoints
| Endpoint | Change |
|----------|--------|
| `GET /api/dashboard` | Add `case_id`, `id` to recent_activity; add `case_id` to upcoming_deadlines query |
### New Files
| File | Purpose |
|------|---------|
| `backend/internal/models/note.go` | Note model |
| `backend/internal/services/note_service.go` | Note CRUD service |
| `backend/internal/handlers/notes.go` | Note HTTP handlers |
| `backend/internal/handlers/case_events.go` | Case event detail handler |
### Database Migration
1. Create `kanzlai.notes` table with polymorphic FK pattern
2. Migrate existing `deadlines.notes` data
3. Drop `deadlines.notes` column
---
## Part 6: Summary of Frontend Changes
### New Files
| File | Purpose |
|------|---------|
| `src/app/(app)/fristen/[id]/page.tsx` | Deadline detail page |
| `src/app/(app)/fristen/neu/page.tsx` | Standalone deadline creation |
| `src/app/(app)/termine/[id]/page.tsx` | Appointment detail page |
| `src/app/(app)/termine/neu/page.tsx` | Standalone appointment creation |
| `src/app/(app)/cases/[id]/layout.tsx` | Case detail shared layout (header + tabs) |
| `src/app/(app)/cases/[id]/verlauf/page.tsx` | Case timeline tab |
| `src/app/(app)/cases/[id]/fristen/page.tsx` | Case deadlines tab |
| `src/app/(app)/cases/[id]/dokumente/page.tsx` | Case documents tab |
| `src/app/(app)/cases/[id]/parteien/page.tsx` | Case parties tab |
| `src/app/(app)/cases/[id]/notizen/page.tsx` | Case notes tab (new) |
| `src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx` | Case event detail |
| `src/components/layout/Breadcrumb.tsx` | Reusable breadcrumb |
| `src/components/notes/NotesList.tsx` | Reusable notes list + inline creation |
| `src/components/dashboard/RecentActivityList.tsx` | Recent activity feed |
### Modified Files
| File | Change |
|------|--------|
| `src/components/dashboard/DeadlineTrafficLights.tsx` | Buttons → Links with navigation |
| `src/components/dashboard/CaseOverviewGrid.tsx` | Static metrics → clickable links |
| `src/components/dashboard/UpcomingTimeline.tsx` | Items → clickable with navigation |
| `src/components/dashboard/AISummaryCard.tsx` | Add refresh button |
| `src/components/dashboard/QuickActions.tsx` | Fix targets, swap CalDAV for Termin |
| `src/app/(app)/dashboard/page.tsx` | Wire navigation, add RecentActivity section |
| `src/app/(app)/fristen/page.tsx` | Read URL params for initial filter |
| `src/app/(app)/cases/page.tsx` | Read URL params for initial filter |
| `src/app/(app)/cases/[id]/page.tsx` | Refactor into layout + nested routes |
| `src/lib/types.ts` | Add Note, RecentActivity types; update UpcomingDeadline |
### Types to Add
```typescript
export interface Note {
id: string;
tenant_id: string;
case_id?: string;
deadline_id?: string;
appointment_id?: string;
case_event_id?: string;
content: string;
created_by?: string;
created_at: string;
updated_at: string;
}
export interface RecentActivity {
id: string;
event_type?: string;
title: string;
case_id: string;
case_number: string;
event_date?: string;
created_at: string;
}
```
---
## Part 7: Implementation Plan
Recommended order for a coder to implement:
### Phase A: Backend Foundation (can be done in parallel)
1. Create `notes` table migration + model + service + handler
2. Add `GET /api/deadlines/{id}` endpoint
3. Add `GET /api/appointments/{id}` endpoint
4. Add `GET /api/case-events/{id}` endpoint
5. Fix dashboard query to include `case_id` in upcoming deadlines and recent activity
### Phase B: Frontend — Dashboard Interactivity
1. Create `Breadcrumb` component
2. Make traffic light cards clickable (Links)
3. Make case overview grid clickable (Links)
4. Make timeline items clickable (Links)
5. Fix quick actions (swap CalDAV for Termin, update hrefs)
6. Add refresh button to AI Summary card
7. Add RecentActivityList component + wire to dashboard
### Phase C: Frontend — New Detail Pages
1. Deadline detail page (`/fristen/[id]`)
2. Appointment detail page (`/termine/[id]`)
3. Case event detail page (`/cases/[id]/ereignisse/[eventId]`)
4. Standalone deadline creation (`/fristen/neu`)
5. Standalone appointment creation (`/termine/neu`)
### Phase D: Frontend — Case Detail Refactor
1. Extract case header + tabs into layout.tsx
2. Create sub-route pages (verlauf, fristen, dokumente, parteien)
3. Add notes tab
4. Wire `NotesList` component into all detail pages
### Phase E: Polish
1. URL filter params on `/fristen` and `/cases` pages
2. Breadcrumbs on all pages
3. Mobile responsive testing
4. Migration of existing `deadlines.notes` data
---
## Appendix: What This Design Does NOT Cover
- Real AI-powered summary (currently rule-based — kept as-is with refresh button)
- Notification system (toast-based alerts for approaching deadlines)
- Audit log / change history per entity
- Batch operations (mark multiple deadlines complete)
- Print views
These are separate features that can be designed independently.

View File

@@ -37,7 +37,7 @@ test-backend:
cd backend && go test ./...
test-frontend:
@echo "No frontend tests configured yet"
cd frontend && bun run test
# Clean
clean:

1321
ROADMAP.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,69 @@
package main
import (
"fmt"
"log"
"log/slog"
"net/http"
"os"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/logging"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/router"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
logging.Setup()
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "ok")
})
database, err := db.Connect(cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer database.Close()
log.Printf("Starting KanzlAI API server on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
// Optional: connect to youpc.org database for similar case finder
var youpcDB *sqlx.DB
if cfg.YouPCDatabaseURL != "" {
youpcDB, err = sqlx.Connect("postgres", cfg.YouPCDatabaseURL)
if err != nil {
slog.Warn("failed to connect to youpc.org database — similar case finder disabled", "error", err)
youpcDB = nil
} else {
youpcDB.SetMaxOpenConns(5)
youpcDB.SetMaxIdleConns(2)
defer youpcDB.Close()
slog.Info("connected to youpc.org database for similar case finder")
}
}
// Start CalDAV sync service
calDAVSvc := services.NewCalDAVService(database)
calDAVSvc.Start()
defer calDAVSvc.Stop()
// Start notification reminder service
notifSvc := services.NewNotificationService(database)
notifSvc.Start()
defer notifSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc, youpcDB)
slog.Info("starting KanzlAI API server", "port", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}

View File

@@ -1,3 +1,22 @@
module mgit.msbls.de/m/KanzlAI-mGMT
go 1.25.5
require (
github.com/anthropics/anthropic-sdk-go v1.27.1
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
github.com/emersion/go-webdav v0.7.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.0
)
require (
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/sync v0.16.0 // indirect
)

49
backend/go.sum Normal file
View File

@@ -0,0 +1,49 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/anthropics/anthropic-sdk-go v1.27.1 h1:7DgMZ2Ng3C2mPzJGHA30NXQTZolcF07mHd0tGaLwfzk=
github.com/anthropics/anthropic-sdk-go v1.27.1/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,64 @@
package auth
import (
"context"
"github.com/google/uuid"
)
type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
userRoleKey contextKey = "user_role"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
func ContextWithTenantID(ctx context.Context, tenantID uuid.UUID) context.Context {
return context.WithValue(ctx, tenantIDKey, tenantID)
}
func UserFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDKey).(uuid.UUID)
return id, ok
}
func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}
func ContextWithUserRole(ctx context.Context, role string) context.Context {
return context.WithValue(ctx, userRoleKey, role)
}
func UserRoleFromContext(ctx context.Context) string {
role, _ := ctx.Value(userRoleKey).(string)
return role
}
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
ctx = context.WithValue(ctx, userAgentKey, userAgent)
return ctx
}
func IPFromContext(ctx context.Context) *string {
if v, ok := ctx.Value(ipKey).(string); ok && v != "" {
return &v
}
return nil
}
func UserAgentFromContext(ctx context.Context) *string {
if v, ok := ctx.Value(userAgentKey).(string); ok && v != "" {
return &v
}
return nil
}

View File

@@ -0,0 +1,101 @@
package auth
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Middleware struct {
jwtSecret []byte
db *sqlx.DB
}
func NewMiddleware(jwtSecret string, db *sqlx.DB) *Middleware {
return &Middleware{jwtSecret: []byte(jwtSecret), db: db}
}
func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
http.Error(w, `{"error":"missing authorization token"}`, http.StatusUnauthorized)
return
}
userID, err := m.verifyJWT(token)
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
ctx := ContextWithUserID(r.Context(), userID)
// Capture IP and user-agent for audit logging
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent())
// Tenant resolution is handled by TenantResolver middleware for scoped routes.
// Tenant management routes handle their own access control.
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *Middleware) verifyJWT(tokenStr string) (uuid.UUID, error) {
parsedToken, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.jwtSecret, nil
})
if err != nil {
return uuid.Nil, fmt.Errorf("parsing JWT: %w", err)
}
if !parsedToken.Valid {
return uuid.Nil, fmt.Errorf("invalid JWT token")
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return uuid.Nil, fmt.Errorf("extracting JWT claims")
}
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
return uuid.Nil, fmt.Errorf("JWT token has expired")
}
}
sub, ok := claims["sub"].(string)
if !ok {
return uuid.Nil, fmt.Errorf("missing sub claim in JWT")
}
userID, err := uuid.Parse(sub)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid user ID format: %w", err)
}
return userID, nil
}
func extractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return parts[1]
}

View File

@@ -0,0 +1,213 @@
package auth
import (
"context"
"net/http"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// Valid roles ordered by privilege level (highest first).
var ValidRoles = []string{"owner", "partner", "associate", "paralegal", "secretary"}
// IsValidRole checks if a role string is one of the defined roles.
func IsValidRole(role string) bool {
for _, r := range ValidRoles {
if r == role {
return true
}
}
return false
}
// Permission represents an action that can be checked against roles.
type Permission int
const (
PermManageTeam Permission = iota
PermManageBilling
PermCreateCase
PermEditAllCases
PermEditAssignedCase
PermViewAllCases
PermManageDeadlines
PermManageAppointments
PermUploadDocuments
PermDeleteDocuments
PermDeleteOwnDocuments
PermViewAuditLog
PermManageSettings
PermAIExtraction
)
// rolePermissions maps each role to its set of permissions.
var rolePermissions = map[string]map[Permission]bool{
"owner": {
PermManageTeam: true,
PermManageBilling: true,
PermCreateCase: true,
PermEditAllCases: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteDocuments: true,
PermDeleteOwnDocuments: true,
PermViewAuditLog: true,
PermManageSettings: true,
PermAIExtraction: true,
},
"partner": {
PermManageTeam: true,
PermManageBilling: true,
PermCreateCase: true,
PermEditAllCases: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteDocuments: true,
PermDeleteOwnDocuments: true,
PermViewAuditLog: true,
PermManageSettings: true,
PermAIExtraction: true,
},
"associate": {
PermCreateCase: true,
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
PermDeleteOwnDocuments: true,
PermAIExtraction: true,
},
"paralegal": {
PermEditAssignedCase: true,
PermViewAllCases: true,
PermManageDeadlines: true,
PermManageAppointments: true,
PermUploadDocuments: true,
},
"secretary": {
PermViewAllCases: true,
PermManageAppointments: true,
PermUploadDocuments: true,
},
}
// HasPermission checks if the given role has the specified permission.
func HasPermission(role string, perm Permission) bool {
perms, ok := rolePermissions[role]
if !ok {
return false
}
return perms[perm]
}
// RequirePermission returns middleware that checks if the user's role has the given permission.
func RequirePermission(perm Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := UserRoleFromContext(r.Context())
if role == "" || !HasPermission(role, perm) {
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireRole returns middleware that checks if the user has one of the specified roles.
func RequireRole(roles ...string) func(http.Handler) http.Handler {
allowed := make(map[string]bool, len(roles))
for _, r := range roles {
allowed[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := UserRoleFromContext(r.Context())
if !allowed[role] {
writeJSONError(w, "insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// IsAssignedToCase checks if a user is assigned to a specific case.
func IsAssignedToCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID) (bool, error) {
var exists bool
err := db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM case_assignments WHERE user_id = $1 AND case_id = $2)`,
userID, caseID)
return exists, err
}
// CanEditCase checks if a user can edit a specific case based on role and assignment.
func CanEditCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID, role string) (bool, error) {
// Owner and partner can edit all cases
if HasPermission(role, PermEditAllCases) {
return true, nil
}
// Others need to be assigned
if !HasPermission(role, PermEditAssignedCase) {
return false, nil
}
return IsAssignedToCase(ctx, db, userID, caseID)
}
// CanDeleteDocument checks if a user can delete a specific document.
func CanDeleteDocument(role string, docUploaderID, userID uuid.UUID) bool {
if HasPermission(role, PermDeleteDocuments) {
return true
}
if HasPermission(role, PermDeleteOwnDocuments) {
return docUploaderID == userID
}
return false
}
// permissionNames maps Permission constants to their string names for frontend use.
var permissionNames = map[Permission]string{
PermManageTeam: "manage_team",
PermManageBilling: "manage_billing",
PermCreateCase: "create_case",
PermEditAllCases: "edit_all_cases",
PermEditAssignedCase: "edit_assigned_case",
PermViewAllCases: "view_all_cases",
PermManageDeadlines: "manage_deadlines",
PermManageAppointments: "manage_appointments",
PermUploadDocuments: "upload_documents",
PermDeleteDocuments: "delete_documents",
PermDeleteOwnDocuments: "delete_own_documents",
PermViewAuditLog: "view_audit_log",
PermManageSettings: "manage_settings",
PermAIExtraction: "ai_extraction",
}
// GetRolePermissions returns a list of permission name strings for the given role.
func GetRolePermissions(role string) []string {
perms, ok := rolePermissions[role]
if !ok {
return nil
}
var names []string
for p := range perms {
if name, ok := permissionNames[p]; ok {
names = append(names, name)
}
}
return names
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write([]byte(`{"error":"` + msg + `"}`))
}

View File

@@ -0,0 +1,78 @@
package auth
import (
"context"
"log/slog"
"net/http"
"github.com/google/uuid"
)
// TenantLookup resolves and verifies tenant access for a user.
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
// or defaults to the user's first tenant. Always verifies user has access.
type TenantResolver struct {
lookup TenantLookup
}
func NewTenantResolver(lookup TenantLookup) *TenantResolver {
return &TenantResolver{lookup: lookup}
}
func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var tenantID uuid.UUID
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
if err != nil {
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return
}
// Verify user has access and get their role
role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
if err != nil {
slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if role == "" {
http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden)
return
}
tenantID = parsed
r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else {
// Default to user's first tenant (role already set by middleware)
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil {
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if first == nil {
http.Error(w, `{"error":"no tenant found for user"}`, http.StatusBadRequest)
return
}
tenantID = *first
}
ctx := ContextWithTenantID(r.Context(), tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,162 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
type mockTenantLookup struct {
tenantID *uuid.UUID
err error
hasAccess bool
accessErr error
role string
noAccess bool // when true, GetUserRole returns ""
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err
}
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr
}
func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
if m.noAccess {
return "", m.err
}
if m.role != "" {
return m.role, m.err
}
return "associate", m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{role: "partner", hasAccess: true})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := TenantFromContext(r.Context())
if !ok {
t.Fatal("tenant ID not in context")
}
gotTenantID = id
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", tenantID.String())
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
}
func TestTenantResolver_FromHeader_NoAccess(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{noAccess: true})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", tenantID.String())
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, _ := TenantFromContext(r.Context())
gotTenantID = id
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
}
func TestTenantResolver_NoUser(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestTenantResolver_InvalidHeader(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", "not-a-uuid")
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestTenantResolver_NoTenantForUser(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{tenantID: nil})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}

View File

@@ -0,0 +1,48 @@
package config
import (
"fmt"
"os"
)
type Config struct {
Port string
DatabaseURL string
SupabaseURL string
SupabaseAnonKey string
SupabaseServiceKey string
SupabaseJWTSecret string
AnthropicAPIKey string
FrontendOrigin string
YouPCDatabaseURL string // read-only connection to youpc.org Supabase for similar case finder
}
func Load() (*Config, error) {
cfg := &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
SupabaseURL: os.Getenv("SUPABASE_URL"),
SupabaseAnonKey: os.Getenv("SUPABASE_ANON_KEY"),
SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"),
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
YouPCDatabaseURL: os.Getenv("YOUPC_DATABASE_URL"),
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
if cfg.SupabaseJWTSecret == "" {
return nil, fmt.Errorf("SUPABASE_JWT_SECRET is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,26 @@
package db
import (
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func Connect(databaseURL string) (*sqlx.DB, error) {
db, err := sqlx.Connect("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("connecting to database: %w", err)
}
// Set search_path so queries use kanzlai schema by default
if _, err := db.Exec("SET search_path TO kanzlai, public"); err != nil {
db.Close()
return nil, fmt.Errorf("setting search_path: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
return db, nil
}

View File

@@ -0,0 +1,255 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AIHandler struct {
ai *services.AIService
}
func NewAIHandler(ai *services.AIService) *AIHandler {
return &AIHandler{ai: ai}
}
// ExtractDeadlines handles POST /api/ai/extract-deadlines
// Accepts either multipart/form-data with a "file" PDF field, or JSON {"text": "..."}.
func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
var pdfData []byte
var text string
// Check if multipart (PDF upload)
if len(contentType) >= 9 && contentType[:9] == "multipart" {
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
writeError(w, http.StatusBadRequest, "failed to parse multipart form")
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing 'file' field in multipart form")
return
}
defer file.Close()
pdfData, err = io.ReadAll(file)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read uploaded file")
return
}
} else {
// Assume JSON body
var body struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
text = body.Text
}
if len(pdfData) == 0 && text == "" {
writeError(w, http.StatusBadRequest, "provide either a PDF file or text")
return
}
if len(text) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "text exceeds maximum length")
return
}
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
if err != nil {
internalError(w, "AI deadline extraction failed", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deadlines": deadlines,
"count": len(deadlines),
})
}
// SummarizeCase handles POST /api/ai/summarize-case
// Accepts JSON {"case_id": "uuid"}.
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var body struct {
CaseID string `json:"case_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" {
writeError(w, http.StatusBadRequest, "case_id is required")
return
}
caseID, err := parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "AI case summarization failed", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"case_id": caseID.String(),
"summary": summary,
})
}
// DraftDocument handles POST /api/ai/draft-document
// Accepts JSON {"case_id": "uuid", "template_type": "string", "instructions": "string", "language": "de|en|fr"}.
func (h *AIHandler) DraftDocument(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var body struct {
CaseID string `json:"case_id"`
TemplateType string `json:"template_type"`
Instructions string `json:"instructions"`
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" {
writeError(w, http.StatusBadRequest, "case_id is required")
return
}
if body.TemplateType == "" {
writeError(w, http.StatusBadRequest, "template_type is required")
return
}
caseID, err := parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
if len(body.Instructions) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "instructions exceeds maximum length")
return
}
draft, err := h.ai.DraftDocument(r.Context(), tenantID, caseID, body.TemplateType, body.Instructions, body.Language)
if err != nil {
internalError(w, "AI document drafting failed", err)
return
}
writeJSON(w, http.StatusOK, draft)
}
// CaseStrategy handles POST /api/ai/case-strategy
// Accepts JSON {"case_id": "uuid"}.
func (h *AIHandler) CaseStrategy(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var body struct {
CaseID string `json:"case_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" {
writeError(w, http.StatusBadRequest, "case_id is required")
return
}
caseID, err := parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
strategy, err := h.ai.CaseStrategy(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "AI case strategy analysis failed", err)
return
}
writeJSON(w, http.StatusOK, strategy)
}
// SimilarCases handles POST /api/ai/similar-cases
// Accepts JSON {"case_id": "uuid", "description": "string"}.
func (h *AIHandler) SimilarCases(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
var body struct {
CaseID string `json:"case_id"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" && body.Description == "" {
writeError(w, http.StatusBadRequest, "either case_id or description is required")
return
}
if len(body.Description) > maxDescriptionLen {
writeError(w, http.StatusBadRequest, "description exceeds maximum length")
return
}
var caseID uuid.UUID
if body.CaseID != "" {
var err error
caseID, err = parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
}
cases, err := h.ai.FindSimilarCases(r.Context(), tenantID, caseID, body.Description)
if err != nil {
internalError(w, "AI similar case search failed", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"cases": cases,
"count": len(cases),
})
}

View File

@@ -0,0 +1,74 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestAIExtractDeadlines_EmptyInput(t *testing.T) {
h := &AIHandler{}
body := `{"text":""}`
r := httptest.NewRequest("POST", "/api/ai/extract-deadlines", bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ExtractDeadlines(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "provide either a PDF file or text" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestAIExtractDeadlines_InvalidJSON(t *testing.T) {
h := &AIHandler{}
r := httptest.NewRequest("POST", "/api/ai/extract-deadlines", bytes.NewBufferString(`{broken`))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ExtractDeadlines(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAISummarizeCase_MissingTenant(t *testing.T) {
h := &AIHandler{}
body := `{"case_id":""}`
r := httptest.NewRequest("POST", "/api/ai/summarize-case", bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.SummarizeCase(w, r)
// Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestAISummarizeCase_InvalidJSON(t *testing.T) {
h := &AIHandler{}
r := httptest.NewRequest("POST", "/api/ai/summarize-case", bytes.NewBufferString(`not-json`))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.SummarizeCase(w, r)
// Without tenant context, TenantFromContext returns !ok → 403
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}

View File

@@ -0,0 +1,196 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
func TestAppointmentCreate_NoTenant(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
h.Create(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestAppointmentCreate_MissingTitle(t *testing.T) {
h := &AppointmentHandler{}
body := `{"start_at":"2026-04-01T10:00:00Z"}`
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(body))
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Create(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "title is required" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestAppointmentCreate_MissingStartAt(t *testing.T) {
h := &AppointmentHandler{}
body := `{"title":"Test Appointment"}`
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(body))
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Create(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "start_at is required" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestAppointmentCreate_InvalidJSON(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(`{broken`))
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Create(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAppointmentList_NoTenant(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("GET", "/api/appointments", nil)
w := httptest.NewRecorder()
h.List(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestAppointmentUpdate_NoTenant(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("PUT", "/api/appointments/"+uuid.New().String(), bytes.NewBufferString(`{}`))
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.Update(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestAppointmentUpdate_InvalidID(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("PUT", "/api/appointments/not-uuid", bytes.NewBufferString(`{}`))
r.SetPathValue("id", "not-uuid")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Update(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAppointmentDelete_NoTenant(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("DELETE", "/api/appointments/"+uuid.New().String(), nil)
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.Delete(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestAppointmentDelete_InvalidID(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("DELETE", "/api/appointments/bad", nil)
r.SetPathValue("id", "bad")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Delete(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAppointmentList_InvalidCaseID(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("GET", "/api/appointments?case_id=bad", nil)
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.List(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAppointmentList_InvalidStartFrom(t *testing.T) {
h := &AppointmentHandler{}
r := httptest.NewRequest("GET", "/api/appointments?start_from=not-a-date", nil)
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.List(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}

View File

@@ -0,0 +1,240 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AppointmentHandler struct {
svc *services.AppointmentService
}
func NewAppointmentHandler(svc *services.AppointmentService) *AppointmentHandler {
return &AppointmentHandler{svc: svc}
}
// Get handles GET /api/appointments/{id}
func (h *AppointmentHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid appointment id")
return
}
appt, err := h.svc.GetByID(r.Context(), tenantID, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "appointment not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch appointment")
return
}
writeJSON(w, http.StatusOK, appt)
}
func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
filter := services.AppointmentFilter{}
if v := r.URL.Query().Get("case_id"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
filter.CaseID = &id
}
if v := r.URL.Query().Get("type"); v != "" {
filter.Type = &v
}
if v := r.URL.Query().Get("start_from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_from (use RFC3339)")
return
}
filter.StartFrom = &t
}
if v := r.URL.Query().Get("start_to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_to (use RFC3339)")
return
}
filter.StartTo = &t
}
appointments, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list appointments")
return
}
writeJSON(w, http.StatusOK, appointments)
}
type createAppointmentRequest struct {
CaseID *uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
Location *string `json:"location"`
AppointmentType *string `json:"appointment_type"`
}
func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
var req createAppointmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
return
}
appt := &models.Appointment{
TenantID: tenantID,
CaseID: req.CaseID,
Title: req.Title,
Description: req.Description,
StartAt: req.StartAt,
EndAt: req.EndAt,
Location: req.Location,
AppointmentType: req.AppointmentType,
}
if err := h.svc.Create(r.Context(), appt); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create appointment")
return
}
writeJSON(w, http.StatusCreated, appt)
}
type updateAppointmentRequest struct {
CaseID *uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
Location *string `json:"location"`
AppointmentType *string `json:"appointment_type"`
}
func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid appointment id")
return
}
// Fetch existing to verify ownership
existing, err := h.svc.GetByID(r.Context(), tenantID, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "appointment not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch appointment")
return
}
var req updateAppointmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if msg := validateStringLength("title", req.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
return
}
existing.CaseID = req.CaseID
existing.Title = req.Title
existing.Description = req.Description
existing.StartAt = req.StartAt
existing.EndAt = req.EndAt
existing.Location = req.Location
existing.AppointmentType = req.AppointmentType
if err := h.svc.Update(r.Context(), existing); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update appointment")
return
}
writeJSON(w, http.StatusOK, existing)
}
func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid appointment id")
return
}
if err := h.svc.Delete(r.Context(), tenantID, id); err != nil {
writeError(w, http.StatusNotFound, "appointment not found")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,63 @@
package handlers
import (
"net/http"
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AuditLogHandler struct {
svc *services.AuditService
}
func NewAuditLogHandler(svc *services.AuditService) *AuditLogHandler {
return &AuditLogHandler{svc: svc}
}
func (h *AuditLogHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
limit, _ := strconv.Atoi(q.Get("limit"))
filter := services.AuditFilter{
EntityType: q.Get("entity_type"),
From: q.Get("from"),
To: q.Get("to"),
Page: page,
Limit: limit,
}
if idStr := q.Get("entity_id"); idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
filter.EntityID = &id
}
}
if idStr := q.Get("user_id"); idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
filter.UserID = &id
}
}
entries, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch audit log")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"entries": entries,
"total": total,
"page": filter.Page,
"limit": filter.Limit,
})
}

View File

@@ -0,0 +1,89 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// CalculateHandlers holds handlers for deadline calculation endpoints
type CalculateHandlers struct {
calculator *services.DeadlineCalculator
rules *services.DeadlineRuleService
}
// NewCalculateHandlers creates calculate handlers
func NewCalculateHandlers(calc *services.DeadlineCalculator, rules *services.DeadlineRuleService) *CalculateHandlers {
return &CalculateHandlers{calculator: calc, rules: rules}
}
// CalculateRequest is the input for POST /api/deadlines/calculate
type CalculateRequest struct {
ProceedingType string `json:"proceeding_type"`
TriggerEventDate string `json:"trigger_event_date"`
SelectedRuleIDs []string `json:"selected_rule_ids,omitempty"`
}
// Calculate handles POST /api/deadlines/calculate
func (h *CalculateHandlers) Calculate(w http.ResponseWriter, r *http.Request) {
var req CalculateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ProceedingType == "" || req.TriggerEventDate == "" {
writeError(w, http.StatusBadRequest, "proceeding_type and trigger_event_date are required")
return
}
eventDate, err := time.Parse("2006-01-02", req.TriggerEventDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid trigger_event_date format, expected YYYY-MM-DD")
return
}
var results []services.CalculatedDeadline
if len(req.SelectedRuleIDs) > 0 {
ruleModels, err := h.rules.GetByIDs(req.SelectedRuleIDs)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch selected rules")
return
}
results = h.calculator.CalculateFromRules(eventDate, ruleModels)
} else {
tree, err := h.rules.GetRuleTree(req.ProceedingType)
if err != nil {
writeError(w, http.StatusBadRequest, "unknown proceeding type")
return
}
// Flatten tree to get all rule models
var flatNodes []services.RuleTreeNode
flattenTree(tree, &flatNodes)
ruleModels := make([]models.DeadlineRule, 0, len(flatNodes))
for _, node := range flatNodes {
ruleModels = append(ruleModels, node.DeadlineRule)
}
results = h.calculator.CalculateFromRules(eventDate, ruleModels)
}
writeJSON(w, http.StatusOK, map[string]any{
"proceeding_type": req.ProceedingType,
"trigger_event_date": req.TriggerEventDate,
"deadlines": results,
})
}
func flattenTree(nodes []services.RuleTreeNode, result *[]services.RuleTreeNode) {
for _, n := range nodes {
*result = append(*result, n)
if len(n.Children) > 0 {
flattenTree(n.Children, result)
}
}
}

View File

@@ -0,0 +1,83 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCalculate_MissingFields(t *testing.T) {
h := &CalculateHandlers{}
tests := []struct {
name string
body string
want string
}{
{
name: "empty body",
body: `{}`,
want: "proceeding_type and trigger_event_date are required",
},
{
name: "missing trigger_event_date",
body: `{"proceeding_type":"INF"}`,
want: "proceeding_type and trigger_event_date are required",
},
{
name: "missing proceeding_type",
body: `{"trigger_event_date":"2026-06-01"}`,
want: "proceeding_type and trigger_event_date are required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/api/deadlines/calculate", bytes.NewBufferString(tt.body))
w := httptest.NewRecorder()
h.Calculate(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != tt.want {
t.Errorf("expected error %q, got %q", tt.want, resp["error"])
}
})
}
}
func TestCalculate_InvalidDateFormat(t *testing.T) {
h := &CalculateHandlers{}
body := `{"proceeding_type":"INF","trigger_event_date":"01-06-2026"}`
r := httptest.NewRequest("POST", "/api/deadlines/calculate", bytes.NewBufferString(body))
w := httptest.NewRecorder()
h.Calculate(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "invalid trigger_event_date format, expected YYYY-MM-DD" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestCalculate_InvalidJSON(t *testing.T) {
h := &CalculateHandlers{}
r := httptest.NewRequest("POST", "/api/deadlines/calculate", bytes.NewBufferString(`not-json`))
w := httptest.NewRecorder()
h.Calculate(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}

View File

@@ -0,0 +1,68 @@
package handlers
import (
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// CalDAVHandler handles CalDAV sync HTTP endpoints.
type CalDAVHandler struct {
svc *services.CalDAVService
}
// NewCalDAVHandler creates a new CalDAV handler.
func NewCalDAVHandler(svc *services.CalDAVService) *CalDAVHandler {
return &CalDAVHandler{svc: svc}
}
// TriggerSync handles POST /api/caldav/sync — triggers a full sync for the current tenant.
func (h *CalDAVHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "no tenant context")
return
}
cfg, err := h.svc.LoadTenantConfig(tenantID)
if err != nil {
writeError(w, http.StatusBadRequest, "CalDAV not configured for this tenant")
return
}
status, err := h.svc.SyncTenant(r.Context(), tenantID, *cfg)
if err != nil {
// Still return the status — it contains partial results + error info
writeJSON(w, http.StatusOK, map[string]any{
"status": "completed_with_errors",
"sync": status,
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"sync": status,
})
}
// GetStatus handles GET /api/caldav/status — returns last sync status.
func (h *CalDAVHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "no tenant context")
return
}
status := h.svc.GetStatus(tenantID)
if status == nil {
writeJSON(w, http.StatusOK, map[string]any{
"status": "no_sync_yet",
"last_sync_at": nil,
})
return
}
writeJSON(w, http.StatusOK, status)
}

View File

@@ -0,0 +1,119 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type CaseAssignmentHandler struct {
svc *services.CaseAssignmentService
}
func NewCaseAssignmentHandler(svc *services.CaseAssignmentService) *CaseAssignmentHandler {
return &CaseAssignmentHandler{svc: svc}
}
// List handles GET /api/cases/{id}/assignments
func (h *CaseAssignmentHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
assignments, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"assignments": assignments,
"total": len(assignments),
})
}
// Assign handles POST /api/cases/{id}/assignments
func (h *CaseAssignmentHandler) Assign(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var req struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
if req.Role == "" {
req.Role = "team"
}
if req.Role != "lead" && req.Role != "team" && req.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be lead, team, or viewer")
return
}
assignment, err := h.svc.Assign(r.Context(), tenantID, caseID, userID, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, assignment)
}
// Unassign handles DELETE /api/cases/{id}/assignments/{uid}
func (h *CaseAssignmentHandler) Unassign(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
userID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid user ID")
return
}
if err := h.svc.Unassign(r.Context(), tenantID, caseID, userID); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}

View File

@@ -0,0 +1,52 @@
package handlers
import (
"database/sql"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"github.com/jmoiron/sqlx"
)
type CaseEventHandler struct {
db *sqlx.DB
}
func NewCaseEventHandler(db *sqlx.DB) *CaseEventHandler {
return &CaseEventHandler{db: db}
}
// Get handles GET /api/case-events/{id}
func (h *CaseEventHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
eventID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid event ID")
return
}
var event models.CaseEvent
err = h.db.GetContext(r.Context(), &event,
`SELECT id, tenant_id, case_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at
FROM case_events
WHERE id = $1 AND tenant_id = $2`, eventID, tenantID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "case event not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch case event")
return
}
writeJSON(w, http.StatusOK, event)
}

View File

@@ -0,0 +1,177 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
func TestCaseCreate_NoAuth(t *testing.T) {
h := &CaseHandler{}
r := httptest.NewRequest("POST", "/api/cases", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
h.Create(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestCaseCreate_MissingFields(t *testing.T) {
h := &CaseHandler{}
body := `{"case_number":"","title":""}`
r := httptest.NewRequest("POST", "/api/cases", bytes.NewBufferString(body))
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Create(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "case_number and title are required" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestCaseCreate_InvalidJSON(t *testing.T) {
h := &CaseHandler{}
r := httptest.NewRequest("POST", "/api/cases", bytes.NewBufferString(`not-json`))
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Create(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestCaseGet_InvalidID(t *testing.T) {
h := &CaseHandler{}
r := httptest.NewRequest("GET", "/api/cases/not-a-uuid", nil)
r.SetPathValue("id", "not-a-uuid")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Get(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestCaseGet_NoTenant(t *testing.T) {
h := &CaseHandler{}
r := httptest.NewRequest("GET", "/api/cases/"+uuid.New().String(), nil)
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.Get(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestCaseList_NoTenant(t *testing.T) {
h := &CaseHandler{}
r := httptest.NewRequest("GET", "/api/cases", nil)
w := httptest.NewRecorder()
h.List(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestCaseUpdate_InvalidID(t *testing.T) {
h := &CaseHandler{}
body := `{"title":"Updated"}`
r := httptest.NewRequest("PUT", "/api/cases/bad-id", bytes.NewBufferString(body))
r.SetPathValue("id", "bad-id")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Update(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestCaseUpdate_InvalidJSON(t *testing.T) {
h := &CaseHandler{}
caseID := uuid.New().String()
r := httptest.NewRequest("PUT", "/api/cases/"+caseID, bytes.NewBufferString(`{bad`))
r.SetPathValue("id", caseID)
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Update(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestCaseDelete_NoTenant(t *testing.T) {
h := &CaseHandler{}
r := httptest.NewRequest("DELETE", "/api/cases/"+uuid.New().String(), nil)
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.Delete(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestCaseDelete_InvalidID(t *testing.T) {
h := &CaseHandler{}
r := httptest.NewRequest("DELETE", "/api/cases/bad-id", nil)
r.SetPathValue("id", "bad-id")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Delete(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}

View File

@@ -0,0 +1,185 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
type CaseHandler struct {
svc *services.CaseService
}
func NewCaseHandler(svc *services.CaseService) *CaseHandler {
return &CaseHandler{svc: svc}
}
func (h *CaseHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, offset = clampPagination(limit, offset)
search := r.URL.Query().Get("search")
if msg := validateStringLength("search", search, maxSearchLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
filter := services.CaseFilter{
Status: r.URL.Query().Get("status"),
Type: r.URL.Query().Get("type"),
Search: search,
Limit: limit,
Offset: offset,
}
cases, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
internalError(w, "failed to list cases", err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"cases": cases,
"total": total,
})
}
func (h *CaseHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
var input services.CreateCaseInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.CaseNumber == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "case_number and title are required")
return
}
if msg := validateStringLength("case_number", input.CaseNumber, maxCaseNumberLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
c, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil {
internalError(w, "failed to create case", err)
return
}
writeJSON(w, http.StatusCreated, c)
}
func (h *CaseHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "failed to get case", err)
return
}
if detail == nil {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeJSON(w, http.StatusOK, detail)
}
func (h *CaseHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.UpdateCaseInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.Title != nil {
if msg := validateStringLength("title", *input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
if input.CaseNumber != nil {
if msg := validateStringLength("case_number", *input.CaseNumber, maxCaseNumberLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
}
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
if err != nil {
internalError(w, "failed to update case", err)
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *CaseHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, caseID, userID); err != nil {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "archived"})
}

View File

@@ -0,0 +1,32 @@
package handlers
import (
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type DashboardHandler struct {
svc *services.DashboardService
}
func NewDashboardHandler(svc *services.DashboardService) *DashboardHandler {
return &DashboardHandler{svc: svc}
}
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
data, err := h.svc.Get(r.Context(), tenantID)
if err != nil {
internalError(w, "failed to load dashboard", err)
return
}
writeJSON(w, http.StatusOK, data)
}

View File

@@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestDashboardGet_NoTenant(t *testing.T) {
h := &DashboardHandler{}
r := httptest.NewRequest("GET", "/api/dashboard", nil)
w := httptest.NewRecorder()
h.Get(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}

View File

@@ -0,0 +1,69 @@
package handlers
import (
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DeadlineRuleHandlers holds handlers for deadline rule endpoints
type DeadlineRuleHandlers struct {
rules *services.DeadlineRuleService
}
// NewDeadlineRuleHandlers creates deadline rule handlers
func NewDeadlineRuleHandlers(rs *services.DeadlineRuleService) *DeadlineRuleHandlers {
return &DeadlineRuleHandlers{rules: rs}
}
// List handles GET /api/deadline-rules
// Query params: proceeding_type_id (optional int filter)
func (h *DeadlineRuleHandlers) List(w http.ResponseWriter, r *http.Request) {
var proceedingTypeID *int
if v := r.URL.Query().Get("proceeding_type_id"); v != "" {
id, err := strconv.Atoi(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid proceeding_type_id")
return
}
proceedingTypeID = &id
}
rules, err := h.rules.List(proceedingTypeID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadline rules")
return
}
writeJSON(w, http.StatusOK, rules)
}
// ListProceedingTypes handles GET /api/proceeding-types
func (h *DeadlineRuleHandlers) ListProceedingTypes(w http.ResponseWriter, r *http.Request) {
types, err := h.rules.ListProceedingTypes()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list proceeding types")
return
}
writeJSON(w, http.StatusOK, types)
}
// GetRuleTree handles GET /api/deadline-rules/{type}
// {type} is the proceeding type code (e.g., "INF", "REV")
func (h *DeadlineRuleHandlers) GetRuleTree(w http.ResponseWriter, r *http.Request) {
typeCode := r.PathValue("type")
if typeCode == "" {
writeError(w, http.StatusBadRequest, "proceeding type code required")
return
}
tree, err := h.rules.GetRuleTree(typeCode)
if err != nil {
writeError(w, http.StatusNotFound, "proceeding type not found")
return
}
writeJSON(w, http.StatusOK, tree)
}

View File

@@ -0,0 +1,207 @@
package handlers
import (
"encoding/json"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DeadlineHandlers holds handlers for deadline CRUD endpoints
type DeadlineHandlers struct {
deadlines *services.DeadlineService
}
// NewDeadlineHandlers creates deadline handlers
func NewDeadlineHandlers(ds *services.DeadlineService) *DeadlineHandlers {
return &DeadlineHandlers{deadlines: ds}
}
// Get handles GET /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
deadline, err := h.deadlines.GetByID(tenantID, deadlineID)
if err != nil {
internalError(w, "failed to fetch deadline", err)
return
}
if deadline == nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, deadline)
}
// ListAll handles GET /api/deadlines
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
deadlines, err := h.deadlines.ListAll(tenantID)
if err != nil {
internalError(w, "failed to list deadlines", err)
return
}
writeJSON(w, http.StatusOK, deadlines)
}
// ListForCase handles GET /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
if err != nil {
internalError(w, "failed to list deadlines for case", err)
return
}
writeJSON(w, http.StatusOK, deadlines)
}
// Create handles POST /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.CreateDeadlineInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
input.CaseID = caseID
if input.Title == "" || input.DueDate == "" {
writeError(w, http.StatusBadRequest, "title and due_date are required")
return
}
if msg := validateStringLength("title", input.Title, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
deadline, err := h.deadlines.Create(r.Context(), tenantID, input)
if err != nil {
internalError(w, "failed to create deadline", err)
return
}
writeJSON(w, http.StatusCreated, deadline)
}
// Update handles PUT /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
var input services.UpdateDeadlineInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
deadline, err := h.deadlines.Update(r.Context(), tenantID, deadlineID, input)
if err != nil {
internalError(w, "failed to update deadline", err)
return
}
if deadline == nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, deadline)
}
// Complete handles PATCH /api/deadlines/{deadlineID}/complete
func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
deadline, err := h.deadlines.Complete(r.Context(), tenantID, deadlineID)
if err != nil {
internalError(w, "failed to complete deadline", err)
return
}
if deadline == nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, deadline)
}
// Delete handles DELETE /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
if err := h.deadlines.Delete(r.Context(), tenantID, deadlineID); err != nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,166 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
func TestDocumentListByCase_NoTenant(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("GET", "/api/cases/"+uuid.New().String()+"/documents", nil)
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.ListByCase(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestDocumentListByCase_InvalidCaseID(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("GET", "/api/cases/bad-id/documents", nil)
r.SetPathValue("id", "bad-id")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.ListByCase(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestDocumentUpload_NoTenant(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("POST", "/api/cases/"+uuid.New().String()+"/documents", nil)
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.Upload(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestDocumentUpload_InvalidCaseID(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("POST", "/api/cases/bad-id/documents", nil)
r.SetPathValue("id", "bad-id")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Upload(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestDocumentDownload_NoTenant(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("GET", "/api/documents/"+uuid.New().String(), nil)
r.SetPathValue("docId", uuid.New().String())
w := httptest.NewRecorder()
h.Download(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestDocumentDownload_InvalidID(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("GET", "/api/documents/bad-id", nil)
r.SetPathValue("docId", "bad-id")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Download(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestDocumentGetMeta_NoTenant(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("GET", "/api/documents/"+uuid.New().String()+"/meta", nil)
r.SetPathValue("docId", uuid.New().String())
w := httptest.NewRecorder()
h.GetMeta(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestDocumentGetMeta_InvalidID(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("GET", "/api/documents/bad-id/meta", nil)
r.SetPathValue("docId", "bad-id")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.GetMeta(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestDocumentDelete_NoTenant(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("DELETE", "/api/documents/"+uuid.New().String(), nil)
r.SetPathValue("docId", uuid.New().String())
w := httptest.NewRecorder()
h.Delete(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", w.Code)
}
}
func TestDocumentDelete_InvalidID(t *testing.T) {
h := &DocumentHandler{}
r := httptest.NewRequest("DELETE", "/api/documents/bad-id", nil)
r.SetPathValue("docId", "bad-id")
ctx := auth.ContextWithTenantID(
auth.ContextWithUserID(r.Context(), uuid.New()),
uuid.New(),
)
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h.Delete(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}

View File

@@ -0,0 +1,204 @@
package handlers
import (
"fmt"
"io"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
const maxUploadSize = 50 << 20 // 50 MB
type DocumentHandler struct {
svc *services.DocumentService
}
func NewDocumentHandler(svc *services.DocumentService) *DocumentHandler {
return &DocumentHandler{svc: svc}
}
func (h *DocumentHandler) ListByCase(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "failed to list documents", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"documents": docs,
"total": len(docs),
})
}
func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file field")
return
}
defer file.Close()
title := r.FormValue("title")
if title == "" {
title = header.Filename
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
input := services.CreateDocumentInput{
Title: title,
DocType: r.FormValue("doc_type"),
Filename: header.Filename,
ContentType: contentType,
Size: int(header.Size),
Data: file,
}
doc, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
if err != nil {
if err.Error() == "case not found" {
writeError(w, http.StatusNotFound, "case not found")
return
}
internalError(w, "failed to upload document", err)
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (h *DocumentHandler) Download(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document ID")
return
}
body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID)
if err != nil {
if err.Error() == "document not found" || err.Error() == "document has no file" {
writeError(w, http.StatusNotFound, "document not found")
return
}
internalError(w, "failed to download document", err)
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, sanitizeFilename(title)))
io.Copy(w, body)
}
func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document ID")
return
}
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil {
internalError(w, "failed to get document metadata", err)
return
}
if doc == nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
role := auth.UserRoleFromContext(r.Context())
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document ID")
return
}
// Check permission: owner/partner can delete any, associate can delete own
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if doc == nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
uploaderID := uuid.Nil
if doc.UploadedBy != nil {
uploaderID = *doc.UploadedBy
}
if !auth.CanDeleteDocument(role, uploaderID, userID) {
writeError(w, http.StatusForbidden, "insufficient permissions to delete this document")
return
}
if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,108 @@
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"unicode/utf8"
"github.com/google/uuid"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// internalError logs the real error and returns a generic message to the client.
func internalError(w http.ResponseWriter, msg string, err error) {
slog.Error(msg, "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
// parsePathUUID extracts a UUID from the URL path using PathValue
func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
return uuid.Parse(r.PathValue(key))
}
// parseUUID parses a UUID string
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}
// --- Input validation helpers ---
const (
maxTitleLen = 500
maxDescriptionLen = 10000
maxCaseNumberLen = 100
maxSearchLen = 200
maxPaginationLimit = 100
)
// validateStringLength checks if a string exceeds the given max length.
func validateStringLength(field, value string, maxLen int) string {
if utf8.RuneCountInString(value) > maxLen {
return field + " exceeds maximum length"
}
return ""
}
// clampPagination enforces sane pagination defaults and limits.
func clampPagination(limit, offset int) (int, int) {
if limit <= 0 {
limit = 20
}
if limit > maxPaginationLimit {
limit = maxPaginationLimit
}
if offset < 0 {
offset = 0
}
return limit, offset
}
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
func sanitizeFilename(name string) string {
// Remove control characters, quotes, and backslashes
var b strings.Builder
for _, r := range name {
if r < 32 || r == '"' || r == '\\' || r == '/' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// maskSettingsPassword masks the CalDAV password in tenant settings JSON before returning to clients.
func maskSettingsPassword(settings json.RawMessage) json.RawMessage {
if len(settings) == 0 {
return settings
}
var m map[string]json.RawMessage
if err := json.Unmarshal(settings, &m); err != nil {
return settings
}
caldavRaw, ok := m["caldav"]
if !ok {
return settings
}
var caldav map[string]json.RawMessage
if err := json.Unmarshal(caldavRaw, &caldav); err != nil {
return settings
}
if _, ok := caldav["password"]; ok {
caldav["password"], _ = json.Marshal("********")
}
m["caldav"], _ = json.Marshal(caldav)
result, _ := json.Marshal(m)
return result
}

View File

@@ -0,0 +1,167 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type NoteHandler struct {
svc *services.NoteService
}
func NewNoteHandler(svc *services.NoteService) *NoteHandler {
return &NoteHandler{svc: svc}
}
// List handles GET /api/notes?{parent_type}_id={id}
func (h *NoteHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
parentType, parentID, err := parseNoteParent(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
notes, err := h.svc.ListByParent(r.Context(), tenantID, parentType, parentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list notes")
return
}
writeJSON(w, http.StatusOK, notes)
}
// Create handles POST /api/notes
func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
var input services.CreateNoteInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if input.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if msg := validateStringLength("content", input.Content, maxDescriptionLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
var createdBy *uuid.UUID
if userID != uuid.Nil {
createdBy = &userID
}
note, err := h.svc.Create(r.Context(), tenantID, createdBy, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create note")
return
}
writeJSON(w, http.StatusCreated, note)
}
// Update handles PUT /api/notes/{id}
func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
noteID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid note ID")
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if msg := validateStringLength("content", req.Content, maxDescriptionLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update note")
return
}
if note == nil {
writeError(w, http.StatusNotFound, "note not found")
return
}
writeJSON(w, http.StatusOK, note)
}
// Delete handles DELETE /api/notes/{id}
func (h *NoteHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
noteID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid note ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, noteID); err != nil {
writeError(w, http.StatusNotFound, "note not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// parseNoteParent extracts the parent type and ID from query parameters.
func parseNoteParent(r *http.Request) (string, uuid.UUID, error) {
params := map[string]string{
"case_id": "case",
"deadline_id": "deadline",
"appointment_id": "appointment",
"case_event_id": "case_event",
}
for param, parentType := range params {
if v := r.URL.Query().Get(param); v != "" {
id, err := uuid.Parse(v)
if err != nil {
return "", uuid.Nil, fmt.Errorf("invalid %s", param)
}
return parentType, id, nil
}
}
return "", uuid.Nil, fmt.Errorf("one of case_id, deadline_id, appointment_id, or case_event_id is required")
}

View File

@@ -0,0 +1,171 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// NotificationHandler handles notification API endpoints.
type NotificationHandler struct {
svc *services.NotificationService
db *sqlx.DB
}
// NewNotificationHandler creates a new notification handler.
func NewNotificationHandler(svc *services.NotificationService, db *sqlx.DB) *NotificationHandler {
return &NotificationHandler{svc: svc, db: db}
}
// List returns paginated notifications for the authenticated user.
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
notifications, total, err := h.svc.ListForUser(r.Context(), tenantID, userID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list notifications")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": notifications,
"total": total,
})
}
// UnreadCount returns the count of unread notifications.
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
count, err := h.svc.UnreadCount(r.Context(), tenantID, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count notifications")
return
}
writeJSON(w, http.StatusOK, map[string]int{"unread_count": count})
}
// MarkRead marks a single notification as read.
func (h *NotificationHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
notifID, err := parsePathUUID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid notification ID")
return
}
if err := h.svc.MarkRead(r.Context(), tenantID, userID, notifID); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// MarkAllRead marks all notifications as read.
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := h.svc.MarkAllRead(r.Context(), tenantID, userID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark all read")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GetPreferences returns notification preferences for the authenticated user.
func (h *NotificationHandler) GetPreferences(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
pref, err := h.svc.GetPreferences(r.Context(), tenantID, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get preferences")
return
}
writeJSON(w, http.StatusOK, pref)
}
// UpdatePreferences updates notification preferences for the authenticated user.
func (h *NotificationHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
userID, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var input services.UpdatePreferencesInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
pref, err := h.svc.UpdatePreferences(r.Context(), tenantID, userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update preferences")
return
}
writeJSON(w, http.StatusOK, pref)
}

View File

@@ -0,0 +1,139 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
type PartyHandler struct {
svc *services.PartyService
}
func NewPartyHandler(svc *services.PartyService) *PartyHandler {
return &PartyHandler{svc: svc}
}
func (h *PartyHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil {
internalError(w, "failed to list parties", err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"parties": parties,
})
}
func (h *PartyHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.CreatePartyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if msg := validateStringLength("name", input.Name, maxTitleLen); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
if err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "case not found")
return
}
internalError(w, "failed to create party", err)
return
}
writeJSON(w, http.StatusCreated, party)
}
func (h *PartyHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
partyID, err := uuid.Parse(r.PathValue("partyId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid party ID")
return
}
var input services.UpdatePartyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
updated, err := h.svc.Update(r.Context(), tenantID, partyID, input)
if err != nil {
internalError(w, "failed to update party", err)
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "party not found")
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *PartyHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
partyID, err := uuid.Parse(r.PathValue("partyId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid party ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, partyID); err != nil {
writeError(w, http.StatusNotFound, "party not found")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,391 @@
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type TenantHandler struct {
svc *services.TenantService
}
func NewTenantHandler(svc *services.TenantService) *TenantHandler {
return &TenantHandler{svc: svc}
}
// CreateTenant handles POST /api/tenants
func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Name == "" || req.Slug == "" {
jsonError(w, "name and slug are required", http.StatusBadRequest)
return
}
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
if err != nil {
slog.Error("failed to create tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
jsonResponse(w, tenant, http.StatusCreated)
}
// ListTenants handles GET /api/tenants
func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenants, err := h.svc.ListForUser(r.Context(), userID)
if err != nil {
slog.Error("failed to list tenants", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
// Mask CalDAV passwords in tenant settings
for i := range tenants {
tenants[i].Settings = maskSettingsPassword(tenants[i].Settings)
}
jsonResponse(w, tenants, http.StatusOK)
}
// GetTenant handles GET /api/tenants/{id}
func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Verify user has access to this tenant
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role == "" {
jsonError(w, "not found", http.StatusNotFound)
return
}
tenant, err := h.svc.GetByID(r.Context(), tenantID)
if err != nil {
slog.Error("failed to get tenant", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if tenant == nil {
jsonError(w, "not found", http.StatusNotFound)
return
}
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
jsonResponse(w, tenant, http.StatusOK)
}
// InviteUser handles POST /api/tenants/{id}/invite
func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Only owners and partners can invite
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can invite users", http.StatusForbidden)
return
}
var req struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
jsonError(w, "email is required", http.StatusBadRequest)
return
}
if req.Role == "" {
req.Role = "associate"
}
if !auth.IsValidRole(req.Role) {
jsonError(w, "invalid role", http.StatusBadRequest)
return
}
// Non-owners cannot invite as owner
if role != "owner" && req.Role == "owner" {
jsonError(w, "only owners can invite as owner", http.StatusForbidden)
return
}
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
if err != nil {
// These are user-facing validation errors (user not found, already member)
jsonError(w, "failed to invite user", http.StatusBadRequest)
return
}
jsonResponse(w, ut, http.StatusCreated)
}
// RemoveMember handles DELETE /api/tenants/{id}/members/{uid}
func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
memberID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
jsonError(w, "invalid member ID", http.StatusBadRequest)
return
}
// Only owners and partners can remove members (or user removing themselves)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role != "owner" && role != "partner" && userID != memberID {
jsonError(w, "insufficient permissions", http.StatusForbidden)
return
}
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
// These are user-facing validation errors (not a member, last owner, etc.)
jsonError(w, "failed to remove member", http.StatusBadRequest)
return
}
jsonResponse(w, map[string]string{"status": "removed"}, http.StatusOK)
}
// UpdateSettings handles PUT /api/tenants/{id}/settings
func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Only owners and partners can update settings
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can update settings", http.StatusForbidden)
return
}
var settings json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
if err != nil {
slog.Error("failed to update settings", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
// Mask CalDAV password before returning
tenant.Settings = maskSettingsPassword(tenant.Settings)
jsonResponse(w, tenant, http.StatusOK)
}
// ListMembers handles GET /api/tenants/{id}/members
func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Verify user has access
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
slog.Error("failed to get user role", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if role == "" {
jsonError(w, "not found", http.StatusNotFound)
return
}
members, err := h.svc.ListMembers(r.Context(), tenantID)
if err != nil {
slog.Error("failed to list members", "error", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
jsonResponse(w, members, http.StatusOK)
}
// UpdateMemberRole handles PUT /api/tenants/{id}/members/{uid}/role
func (h *TenantHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
memberID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
jsonError(w, "invalid member ID", http.StatusBadRequest)
return
}
// Only owners and partners can change roles
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "partner" {
jsonError(w, "only owners and partners can change roles", http.StatusForbidden)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if !auth.IsValidRole(req.Role) {
jsonError(w, "invalid role", http.StatusBadRequest)
return
}
// Non-owners cannot promote to owner
if role != "owner" && req.Role == "owner" {
jsonError(w, "only owners can promote to owner", http.StatusForbidden)
return
}
if err := h.svc.UpdateMemberRole(r.Context(), tenantID, memberID, req.Role); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK)
}
// GetMe handles GET /api/me — returns the current user's ID and role in the active tenant.
func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
role := auth.UserRoleFromContext(r.Context())
tenantID, _ := auth.TenantFromContext(r.Context())
// Get user's permissions for frontend UI
perms := auth.GetRolePermissions(role)
jsonResponse(w, map[string]any{
"user_id": userID,
"tenant_id": tenantID,
"role": role,
"permissions": perms,
}, http.StatusOK)
}
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,132 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
func TestCreateTenant_MissingFields(t *testing.T) {
h := &TenantHandler{} // no service needed for validation
// Build request with auth context
body := `{"name":"","slug":""}`
r := httptest.NewRequest("POST", "/api/tenants", bytes.NewBufferString(body))
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.CreateTenant(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "name and slug are required" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestCreateTenant_NoAuth(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("POST", "/api/tenants", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
h.CreateTenant(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestGetTenant_InvalidID(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("GET", "/api/tenants/not-a-uuid", nil)
r.SetPathValue("id", "not-a-uuid")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.GetTenant(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestInviteUser_InvalidTenantID(t *testing.T) {
h := &TenantHandler{}
body := `{"email":"test@example.com","role":"member"}`
r := httptest.NewRequest("POST", "/api/tenants/bad/invite", bytes.NewBufferString(body))
r.SetPathValue("id", "bad")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.InviteUser(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestInviteUser_NoAuth(t *testing.T) {
h := &TenantHandler{}
body := `{"email":"test@example.com"}`
r := httptest.NewRequest("POST", "/api/tenants/"+uuid.New().String()+"/invite", bytes.NewBufferString(body))
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.InviteUser(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestRemoveMember_InvalidIDs(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("DELETE", "/api/tenants/bad/members/bad", nil)
r.SetPathValue("id", "bad")
r.SetPathValue("uid", "bad")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.RemoveMember(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestJsonResponse(t *testing.T) {
w := httptest.NewRecorder()
jsonResponse(w, map[string]string{"key": "value"}, http.StatusOK)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("expected application/json, got %s", ct)
}
}
func TestJsonError(t *testing.T) {
w := httptest.NewRecorder()
jsonError(w, "something went wrong", http.StatusBadRequest)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "something went wrong" {
t.Errorf("unexpected error: %s", resp["error"])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
package logging
import (
"log/slog"
"os"
)
// Setup initializes the global slog logger with JSON output for production.
func Setup() {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(handler))
}

View File

@@ -0,0 +1,98 @@
package middleware
import (
"log/slog"
"net/http"
"sync"
"time"
)
// TokenBucket implements a simple per-IP token bucket rate limiter.
type TokenBucket struct {
mu sync.Mutex
buckets map[string]*bucket
rate float64 // tokens per second
burst int // max tokens
}
type bucket struct {
tokens float64
lastTime time.Time
}
// NewTokenBucket creates a rate limiter allowing rate requests per second with burst capacity.
func NewTokenBucket(rate float64, burst int) *TokenBucket {
tb := &TokenBucket{
buckets: make(map[string]*bucket),
rate: rate,
burst: burst,
}
// Periodically clean up stale buckets
go tb.cleanup()
return tb
}
func (tb *TokenBucket) allow(key string) bool {
tb.mu.Lock()
defer tb.mu.Unlock()
b, ok := tb.buckets[key]
if !ok {
b = &bucket{tokens: float64(tb.burst), lastTime: time.Now()}
tb.buckets[key] = b
}
now := time.Now()
elapsed := now.Sub(b.lastTime).Seconds()
b.tokens += elapsed * tb.rate
if b.tokens > float64(tb.burst) {
b.tokens = float64(tb.burst)
}
b.lastTime = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func (tb *TokenBucket) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
tb.mu.Lock()
cutoff := time.Now().Add(-10 * time.Minute)
for key, b := range tb.buckets {
if b.lastTime.Before(cutoff) {
delete(tb.buckets, key)
}
}
tb.mu.Unlock()
}
}
// Limit wraps an http.Handler with rate limiting.
func (tb *TokenBucket) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
if !tb.allow(ip) {
slog.Warn("rate limit exceeded", "ip", ip, "path", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", "10")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":"rate limit exceeded, try again later"}`))
return
}
next.ServeHTTP(w, r)
})
}
// LimitFunc wraps an http.HandlerFunc with rate limiting.
func (tb *TokenBucket) LimitFunc(next http.HandlerFunc) http.HandlerFunc {
limited := tb.Limit(http.HandlerFunc(next))
return limited.ServeHTTP
}

View File

@@ -0,0 +1,70 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestTokenBucket_AllowsBurst(t *testing.T) {
tb := NewTokenBucket(1.0, 5) // 1/sec, burst 5
handler := tb.LimitFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Should allow burst of 5 requests
for i := 0; i < 5; i++ {
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
// 6th request should be rate limited
req := httptest.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("request 6: expected 429, got %d", w.Code)
}
}
func TestTokenBucket_DifferentIPs(t *testing.T) {
tb := NewTokenBucket(1.0, 2) // 1/sec, burst 2
handler := tb.LimitFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Exhaust IP1's bucket
for i := 0; i < 2; i++ {
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Forwarded-For", "1.2.3.4")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ip1 request %d: expected 200, got %d", i+1, w.Code)
}
}
// IP1 should now be limited
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Forwarded-For", "1.2.3.4")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("ip1 request 3: expected 429, got %d", w.Code)
}
// IP2 should still work
req = httptest.NewRequest("GET", "/test", nil)
req.Header.Set("X-Forwarded-For", "5.6.7.8")
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ip2 request 1: expected 200, got %d", w.Code)
}
}

View File

@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"strings"
)
// SecurityHeaders adds standard security headers to all responses.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// CORS returns middleware that restricts cross-origin requests to the given origin.
// If allowedOrigin is empty, CORS headers are not set (same-origin only).
func CORS(allowedOrigin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigin != "" && origin != "" && matchOrigin(origin, allowedOrigin) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Tenant-ID")
w.Header().Set("Access-Control-Max-Age", "86400")
w.Header().Set("Vary", "Origin")
}
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// matchOrigin checks if the request origin matches the allowed origin.
func matchOrigin(origin, allowed string) bool {
return strings.EqualFold(strings.TrimRight(origin, "/"), strings.TrimRight(allowed, "/"))
}

View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Appointment struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
StartAt time.Time `db:"start_at" json:"start_at"`
EndAt *time.Time `db:"end_at" json:"end_at,omitempty"`
Location *string `db:"location" json:"location,omitempty"`
AppointmentType *string `db:"appointment_type" json:"appointment_type,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,22 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type AuditLog struct {
ID int64 `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"`
Action string `db:"action" json:"action"`
EntityType string `db:"entity_type" json:"entity_type"`
EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"`
OldValues *json.RawMessage `db:"old_values" json:"old_values,omitempty"`
NewValues *json.RawMessage `db:"new_values" json:"new_values,omitempty"`
IPAddress *string `db:"ip_address" json:"ip_address,omitempty"`
UserAgent *string `db:"user_agent" json:"user_agent,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

View File

@@ -0,0 +1,23 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Case struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseNumber string `db:"case_number" json:"case_number"`
Title string `db:"title" json:"title"`
CaseType *string `db:"case_type" json:"case_type,omitempty"`
Court *string `db:"court" json:"court,omitempty"`
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
Status string `db:"status" json:"status"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
"github.com/google/uuid"
)
type CaseAssignment struct {
ID uuid.UUID `db:"id" json:"id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Role string `db:"role" json:"role"`
AssignedAt time.Time `db:"assigned_at" json:"assigned_at"`
}

View File

@@ -0,0 +1,22 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type CaseEvent struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,27 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Deadline struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
DueDate string `db:"due_date" json:"due_date"`
OriginalDueDate *string `db:"original_due_date" json:"original_due_date,omitempty"`
WarningDate *string `db:"warning_date" json:"warning_date,omitempty"`
Source string `db:"source" json:"source"`
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,43 @@
package models
import (
"time"
"github.com/google/uuid"
)
type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
DurationValue int `db:"duration_value" json:"duration_value"`
DurationUnit string `db:"duration_unit" json:"duration_unit"`
Timing *string `db:"timing" json:"timing,omitempty"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type ProceedingType struct {
ID int `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
}

View File

@@ -0,0 +1,23 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Document struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
Title string `db:"title" json:"title"`
DocType *string `db:"doc_type" json:"doc_type,omitempty"`
FilePath *string `db:"file_path" json:"file_path,omitempty"`
FileSize *int `db:"file_size" json:"file_size,omitempty"`
MimeType *string `db:"mime_type" json:"mime_type,omitempty"`
AIExtracted *json.RawMessage `db:"ai_extracted" json:"ai_extracted,omitempty"`
UploadedBy *uuid.UUID `db:"uploaded_by" json:"uploaded_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Note struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"`
DeadlineID *uuid.UUID `db:"deadline_id" json:"deadline_id,omitempty"`
AppointmentID *uuid.UUID `db:"appointment_id" json:"appointment_id,omitempty"`
CaseEventID *uuid.UUID `db:"case_event_id" json:"case_event_id,omitempty"`
Content string `db:"content" json:"content"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,32 @@
package models
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
type Notification struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Type string `db:"type" json:"type"`
EntityType *string `db:"entity_type" json:"entity_type,omitempty"`
EntityID *uuid.UUID `db:"entity_id" json:"entity_id,omitempty"`
Title string `db:"title" json:"title"`
Body *string `db:"body" json:"body,omitempty"`
SentAt *time.Time `db:"sent_at" json:"sent_at,omitempty"`
ReadAt *time.Time `db:"read_at" json:"read_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
type NotificationPreferences struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
DeadlineReminderDays pq.Int64Array `db:"deadline_reminder_days" json:"deadline_reminder_days"`
EmailEnabled bool `db:"email_enabled" json:"email_enabled"`
DailyDigest bool `db:"daily_digest" json:"daily_digest"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,17 @@
package models
import (
"encoding/json"
"github.com/google/uuid"
)
type Party struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
Name string `db:"name" json:"name"`
Role *string `db:"role" json:"role,omitempty"`
Representative *string `db:"representative" json:"representative,omitempty"`
ContactInfo json.RawMessage `db:"contact_info" json:"contact_info"`
}

View File

@@ -0,0 +1,30 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Tenant struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Slug string `db:"slug" json:"slug"`
Settings json.RawMessage `db:"settings" json:"settings"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type UserTenant struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
Role string `db:"role" json:"role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// TenantWithRole is a Tenant joined with the user's role in that tenant.
type TenantWithRole struct {
Tenant
Role string `db:"role" json:"role"`
}

View File

@@ -0,0 +1,255 @@
package router
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/middleware"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService, notifSvc *services.NotificationService, youpcDB ...*sqlx.DB) http.Handler {
mux := http.NewServeMux()
// Services
auditSvc := services.NewAuditService(db)
tenantSvc := services.NewTenantService(db, auditSvc)
caseSvc := services.NewCaseService(db, auditSvc)
partySvc := services.NewPartyService(db, auditSvc)
appointmentSvc := services.NewAppointmentService(db, auditSvc)
holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db, auditSvc)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
assignmentSvc := services.NewCaseAssignmentService(db)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
if cfg.AnthropicAPIKey != "" {
var ydb *sqlx.DB
if len(youpcDB) > 0 {
ydb = youpcDB[0]
}
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db, ydb)
aiH = handlers.NewAIHandler(aiSvc)
}
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
noteSvc := services.NewNoteService(db, auditSvc)
dashboardSvc := services.NewDashboardService(db)
// Notification handler (optional — nil in tests)
var notifH *handlers.NotificationHandler
if notifSvc != nil {
notifH = handlers.NewNotificationHandler(notifSvc, db)
}
// Handlers
auditH := handlers.NewAuditLogHandler(auditSvc)
tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc)
apptH := handlers.NewAppointmentHandler(appointmentSvc)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
// Authenticated API routes
api := http.NewServeMux()
// Tenant management (no tenant resolver — these operate across tenants)
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
api.HandleFunc("PUT /api/tenants/{id}/settings", tenantH.UpdateSettings)
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
api.HandleFunc("PUT /api/tenants/{id}/members/{uid}/role", tenantH.UpdateMemberRole)
// Permission-wrapping helper: wraps a HandlerFunc with a permission check
perm := func(p auth.Permission, fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role := auth.UserRoleFromContext(r.Context())
if !auth.HasPermission(role, p) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"insufficient permissions"}`))
return
}
fn(w, r)
}
}
// Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux()
// Current user info (role, permissions) — all authenticated users
scoped.HandleFunc("GET /api/me", tenantH.GetMe)
// Cases — all can view, create needs PermCreateCase, archive needs PermCreateCase
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create))
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler
scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete))
// Parties — same access as case editing
scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Deadlines — manage needs PermManageDeadlines, view is open
scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get)
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", perm(auth.PermManageDeadlines, deadlineH.Create))
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Update))
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", perm(auth.PermManageDeadlines, deadlineH.Complete))
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Delete))
// Deadline rules (reference data) — all can read
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes)
// Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments — all can manage (PermManageAppointments granted to all)
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create))
scoped.HandleFunc("PUT /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Update))
scoped.HandleFunc("DELETE /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Delete))
// Case assignments — manage team required for assign/unassign
scoped.HandleFunc("GET /api/cases/{id}/assignments", assignmentH.List)
scoped.HandleFunc("POST /api/cases/{id}/assignments", perm(auth.PermManageTeam, assignmentH.Assign))
scoped.HandleFunc("DELETE /api/cases/{id}/assignments/{uid}", perm(auth.PermManageTeam, assignmentH.Unassign))
// Case events — all can view
scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
// Notes — all can manage
scoped.HandleFunc("GET /api/notes", noteH.List)
scoped.HandleFunc("POST /api/notes", noteH.Create)
scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update)
scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete)
// Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents — all can upload, delete checked in handler (own vs all)
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload))
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
if aiH != nil {
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
scoped.HandleFunc("POST /api/ai/extract-deadlines", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
scoped.HandleFunc("POST /api/ai/draft-document", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.DraftDocument)))
scoped.HandleFunc("POST /api/ai/case-strategy", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.CaseStrategy)))
scoped.HandleFunc("POST /api/ai/similar-cases", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SimilarCases)))
}
// Notifications
if notifH != nil {
scoped.HandleFunc("GET /api/notifications", notifH.List)
scoped.HandleFunc("GET /api/notifications/unread-count", notifH.UnreadCount)
scoped.HandleFunc("PATCH /api/notifications/{id}/read", notifH.MarkRead)
scoped.HandleFunc("PATCH /api/notifications/read-all", notifH.MarkAllRead)
scoped.HandleFunc("GET /api/notification-preferences", notifH.GetPreferences)
scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences)
}
// CalDAV sync endpoints — settings permission required
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
}
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
api.Handle("/api/", tenantResolver.Resolve(scoped))
mux.Handle("/api/", authMW.RequireAuth(api))
// Apply security middleware stack: CORS -> Security Headers -> Request Logger -> Routes
var handler http.Handler = mux
handler = requestLogger(handler)
handler = middleware.SecurityHeaders(handler)
handler = middleware.CORS(cfg.FrontendOrigin)(handler)
return handler
}
func handleHealth(db *sqlx.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "error"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip health checks to reduce noise
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next.ServeHTTP(sw, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
package services
import (
"encoding/json"
"testing"
)
func TestDeadlineExtractionToolSchema(t *testing.T) {
// Verify the tool schema serializes correctly
data, err := json.Marshal(deadlineExtractionTool)
if err != nil {
t.Fatalf("failed to marshal tool: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal tool JSON: %v", err)
}
if parsed["name"] != "extract_deadlines" {
t.Errorf("expected name 'extract_deadlines', got %v", parsed["name"])
}
schema, ok := parsed["input_schema"].(map[string]any)
if !ok {
t.Fatal("input_schema is not a map")
}
if schema["type"] != "object" {
t.Errorf("expected schema type 'object', got %v", schema["type"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatal("properties is not a map")
}
deadlines, ok := props["deadlines"].(map[string]any)
if !ok {
t.Fatal("deadlines property is not a map")
}
if deadlines["type"] != "array" {
t.Errorf("expected deadlines type 'array', got %v", deadlines["type"])
}
items, ok := deadlines["items"].(map[string]any)
if !ok {
t.Fatal("items is not a map")
}
itemProps, ok := items["properties"].(map[string]any)
if !ok {
t.Fatal("item properties is not a map")
}
expectedFields := []string{"title", "due_date", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"}
for _, field := range expectedFields {
if _, ok := itemProps[field]; !ok {
t.Errorf("missing expected field %q in item properties", field)
}
}
required, ok := items["required"].([]any)
if !ok {
t.Fatal("required is not a list")
}
if len(required) != 8 {
t.Errorf("expected 8 required fields, got %d", len(required))
}
}
func TestExtractedDeadlineJSON(t *testing.T) {
dueDate := "2026-04-15"
d := ExtractedDeadline{
Title: "Statement of Defence",
DueDate: &dueDate,
DurationValue: 3,
DurationUnit: "months",
Timing: "after",
TriggerEvent: "service of the Statement of Claim",
RuleReference: "Rule 23 RoP",
Confidence: 0.95,
SourceQuote: "The defendant shall file a defence within 3 months",
}
data, err := json.Marshal(d)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed ExtractedDeadline
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.Title != d.Title {
t.Errorf("title mismatch: %q != %q", parsed.Title, d.Title)
}
if *parsed.DueDate != *d.DueDate {
t.Errorf("due_date mismatch: %q != %q", *parsed.DueDate, *d.DueDate)
}
if parsed.DurationValue != d.DurationValue {
t.Errorf("duration_value mismatch: %d != %d", parsed.DurationValue, d.DurationValue)
}
if parsed.Confidence != d.Confidence {
t.Errorf("confidence mismatch: %f != %f", parsed.Confidence, d.Confidence)
}
}

View File

@@ -0,0 +1,139 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type AppointmentService struct {
db *sqlx.DB
audit *AuditService
}
func NewAppointmentService(db *sqlx.DB, audit *AuditService) *AppointmentService {
return &AppointmentService{db: db, audit: audit}
}
type AppointmentFilter struct {
CaseID *uuid.UUID
Type *string
StartFrom *time.Time
StartTo *time.Time
}
func (s *AppointmentService) List(ctx context.Context, tenantID uuid.UUID, filter AppointmentFilter) ([]models.Appointment, error) {
query := "SELECT * FROM appointments WHERE tenant_id = $1"
args := []any{tenantID}
argN := 2
if filter.CaseID != nil {
query += fmt.Sprintf(" AND case_id = $%d", argN)
args = append(args, *filter.CaseID)
argN++
}
if filter.Type != nil {
query += fmt.Sprintf(" AND appointment_type = $%d", argN)
args = append(args, *filter.Type)
argN++
}
if filter.StartFrom != nil {
query += fmt.Sprintf(" AND start_at >= $%d", argN)
args = append(args, *filter.StartFrom)
argN++
}
if filter.StartTo != nil {
query += fmt.Sprintf(" AND start_at <= $%d", argN)
args = append(args, *filter.StartTo)
argN++
}
query += " ORDER BY start_at ASC"
var appointments []models.Appointment
if err := s.db.SelectContext(ctx, &appointments, query, args...); err != nil {
return nil, fmt.Errorf("listing appointments: %w", err)
}
if appointments == nil {
appointments = []models.Appointment{}
}
return appointments, nil
}
func (s *AppointmentService) GetByID(ctx context.Context, tenantID, id uuid.UUID) (*models.Appointment, error) {
var a models.Appointment
err := s.db.GetContext(ctx, &a, "SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return nil, fmt.Errorf("getting appointment: %w", err)
}
return &a, nil
}
func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment) error {
a.ID = uuid.New()
now := time.Now().UTC()
a.CreatedAt = now
a.UpdatedAt = now
_, err := s.db.NamedExecContext(ctx, `
INSERT INTO appointments (id, tenant_id, case_id, title, description, start_at, end_at, location, appointment_type, caldav_uid, caldav_etag, created_at, updated_at)
VALUES (:id, :tenant_id, :case_id, :title, :description, :start_at, :end_at, :location, :appointment_type, :caldav_uid, :caldav_etag, :created_at, :updated_at)
`, a)
if err != nil {
return fmt.Errorf("creating appointment: %w", err)
}
s.audit.Log(ctx, "create", "appointment", &a.ID, nil, a)
return nil
}
func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment) error {
a.UpdatedAt = time.Now().UTC()
result, err := s.db.NamedExecContext(ctx, `
UPDATE appointments SET
case_id = :case_id,
title = :title,
description = :description,
start_at = :start_at,
end_at = :end_at,
location = :location,
appointment_type = :appointment_type,
caldav_uid = :caldav_uid,
caldav_etag = :caldav_etag,
updated_at = :updated_at
WHERE id = :id AND tenant_id = :tenant_id
`, a)
if err != nil {
return fmt.Errorf("updating appointment: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("appointment not found")
}
s.audit.Log(ctx, "update", "appointment", &a.ID, nil, a)
return nil
}
func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID) error {
result, err := s.db.ExecContext(ctx, "DELETE FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return fmt.Errorf("deleting appointment: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("appointment not found")
}
s.audit.Log(ctx, "delete", "appointment", &id, nil, nil)
return nil
}

View File

@@ -0,0 +1,141 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type AuditService struct {
db *sqlx.DB
}
func NewAuditService(db *sqlx.DB) *AuditService {
return &AuditService{db: db}
}
// Log records an audit entry. It extracts tenant, user, IP, and user-agent from context.
// Errors are logged but not returned — audit logging must not break business operations.
func (s *AuditService) Log(ctx context.Context, action, entityType string, entityID *uuid.UUID, oldValues, newValues any) {
tenantID, ok := auth.TenantFromContext(ctx)
if !ok {
slog.Warn("audit: missing tenant_id in context", "action", action, "entity_type", entityType)
return
}
var userID *uuid.UUID
if uid, ok := auth.UserFromContext(ctx); ok {
userID = &uid
}
var oldJSON, newJSON *json.RawMessage
if oldValues != nil {
if b, err := json.Marshal(oldValues); err == nil {
raw := json.RawMessage(b)
oldJSON = &raw
}
}
if newValues != nil {
if b, err := json.Marshal(newValues); err == nil {
raw := json.RawMessage(b)
newJSON = &raw
}
}
ip := auth.IPFromContext(ctx)
ua := auth.UserAgentFromContext(ctx)
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit_log (tenant_id, user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
tenantID, userID, action, entityType, entityID, oldJSON, newJSON, ip, ua)
if err != nil {
slog.Error("audit: failed to write log entry",
"error", err,
"action", action,
"entity_type", entityType,
"entity_id", entityID,
)
}
}
// AuditFilter holds query parameters for listing audit log entries.
type AuditFilter struct {
EntityType string
EntityID *uuid.UUID
UserID *uuid.UUID
From string // RFC3339 date
To string // RFC3339 date
Page int
Limit int
}
// List returns paginated audit log entries for a tenant.
func (s *AuditService) List(ctx context.Context, tenantID uuid.UUID, filter AuditFilter) ([]models.AuditLog, int, error) {
if filter.Limit <= 0 {
filter.Limit = 50
}
if filter.Limit > 200 {
filter.Limit = 200
}
if filter.Page <= 0 {
filter.Page = 1
}
offset := (filter.Page - 1) * filter.Limit
where := "WHERE tenant_id = $1"
args := []any{tenantID}
argIdx := 2
if filter.EntityType != "" {
where += fmt.Sprintf(" AND entity_type = $%d", argIdx)
args = append(args, filter.EntityType)
argIdx++
}
if filter.EntityID != nil {
where += fmt.Sprintf(" AND entity_id = $%d", argIdx)
args = append(args, *filter.EntityID)
argIdx++
}
if filter.UserID != nil {
where += fmt.Sprintf(" AND user_id = $%d", argIdx)
args = append(args, *filter.UserID)
argIdx++
}
if filter.From != "" {
where += fmt.Sprintf(" AND created_at >= $%d", argIdx)
args = append(args, filter.From)
argIdx++
}
if filter.To != "" {
where += fmt.Sprintf(" AND created_at <= $%d", argIdx)
args = append(args, filter.To)
argIdx++
}
var total int
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM audit_log "+where, args...); err != nil {
return nil, 0, fmt.Errorf("counting audit entries: %w", err)
}
query := fmt.Sprintf("SELECT * FROM audit_log %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
where, argIdx, argIdx+1)
args = append(args, filter.Limit, offset)
var entries []models.AuditLog
if err := s.db.SelectContext(ctx, &entries, query, args...); err != nil {
return nil, 0, fmt.Errorf("listing audit entries: %w", err)
}
if entries == nil {
entries = []models.AuditLog{}
}
return entries, total, nil
}

View File

@@ -0,0 +1,687 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
const (
calDAVDomain = "kanzlai.msbls.de"
calDAVProdID = "-//KanzlAI//KanzlAI-mGMT//EN"
defaultSyncMin = 15
)
// CalDAVConfig holds per-tenant CalDAV configuration from tenants.settings.
type CalDAVConfig struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
CalendarPath string `json:"calendar_path"`
SyncEnabled bool `json:"sync_enabled"`
SyncIntervalMinutes int `json:"sync_interval_minutes"`
}
// SyncStatus holds the last sync result for a tenant.
type SyncStatus struct {
TenantID uuid.UUID `json:"tenant_id"`
LastSyncAt time.Time `json:"last_sync_at"`
ItemsPushed int `json:"items_pushed"`
ItemsPulled int `json:"items_pulled"`
Errors []string `json:"errors,omitempty"`
SyncDuration string `json:"sync_duration"`
}
// CalDAVService handles bidirectional CalDAV synchronization.
type CalDAVService struct {
db *sqlx.DB
mu sync.RWMutex
statuses map[uuid.UUID]*SyncStatus // per-tenant sync status
stopCh chan struct{}
wg sync.WaitGroup
}
// NewCalDAVService creates a new CalDAV sync service.
func NewCalDAVService(db *sqlx.DB) *CalDAVService {
return &CalDAVService{
db: db,
statuses: make(map[uuid.UUID]*SyncStatus),
stopCh: make(chan struct{}),
}
}
// GetStatus returns the last sync status for a tenant.
func (s *CalDAVService) GetStatus(tenantID uuid.UUID) *SyncStatus {
s.mu.RLock()
defer s.mu.RUnlock()
return s.statuses[tenantID]
}
// setStatus stores the sync status for a tenant.
func (s *CalDAVService) setStatus(status *SyncStatus) {
s.mu.Lock()
defer s.mu.Unlock()
s.statuses[status.TenantID] = status
}
// Start begins the background sync goroutine that polls per-tenant.
func (s *CalDAVService) Start() {
s.wg.Go(func() {
s.backgroundLoop()
})
slog.Info("CalDAV sync service started")
}
// Stop gracefully stops the background sync.
func (s *CalDAVService) Stop() {
close(s.stopCh)
s.wg.Wait()
slog.Info("CalDAV sync service stopped")
}
// backgroundLoop polls tenants at their configured interval.
func (s *CalDAVService) backgroundLoop() {
// Check every minute, but only sync tenants whose interval has elapsed.
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.syncAllTenants()
}
}
}
// syncAllTenants checks all tenants and syncs those due for a sync.
func (s *CalDAVService) syncAllTenants() {
configs, err := s.loadAllTenantConfigs()
if err != nil {
slog.Error("CalDAV: failed to load tenant configs", "error", err)
return
}
for tenantID, cfg := range configs {
if !cfg.SyncEnabled {
continue
}
interval := cfg.SyncIntervalMinutes
if interval <= 0 {
interval = defaultSyncMin
}
// Check if enough time has passed since last sync
status := s.GetStatus(tenantID)
if status != nil && time.Since(status.LastSyncAt) < time.Duration(interval)*time.Minute {
continue
}
go func(tid uuid.UUID, c CalDAVConfig) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := s.SyncTenant(ctx, tid, c); err != nil {
slog.Error("CalDAV: sync failed", "tenant_id", tid, "error", err)
}
}(tenantID, cfg)
}
}
// loadAllTenantConfigs reads CalDAV configs from all tenants.
func (s *CalDAVService) loadAllTenantConfigs() (map[uuid.UUID]CalDAVConfig, error) {
type row struct {
ID uuid.UUID `db:"id"`
Settings json.RawMessage `db:"settings"`
}
var rows []row
if err := s.db.Select(&rows, "SELECT id, settings FROM tenants"); err != nil {
return nil, fmt.Errorf("querying tenants: %w", err)
}
result := make(map[uuid.UUID]CalDAVConfig)
for _, r := range rows {
cfg, err := parseCalDAVConfig(r.Settings)
if err != nil || cfg.URL == "" {
continue
}
result[r.ID] = cfg
}
return result, nil
}
// LoadTenantConfig reads CalDAV config for a single tenant.
func (s *CalDAVService) LoadTenantConfig(tenantID uuid.UUID) (*CalDAVConfig, error) {
var settings json.RawMessage
if err := s.db.Get(&settings, "SELECT settings FROM tenants WHERE id = $1", tenantID); err != nil {
return nil, fmt.Errorf("loading tenant settings: %w", err)
}
cfg, err := parseCalDAVConfig(settings)
if err != nil {
return nil, err
}
if cfg.URL == "" {
return nil, fmt.Errorf("no CalDAV configuration for tenant")
}
return &cfg, nil
}
func parseCalDAVConfig(settings json.RawMessage) (CalDAVConfig, error) {
if len(settings) == 0 {
return CalDAVConfig{}, nil
}
var wrapper struct {
CalDAV CalDAVConfig `json:"caldav"`
}
if err := json.Unmarshal(settings, &wrapper); err != nil {
return CalDAVConfig{}, fmt.Errorf("parsing CalDAV settings: %w", err)
}
return wrapper.CalDAV, nil
}
// newCalDAVClient creates a caldav.Client from config.
func newCalDAVClient(cfg CalDAVConfig) (*caldav.Client, error) {
httpClient := webdav.HTTPClientWithBasicAuth(nil, cfg.Username, cfg.Password)
return caldav.NewClient(httpClient, cfg.URL)
}
// SyncTenant performs a full bidirectional sync for a tenant.
func (s *CalDAVService) SyncTenant(ctx context.Context, tenantID uuid.UUID, cfg CalDAVConfig) (*SyncStatus, error) {
start := time.Now()
status := &SyncStatus{
TenantID: tenantID,
}
client, err := newCalDAVClient(cfg)
if err != nil {
status.Errors = append(status.Errors, fmt.Sprintf("creating client: %v", err))
status.LastSyncAt = time.Now()
s.setStatus(status)
return status, err
}
// Push local changes to CalDAV
pushed, pushErrs := s.pushAll(ctx, client, tenantID, cfg)
status.ItemsPushed = pushed
status.Errors = append(status.Errors, pushErrs...)
// Pull remote changes from CalDAV
pulled, pullErrs := s.pullAll(ctx, client, tenantID, cfg)
status.ItemsPulled = pulled
status.Errors = append(status.Errors, pullErrs...)
status.LastSyncAt = time.Now()
status.SyncDuration = time.Since(start).String()
s.setStatus(status)
if len(status.Errors) > 0 {
return status, fmt.Errorf("sync completed with %d errors", len(status.Errors))
}
return status, nil
}
// --- Push: Local -> CalDAV ---
// pushAll pushes all deadlines and appointments to CalDAV.
func (s *CalDAVService) pushAll(ctx context.Context, client *caldav.Client, tenantID uuid.UUID, cfg CalDAVConfig) (int, []string) {
var pushed int
var errs []string
// Push deadlines as VTODO
deadlines, err := s.loadDeadlines(tenantID)
if err != nil {
return 0, []string{fmt.Sprintf("loading deadlines: %v", err)}
}
for _, d := range deadlines {
if err := s.pushDeadline(ctx, client, cfg, &d); err != nil {
errs = append(errs, fmt.Sprintf("push deadline %s: %v", d.ID, err))
} else {
pushed++
}
}
// Push appointments as VEVENT
appointments, err := s.loadAppointments(ctx, tenantID)
if err != nil {
errs = append(errs, fmt.Sprintf("loading appointments: %v", err))
return pushed, errs
}
for _, a := range appointments {
if err := s.pushAppointment(ctx, client, cfg, &a); err != nil {
errs = append(errs, fmt.Sprintf("push appointment %s: %v", a.ID, err))
} else {
pushed++
}
}
return pushed, errs
}
// PushDeadline pushes a single deadline to CalDAV (called on create/update).
func (s *CalDAVService) PushDeadline(ctx context.Context, tenantID uuid.UUID, deadline *models.Deadline) error {
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil // CalDAV not configured or disabled — silently skip
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
return s.pushDeadline(ctx, client, *cfg, deadline)
}
func (s *CalDAVService) pushDeadline(ctx context.Context, client *caldav.Client, cfg CalDAVConfig, d *models.Deadline) error {
uid := deadlineUID(d.ID)
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, calDAVProdID)
cal.Props.SetText(ical.PropVersion, "2.0")
todo := ical.NewComponent(ical.CompToDo)
todo.Props.SetText(ical.PropUID, uid)
todo.Props.SetText(ical.PropSummary, d.Title)
todo.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
if d.Description != nil {
todo.Props.SetText(ical.PropDescription, *d.Description)
}
if d.Notes != nil {
desc := ""
if d.Description != nil {
desc = *d.Description + "\n\n"
}
todo.Props.SetText(ical.PropDescription, desc+*d.Notes)
}
// Parse due_date (stored as string "YYYY-MM-DD")
if due, err := time.Parse("2006-01-02", d.DueDate); err == nil {
todo.Props.SetDate(ical.PropDue, due)
}
// Map status
switch d.Status {
case "completed":
todo.Props.SetText(ical.PropStatus, "COMPLETED")
if d.CompletedAt != nil {
todo.Props.SetDateTime(ical.PropCompleted, d.CompletedAt.UTC())
}
case "pending":
todo.Props.SetText(ical.PropStatus, "NEEDS-ACTION")
default:
todo.Props.SetText(ical.PropStatus, "IN-PROCESS")
}
cal.Children = append(cal.Children, todo)
path := calendarObjectPath(cfg.CalendarPath, uid)
obj, err := client.PutCalendarObject(ctx, path, cal)
if err != nil {
return fmt.Errorf("putting VTODO: %w", err)
}
// Update caldav_uid and etag in DB
return s.updateDeadlineCalDAV(d.ID, uid, obj.ETag)
}
// PushAppointment pushes a single appointment to CalDAV (called on create/update).
func (s *CalDAVService) PushAppointment(ctx context.Context, tenantID uuid.UUID, appointment *models.Appointment) error {
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
return s.pushAppointment(ctx, client, *cfg, appointment)
}
func (s *CalDAVService) pushAppointment(ctx context.Context, client *caldav.Client, cfg CalDAVConfig, a *models.Appointment) error {
uid := appointmentUID(a.ID)
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, calDAVProdID)
cal.Props.SetText(ical.PropVersion, "2.0")
event := ical.NewEvent()
event.Props.SetText(ical.PropUID, uid)
event.Props.SetText(ical.PropSummary, a.Title)
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
event.Props.SetDateTime(ical.PropDateTimeStart, a.StartAt.UTC())
if a.EndAt != nil {
event.Props.SetDateTime(ical.PropDateTimeEnd, a.EndAt.UTC())
}
if a.Description != nil {
event.Props.SetText(ical.PropDescription, *a.Description)
}
if a.Location != nil {
event.Props.SetText(ical.PropLocation, *a.Location)
}
cal.Children = append(cal.Children, event.Component)
path := calendarObjectPath(cfg.CalendarPath, uid)
obj, err := client.PutCalendarObject(ctx, path, cal)
if err != nil {
return fmt.Errorf("putting VEVENT: %w", err)
}
return s.updateAppointmentCalDAV(a.ID, uid, obj.ETag)
}
// DeleteDeadlineCalDAV removes a deadline's VTODO from CalDAV.
func (s *CalDAVService) DeleteDeadlineCalDAV(ctx context.Context, tenantID uuid.UUID, deadline *models.Deadline) error {
if deadline.CalDAVUID == nil || *deadline.CalDAVUID == "" {
return nil
}
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
path := calendarObjectPath(cfg.CalendarPath, *deadline.CalDAVUID)
return client.RemoveAll(ctx, path)
}
// DeleteAppointmentCalDAV removes an appointment's VEVENT from CalDAV.
func (s *CalDAVService) DeleteAppointmentCalDAV(ctx context.Context, tenantID uuid.UUID, appointment *models.Appointment) error {
if appointment.CalDAVUID == nil || *appointment.CalDAVUID == "" {
return nil
}
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
path := calendarObjectPath(cfg.CalendarPath, *appointment.CalDAVUID)
return client.RemoveAll(ctx, path)
}
// --- Pull: CalDAV -> Local ---
// pullAll fetches all calendar objects from CalDAV and reconciles with local DB.
func (s *CalDAVService) pullAll(ctx context.Context, client *caldav.Client, tenantID uuid.UUID, cfg CalDAVConfig) (int, []string) {
var pulled int
var errs []string
query := &caldav.CalendarQuery{
CompFilter: caldav.CompFilter{
Name: ical.CompCalendar,
},
}
objects, err := client.QueryCalendar(ctx, cfg.CalendarPath, query)
if err != nil {
return 0, []string{fmt.Sprintf("querying calendar: %v", err)}
}
for _, obj := range objects {
if obj.Data == nil {
continue
}
for _, child := range obj.Data.Children {
switch child.Name {
case ical.CompToDo:
uid, _ := child.Props.Text(ical.PropUID)
if uid == "" || !isKanzlAIUID(uid, "deadline") {
continue
}
if err := s.reconcileDeadline(ctx, tenantID, child, obj.ETag); err != nil {
errs = append(errs, fmt.Sprintf("reconcile deadline %s: %v", uid, err))
} else {
pulled++
}
case ical.CompEvent:
uid, _ := child.Props.Text(ical.PropUID)
if uid == "" || !isKanzlAIUID(uid, "appointment") {
continue
}
if err := s.reconcileAppointment(ctx, tenantID, child, obj.ETag); err != nil {
errs = append(errs, fmt.Sprintf("reconcile appointment %s: %v", uid, err))
} else {
pulled++
}
}
}
}
return pulled, errs
}
// reconcileDeadline handles conflict resolution for a pulled VTODO.
// KanzlAI wins for dates/status, CalDAV wins for notes/description.
func (s *CalDAVService) reconcileDeadline(ctx context.Context, tenantID uuid.UUID, comp *ical.Component, remoteEtag string) error {
uid, _ := comp.Props.Text(ical.PropUID)
deadlineID := extractIDFromUID(uid, "deadline")
if deadlineID == uuid.Nil {
return fmt.Errorf("invalid UID: %s", uid)
}
// Load existing deadline
var d models.Deadline
err := s.db.Get(&d, `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines WHERE id = $1 AND tenant_id = $2`, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("loading deadline: %w", err)
}
// Check if remote changed (etag mismatch)
if d.CalDAVEtag != nil && *d.CalDAVEtag == remoteEtag {
return nil // No change
}
// CalDAV wins for description/notes
description, _ := comp.Props.Text(ical.PropDescription)
hasConflict := false
if description != "" {
existingDesc := ""
if d.Description != nil {
existingDesc = *d.Description
}
existingNotes := ""
if d.Notes != nil {
existingNotes = *d.Notes
}
// CalDAV wins for notes/description
if description != existingDesc && description != existingNotes {
hasConflict = true
_, err = s.db.Exec(`UPDATE deadlines SET notes = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4`, description, remoteEtag, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("updating deadline notes: %w", err)
}
}
}
if !hasConflict {
// Just update etag
_, err = s.db.Exec(`UPDATE deadlines SET caldav_etag = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3`, remoteEtag, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("updating deadline etag: %w", err)
}
}
// Log conflict in case_events if detected
if hasConflict {
s.logConflictEvent(ctx, tenantID, d.CaseID, "deadline", deadlineID, "CalDAV description updated from remote")
}
return nil
}
// reconcileAppointment handles conflict resolution for a pulled VEVENT.
func (s *CalDAVService) reconcileAppointment(ctx context.Context, tenantID uuid.UUID, comp *ical.Component, remoteEtag string) error {
uid, _ := comp.Props.Text(ical.PropUID)
appointmentID := extractIDFromUID(uid, "appointment")
if appointmentID == uuid.Nil {
return fmt.Errorf("invalid UID: %s", uid)
}
var a models.Appointment
err := s.db.GetContext(ctx, &a, `SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2`, appointmentID, tenantID)
if err != nil {
return fmt.Errorf("loading appointment: %w", err)
}
if a.CalDAVEtag != nil && *a.CalDAVEtag == remoteEtag {
return nil
}
// CalDAV wins for description
description, _ := comp.Props.Text(ical.PropDescription)
location, _ := comp.Props.Text(ical.PropLocation)
hasConflict := false
updates := []string{"caldav_etag = $1", "updated_at = NOW()"}
args := []any{remoteEtag}
argN := 2
if description != "" {
existingDesc := ""
if a.Description != nil {
existingDesc = *a.Description
}
if description != existingDesc {
hasConflict = true
updates = append(updates, fmt.Sprintf("description = $%d", argN))
args = append(args, description)
argN++
}
}
if location != "" {
existingLoc := ""
if a.Location != nil {
existingLoc = *a.Location
}
if location != existingLoc {
hasConflict = true
updates = append(updates, fmt.Sprintf("location = $%d", argN))
args = append(args, location)
argN++
}
}
args = append(args, appointmentID, tenantID)
query := fmt.Sprintf("UPDATE appointments SET %s WHERE id = $%d AND tenant_id = $%d",
strings.Join(updates, ", "), argN, argN+1)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return fmt.Errorf("updating appointment: %w", err)
}
if hasConflict {
caseID := uuid.Nil
if a.CaseID != nil {
caseID = *a.CaseID
}
s.logConflictEvent(ctx, tenantID, caseID, "appointment", appointmentID, "CalDAV description/location updated from remote")
}
return nil
}
// --- DB helpers ---
func (s *CalDAVService) loadDeadlines(tenantID uuid.UUID) ([]models.Deadline, error) {
var deadlines []models.Deadline
err := s.db.Select(&deadlines, `SELECT id, tenant_id, case_id, title, description, due_date,
original_due_date, warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines WHERE tenant_id = $1`, tenantID)
return deadlines, err
}
func (s *CalDAVService) loadAppointments(ctx context.Context, tenantID uuid.UUID) ([]models.Appointment, error) {
var appointments []models.Appointment
err := s.db.SelectContext(ctx, &appointments, "SELECT * FROM appointments WHERE tenant_id = $1", tenantID)
return appointments, err
}
func (s *CalDAVService) updateDeadlineCalDAV(id uuid.UUID, calDAVUID, etag string) error {
_, err := s.db.Exec(`UPDATE deadlines SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3`, calDAVUID, etag, id)
return err
}
func (s *CalDAVService) updateAppointmentCalDAV(id uuid.UUID, calDAVUID, etag string) error {
_, err := s.db.Exec(`UPDATE appointments SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3`, calDAVUID, etag, id)
return err
}
func (s *CalDAVService) logConflictEvent(ctx context.Context, tenantID, caseID uuid.UUID, objectType string, objectID uuid.UUID, msg string) {
if caseID == uuid.Nil {
return
}
metadata, _ := json.Marshal(map[string]string{
"object_type": objectType,
"object_id": objectID.String(),
"source": "caldav_sync",
})
_, err := s.db.ExecContext(ctx, `INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, metadata, created_at, updated_at)
VALUES ($1, $2, $3, 'caldav_conflict', $4, $5, $6, NOW(), NOW())`,
uuid.New(), tenantID, caseID, "CalDAV sync conflict", msg, metadata)
if err != nil {
slog.Error("CalDAV: failed to log conflict event", "error", err)
}
}
// --- UID helpers ---
func deadlineUID(id uuid.UUID) string {
return fmt.Sprintf("kanzlai-deadline-%s@%s", id, calDAVDomain)
}
func appointmentUID(id uuid.UUID) string {
return fmt.Sprintf("kanzlai-appointment-%s@%s", id, calDAVDomain)
}
func isKanzlAIUID(uid, objectType string) bool {
return strings.HasPrefix(uid, "kanzlai-"+objectType+"-") && strings.HasSuffix(uid, "@"+calDAVDomain)
}
func extractIDFromUID(uid, objectType string) uuid.UUID {
prefix := "kanzlai-" + objectType + "-"
suffix := "@" + calDAVDomain
if !strings.HasPrefix(uid, prefix) || !strings.HasSuffix(uid, suffix) {
return uuid.Nil
}
idStr := uid[len(prefix) : len(uid)-len(suffix)]
id, err := uuid.Parse(idStr)
if err != nil {
return uuid.Nil
}
return id
}
func calendarObjectPath(calendarPath, uid string) string {
path := strings.TrimSuffix(calendarPath, "/")
return path + "/" + uid + ".ics"
}

View File

@@ -0,0 +1,124 @@
package services
import (
"testing"
"github.com/google/uuid"
)
func TestDeadlineUID(t *testing.T) {
id := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
uid := deadlineUID(id)
want := "kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de"
if uid != want {
t.Errorf("deadlineUID = %q, want %q", uid, want)
}
}
func TestAppointmentUID(t *testing.T) {
id := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
uid := appointmentUID(id)
want := "kanzlai-appointment-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de"
if uid != want {
t.Errorf("appointmentUID = %q, want %q", uid, want)
}
}
func TestIsKanzlAIUID(t *testing.T) {
tests := []struct {
uid string
objectType string
want bool
}{
{"kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "deadline", true},
{"kanzlai-appointment-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "appointment", true},
{"kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "appointment", false},
{"random-uid@other.com", "deadline", false},
{"", "deadline", false},
}
for _, tt := range tests {
got := isKanzlAIUID(tt.uid, tt.objectType)
if got != tt.want {
t.Errorf("isKanzlAIUID(%q, %q) = %v, want %v", tt.uid, tt.objectType, got, tt.want)
}
}
}
func TestExtractIDFromUID(t *testing.T) {
id := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
tests := []struct {
uid string
objectType string
want uuid.UUID
}{
{"kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "deadline", id},
{"kanzlai-appointment-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "appointment", id},
{"invalid-uid", "deadline", uuid.Nil},
{"kanzlai-deadline-not-a-uuid@kanzlai.msbls.de", "deadline", uuid.Nil},
}
for _, tt := range tests {
got := extractIDFromUID(tt.uid, tt.objectType)
if got != tt.want {
t.Errorf("extractIDFromUID(%q, %q) = %v, want %v", tt.uid, tt.objectType, got, tt.want)
}
}
}
func TestCalendarObjectPath(t *testing.T) {
tests := []struct {
calendarPath string
uid string
want string
}{
{"/dav/calendars/user/cal", "kanzlai-deadline-abc@kanzlai.msbls.de", "/dav/calendars/user/cal/kanzlai-deadline-abc@kanzlai.msbls.de.ics"},
{"/dav/calendars/user/cal/", "kanzlai-deadline-abc@kanzlai.msbls.de", "/dav/calendars/user/cal/kanzlai-deadline-abc@kanzlai.msbls.de.ics"},
}
for _, tt := range tests {
got := calendarObjectPath(tt.calendarPath, tt.uid)
if got != tt.want {
t.Errorf("calendarObjectPath(%q, %q) = %q, want %q", tt.calendarPath, tt.uid, got, tt.want)
}
}
}
func TestParseCalDAVConfig(t *testing.T) {
settings := []byte(`{"caldav": {"url": "https://dav.example.com", "username": "user", "password": "pass", "calendar_path": "/cal", "sync_enabled": true, "sync_interval_minutes": 30}}`)
cfg, err := parseCalDAVConfig(settings)
if err != nil {
t.Fatalf("parseCalDAVConfig: %v", err)
}
if cfg.URL != "https://dav.example.com" {
t.Errorf("URL = %q, want %q", cfg.URL, "https://dav.example.com")
}
if cfg.Username != "user" {
t.Errorf("Username = %q, want %q", cfg.Username, "user")
}
if cfg.SyncIntervalMinutes != 30 {
t.Errorf("SyncIntervalMinutes = %d, want 30", cfg.SyncIntervalMinutes)
}
if !cfg.SyncEnabled {
t.Error("SyncEnabled = false, want true")
}
}
func TestParseCalDAVConfig_Empty(t *testing.T) {
cfg, err := parseCalDAVConfig(nil)
if err != nil {
t.Fatalf("parseCalDAVConfig(nil): %v", err)
}
if cfg.URL != "" {
t.Errorf("expected empty config, got URL=%q", cfg.URL)
}
}
func TestParseCalDAVConfig_NoCalDAV(t *testing.T) {
settings := []byte(`{"other_setting": true}`)
cfg, err := parseCalDAVConfig(settings)
if err != nil {
t.Fatalf("parseCalDAVConfig: %v", err)
}
if cfg.URL != "" {
t.Errorf("expected empty caldav config, got URL=%q", cfg.URL)
}
}

View File

@@ -0,0 +1,92 @@
package services
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type CaseAssignmentService struct {
db *sqlx.DB
}
func NewCaseAssignmentService(db *sqlx.DB) *CaseAssignmentService {
return &CaseAssignmentService{db: db}
}
// ListByCase returns all assignments for a case.
func (s *CaseAssignmentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.CaseAssignment, error) {
var assignments []models.CaseAssignment
err := s.db.SelectContext(ctx, &assignments,
`SELECT ca.id, ca.case_id, ca.user_id, ca.role, ca.assigned_at
FROM case_assignments ca
JOIN cases c ON c.id = ca.case_id
WHERE ca.case_id = $1 AND c.tenant_id = $2
ORDER BY ca.assigned_at`,
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("list case assignments: %w", err)
}
return assignments, nil
}
// Assign adds a user to a case with the given role.
func (s *CaseAssignmentService) Assign(ctx context.Context, tenantID, caseID, userID uuid.UUID, role string) (*models.CaseAssignment, error) {
// Verify user is a member of this tenant
var memberExists bool
err := s.db.GetContext(ctx, &memberExists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenantID)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if !memberExists {
return nil, fmt.Errorf("user is not a member of this tenant")
}
// Verify case belongs to tenant
var caseExists bool
err = s.db.GetContext(ctx, &caseExists,
`SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)`,
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("check case: %w", err)
}
if !caseExists {
return nil, fmt.Errorf("case not found")
}
var assignment models.CaseAssignment
err = s.db.QueryRowxContext(ctx,
`INSERT INTO case_assignments (case_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (case_id, user_id) DO UPDATE SET role = EXCLUDED.role
RETURNING id, case_id, user_id, role, assigned_at`,
caseID, userID, role,
).StructScan(&assignment)
if err != nil {
return nil, fmt.Errorf("assign user to case: %w", err)
}
return &assignment, nil
}
// Unassign removes a user from a case.
func (s *CaseAssignmentService) Unassign(ctx context.Context, tenantID, caseID, userID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
`DELETE FROM case_assignments ca
USING cases c
WHERE ca.case_id = c.id AND ca.case_id = $1 AND ca.user_id = $2 AND c.tenant_id = $3`,
caseID, userID, tenantID)
if err != nil {
return fmt.Errorf("unassign: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("assignment not found")
}
return nil
}

View File

@@ -0,0 +1,285 @@
package services
import (
"context"
"database/sql"
"fmt"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type CaseService struct {
db *sqlx.DB
audit *AuditService
}
func NewCaseService(db *sqlx.DB, audit *AuditService) *CaseService {
return &CaseService{db: db, audit: audit}
}
type CaseFilter struct {
Status string
Type string
Search string
Limit int
Offset int
}
type CaseDetail struct {
models.Case
Parties []models.Party `json:"parties"`
RecentEvents []models.CaseEvent `json:"recent_events"`
DeadlinesCount int `json:"deadlines_count"`
}
type CreateCaseInput struct {
CaseNumber string `json:"case_number"`
Title string `json:"title"`
CaseType *string `json:"case_type,omitempty"`
Court *string `json:"court,omitempty"`
CourtRef *string `json:"court_ref,omitempty"`
Status string `json:"status"`
}
type UpdateCaseInput struct {
CaseNumber *string `json:"case_number,omitempty"`
Title *string `json:"title,omitempty"`
CaseType *string `json:"case_type,omitempty"`
Court *string `json:"court,omitempty"`
CourtRef *string `json:"court_ref,omitempty"`
Status *string `json:"status,omitempty"`
}
func (s *CaseService) List(ctx context.Context, tenantID uuid.UUID, filter CaseFilter) ([]models.Case, int, error) {
if filter.Limit <= 0 {
filter.Limit = 20
}
if filter.Limit > 100 {
filter.Limit = 100
}
// Build WHERE clause
where := "WHERE tenant_id = $1"
args := []interface{}{tenantID}
argIdx := 2
if filter.Status != "" {
where += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, filter.Status)
argIdx++
}
if filter.Type != "" {
where += fmt.Sprintf(" AND case_type = $%d", argIdx)
args = append(args, filter.Type)
argIdx++
}
if filter.Search != "" {
where += fmt.Sprintf(" AND (title ILIKE $%d OR case_number ILIKE $%d)", argIdx, argIdx)
args = append(args, "%"+filter.Search+"%")
argIdx++
}
// Count total
var total int
countQuery := "SELECT COUNT(*) FROM cases " + where
if err := s.db.GetContext(ctx, &total, countQuery, args...); err != nil {
return nil, 0, fmt.Errorf("counting cases: %w", err)
}
// Fetch page
query := fmt.Sprintf("SELECT * FROM cases %s ORDER BY updated_at DESC LIMIT $%d OFFSET $%d",
where, argIdx, argIdx+1)
args = append(args, filter.Limit, filter.Offset)
var cases []models.Case
if err := s.db.SelectContext(ctx, &cases, query, args...); err != nil {
return nil, 0, fmt.Errorf("listing cases: %w", err)
}
return cases, total, nil
}
func (s *CaseService) GetByID(ctx context.Context, tenantID, caseID uuid.UUID) (*CaseDetail, error) {
var c models.Case
err := s.db.GetContext(ctx, &c,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting case: %w", err)
}
detail := &CaseDetail{Case: c}
// Parties
if err := s.db.SelectContext(ctx, &detail.Parties,
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("getting parties: %w", err)
}
// Recent events (last 20)
if err := s.db.SelectContext(ctx, &detail.RecentEvents,
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 20",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("getting events: %w", err)
}
// Deadlines count
if err := s.db.GetContext(ctx, &detail.DeadlinesCount,
"SELECT COUNT(*) FROM deadlines WHERE case_id = $1 AND tenant_id = $2",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("counting deadlines: %w", err)
}
return detail, nil
}
func (s *CaseService) Create(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, input CreateCaseInput) (*models.Case, error) {
if input.Status == "" {
input.Status = "active"
}
id := uuid.New()
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $9, $9)`,
id, tenantID, input.CaseNumber, input.Title, input.CaseType, input.Court, input.CourtRef, input.Status, now)
if err != nil {
return nil, fmt.Errorf("creating case: %w", err)
}
// Create case_created event
createEvent(ctx, s.db, tenantID, id, userID, "case_created", "Case created", nil)
var c models.Case
if err := s.db.GetContext(ctx, &c, "SELECT * FROM cases WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created case: %w", err)
}
s.audit.Log(ctx, "create", "case", &id, nil, c)
return &c, nil
}
func (s *CaseService) Update(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input UpdateCaseInput) (*models.Case, error) {
// Fetch current to detect status change
var current models.Case
err := s.db.GetContext(ctx, &current,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("fetching case for update: %w", err)
}
// Build SET clause dynamically
sets := []string{}
args := []interface{}{}
argIdx := 1
if input.CaseNumber != nil {
sets = append(sets, fmt.Sprintf("case_number = $%d", argIdx))
args = append(args, *input.CaseNumber)
argIdx++
}
if input.Title != nil {
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
args = append(args, *input.Title)
argIdx++
}
if input.CaseType != nil {
sets = append(sets, fmt.Sprintf("case_type = $%d", argIdx))
args = append(args, *input.CaseType)
argIdx++
}
if input.Court != nil {
sets = append(sets, fmt.Sprintf("court = $%d", argIdx))
args = append(args, *input.Court)
argIdx++
}
if input.CourtRef != nil {
sets = append(sets, fmt.Sprintf("court_ref = $%d", argIdx))
args = append(args, *input.CourtRef)
argIdx++
}
if input.Status != nil {
sets = append(sets, fmt.Sprintf("status = $%d", argIdx))
args = append(args, *input.Status)
argIdx++
}
if len(sets) == 0 {
return &current, nil
}
sets = append(sets, fmt.Sprintf("updated_at = $%d", argIdx))
args = append(args, time.Now())
argIdx++
query := fmt.Sprintf("UPDATE cases SET %s WHERE id = $%d AND tenant_id = $%d",
joinStrings(sets, ", "), argIdx, argIdx+1)
args = append(args, caseID, tenantID)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("updating case: %w", err)
}
// Log status change event
if input.Status != nil && *input.Status != current.Status {
desc := fmt.Sprintf("Status changed from %s to %s", current.Status, *input.Status)
createEvent(ctx, s.db, tenantID, caseID, userID, "status_changed", desc, nil)
}
var updated models.Case
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM cases WHERE id = $1", caseID); err != nil {
return nil, fmt.Errorf("fetching updated case: %w", err)
}
s.audit.Log(ctx, "update", "case", &caseID, current, updated)
return &updated, nil
}
func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
"UPDATE cases SET status = 'archived', updated_at = $1 WHERE id = $2 AND tenant_id = $3 AND status != 'archived'",
time.Now(), caseID, tenantID)
if err != nil {
return fmt.Errorf("archiving case: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil)
s.audit.Log(ctx, "delete", "case", &caseID, map[string]string{"status": "active"}, map[string]string{"status": "archived"})
return nil
}
func createEvent(ctx context.Context, db *sqlx.DB, tenantID, caseID uuid.UUID, userID uuid.UUID, eventType, title string, description *string) {
now := time.Now()
db.ExecContext(ctx,
`INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $7, $7)`,
uuid.New(), tenantID, caseID, eventType, title, description, now, userID)
}
func joinStrings(strs []string, sep string) string {
result := ""
for i, s := range strs {
if i > 0 {
result += sep
}
result += s
}
return result
}

View File

@@ -0,0 +1,154 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type DashboardService struct {
db *sqlx.DB
}
func NewDashboardService(db *sqlx.DB) *DashboardService {
return &DashboardService{db: db}
}
type DashboardData struct {
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
CaseSummary CaseSummary `json:"case_summary"`
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []RecentActivity `json:"recent_activity"`
}
type DeadlineSummary struct {
OverdueCount int `json:"overdue_count" db:"overdue_count"`
DueThisWeek int `json:"due_this_week" db:"due_this_week"`
DueNextWeek int `json:"due_next_week" db:"due_next_week"`
OKCount int `json:"ok_count" db:"ok_count"`
}
type CaseSummary struct {
ActiveCount int `json:"active_count" db:"active_count"`
NewThisMonth int `json:"new_this_month" db:"new_this_month"`
ClosedCount int `json:"closed_count" db:"closed_count"`
}
type UpcomingDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
CaseID uuid.UUID `json:"case_id" db:"case_id"`
CaseNumber string `json:"case_number" db:"case_number"`
CaseTitle string `json:"case_title" db:"case_title"`
Status string `json:"status" db:"status"`
}
type UpcomingAppointment struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
StartAt time.Time `json:"start_at" db:"start_at"`
CaseNumber *string `json:"case_number" db:"case_number"`
Location *string `json:"location" db:"location"`
}
type RecentActivity struct {
ID uuid.UUID `json:"id" db:"id"`
EventType *string `json:"event_type" db:"event_type"`
Title string `json:"title" db:"title"`
CaseID uuid.UUID `json:"case_id" db:"case_id"`
CaseNumber string `json:"case_number" db:"case_number"`
EventDate *time.Time `json:"event_date" db:"event_date"`
}
func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*DashboardData, error) {
now := time.Now()
today := now.Format("2006-01-02")
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
data := &DashboardData{}
// Single query with CTEs for deadline + case summaries
summaryQuery := `
WITH deadline_stats AS (
SELECT
COUNT(*) FILTER (WHERE due_date < $2 AND status = 'pending') AS overdue_count,
COUNT(*) FILTER (WHERE due_date >= $2 AND due_date <= $3 AND status = 'pending') AS due_this_week,
COUNT(*) FILTER (WHERE due_date > $3 AND due_date <= $4 AND status = 'pending') AS due_next_week,
COUNT(*) FILTER (WHERE due_date > $4 AND status = 'pending') AS ok_count
FROM deadlines
WHERE tenant_id = $1
),
case_stats AS (
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
COUNT(*) FILTER (WHERE created_at >= $5::date AND status != 'archived') AS new_this_month,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed_count
FROM cases
WHERE tenant_id = $1
)
SELECT
ds.overdue_count, ds.due_this_week, ds.due_next_week, ds.ok_count,
cs.active_count, cs.new_this_month, cs.closed_count
FROM deadline_stats ds, case_stats cs`
var summaryRow struct {
DeadlineSummary
CaseSummary
}
err := s.db.GetContext(ctx, &summaryRow, summaryQuery, tenantID, today, endOfWeek, endOfNextWeek, startOfMonth)
if err != nil {
return nil, fmt.Errorf("dashboard summary: %w", err)
}
data.DeadlineSummary = summaryRow.DeadlineSummary
data.CaseSummary = summaryRow.CaseSummary
// Upcoming deadlines (next 7 days)
deadlineQuery := `
SELECT d.id, d.title, d.due_date, d.case_id, c.case_number, c.title AS case_title, d.status
FROM deadlines d
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
WHERE d.tenant_id = $1 AND d.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3
ORDER BY d.due_date ASC`
data.UpcomingDeadlines = []UpcomingDeadline{}
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, deadlineQuery, tenantID, today, in7Days); err != nil {
return nil, fmt.Errorf("dashboard upcoming deadlines: %w", err)
}
// Upcoming appointments (next 7 days)
appointmentQuery := `
SELECT a.id, a.title, a.start_at, c.case_number, a.location
FROM appointments a
LEFT JOIN cases c ON c.id = a.case_id AND c.tenant_id = a.tenant_id
WHERE a.tenant_id = $1 AND a.start_at >= $2::timestamp AND a.start_at < ($2::date + interval '7 days')
ORDER BY a.start_at ASC`
data.UpcomingAppointments = []UpcomingAppointment{}
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, appointmentQuery, tenantID, now); err != nil {
return nil, fmt.Errorf("dashboard upcoming appointments: %w", err)
}
// Recent activity (last 10 case events)
activityQuery := `
SELECT ce.id, ce.event_type, ce.title, ce.case_id, c.case_number, ce.event_date
FROM case_events ce
JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id
WHERE ce.tenant_id = $1
ORDER BY COALESCE(ce.event_date, ce.created_at) DESC
LIMIT 10`
data.RecentActivity = []RecentActivity{}
if err := s.db.SelectContext(ctx, &data.RecentActivity, activityQuery, tenantID); err != nil {
return nil, fmt.Errorf("dashboard recent activity: %w", err)
}
return data, nil
}

View File

@@ -0,0 +1,33 @@
package services
import (
"testing"
"time"
)
func TestDashboardDateCalculations(t *testing.T) {
// Verify the date range logic used in Get()
now := time.Date(2026, 3, 25, 14, 0, 0, 0, time.UTC) // Wednesday
today := now.Format("2006-01-02")
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
if today != "2026-03-25" {
t.Errorf("today = %s, want 2026-03-25", today)
}
if endOfWeek != "2026-03-29" { // Sunday
t.Errorf("endOfWeek = %s, want 2026-03-29", endOfWeek)
}
if endOfNextWeek != "2026-04-05" {
t.Errorf("endOfNextWeek = %s, want 2026-04-05", endOfNextWeek)
}
if in7Days != "2026-04-01" {
t.Errorf("in7Days = %s, want 2026-04-01", in7Days)
}
if startOfMonth != "2026-03-01" {
t.Errorf("startOfMonth = %s, want 2026-03-01", startOfMonth)
}
}

View File

@@ -0,0 +1,99 @@
package services
import (
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// CalculatedDeadline holds a calculated deadline with adjustment info
type CalculatedDeadline struct {
RuleCode string `json:"rule_code"`
RuleID string `json:"rule_id"`
Title string `json:"title"`
DueDate string `json:"due_date"`
OriginalDueDate string `json:"original_due_date"`
WasAdjusted bool `json:"was_adjusted"`
}
// DeadlineCalculator calculates deadlines from rules and event dates
type DeadlineCalculator struct {
holidays *HolidayService
}
// NewDeadlineCalculator creates a new calculator
func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
return &DeadlineCalculator{holidays: holidays}
}
// CalculateEndDate calculates the end date for a single deadline rule based on an event date.
// Adapted from youpc.org CalculateDeadlineEndDate.
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule) (adjusted time.Time, original time.Time, wasAdjusted bool) {
endDate := eventDate
timing := "after"
if rule.Timing != nil {
timing = *rule.Timing
}
durationValue := rule.DurationValue
durationUnit := rule.DurationUnit
if timing == "before" {
switch durationUnit {
case "days":
endDate = endDate.AddDate(0, 0, -durationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, -durationValue*7)
case "months":
endDate = endDate.AddDate(0, -durationValue, 0)
}
} else {
switch durationUnit {
case "days":
endDate = endDate.AddDate(0, 0, durationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, durationValue*7)
case "months":
endDate = endDate.AddDate(0, durationValue, 0)
}
}
original = endDate
adjusted, _, wasAdjusted = c.holidays.AdjustForNonWorkingDays(endDate)
return adjusted, original, wasAdjusted
}
// CalculateFromRules calculates deadlines for a set of rules given an event date.
// Returns a list of calculated deadlines with due dates.
func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []models.DeadlineRule) []CalculatedDeadline {
results := make([]CalculatedDeadline, 0, len(rules))
for _, rule := range rules {
var adjusted, original time.Time
var wasAdjusted bool
if rule.DurationValue > 0 {
adjusted, original, wasAdjusted = c.CalculateEndDate(eventDate, rule)
} else {
adjusted = eventDate
original = eventDate
}
code := ""
if rule.Code != nil {
code = *rule.Code
}
results = append(results, CalculatedDeadline{
RuleCode: code,
RuleID: rule.ID.String(),
Title: rule.Name,
DueDate: adjusted.Format("2006-01-02"),
OriginalDueDate: original.Format("2006-01-02"),
WasAdjusted: wasAdjusted,
})
}
return results
}

View File

@@ -0,0 +1,141 @@
package services
import (
"testing"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
func TestCalculateEndDateAfterDays(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday
timing := "after"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 10 days",
DurationValue: 10,
DurationUnit: "days",
Timing: &timing,
}
adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule)
// 25 March + 10 days = 4 April 2026 (Saturday)
// Apr 5 = Easter Sunday (holiday), Apr 6 = Easter Monday (holiday) -> adjusted to 7 April (Tuesday)
expectedOriginal := time.Date(2026, 4, 4, 0, 0, 0, 0, time.UTC)
expectedAdjusted := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
if original != expectedOriginal {
t.Errorf("original should be %s, got %s", expectedOriginal, original)
}
if adjusted != expectedAdjusted {
t.Errorf("adjusted should be %s, got %s", expectedAdjusted, adjusted)
}
if !wasAdjusted {
t.Error("should have been adjusted (Saturday)")
}
}
func TestCalculateEndDateBeforeMonths(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) // Monday
timing := "before"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 2 months before",
DurationValue: 2,
DurationUnit: "months",
Timing: &timing,
}
adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule)
// 15 June - 2 months = 15 April 2026 (Wednesday)
expected := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
if original != expected {
t.Errorf("original should be %s, got %s", expected, original)
}
if adjusted != expected {
t.Errorf("adjusted should be %s (not a holiday/weekend), got %s", expected, adjusted)
}
if wasAdjusted {
t.Error("should not have been adjusted (Wednesday)")
}
}
func TestCalculateEndDateWeeks(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday
timing := "after"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 2 weeks",
DurationValue: 2,
DurationUnit: "weeks",
Timing: &timing,
}
adjusted, original, _ := calc.CalculateEndDate(eventDate, rule)
// 25 March + 14 days = 8 April 2026 (Wednesday)
expected := time.Date(2026, 4, 8, 0, 0, 0, 0, time.UTC)
if original != expected {
t.Errorf("original should be %s, got %s", expected, original)
}
if adjusted != expected {
t.Errorf("adjusted should be %s, got %s", expected, adjusted)
}
}
func TestCalculateFromRules(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
timing := "after"
code := "TEST-1"
rules := []models.DeadlineRule{
{
ID: uuid.New(),
Code: &code,
Name: "Rule A",
DurationValue: 7,
DurationUnit: "days",
Timing: &timing,
},
{
ID: uuid.New(),
Name: "Rule B (zero duration)",
DurationValue: 0,
DurationUnit: "days",
},
}
results := calc.CalculateFromRules(eventDate, rules)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
// Rule A: 25 March + 7 = 1 April (Wednesday)
if results[0].DueDate != "2026-04-01" {
t.Errorf("Rule A due date should be 2026-04-01, got %s", results[0].DueDate)
}
if results[0].RuleCode != "TEST-1" {
t.Errorf("Rule A code should be TEST-1, got %s", results[0].RuleCode)
}
// Rule B: zero duration -> event date
if results[1].DueDate != "2026-03-25" {
t.Errorf("Rule B due date should be 2026-03-25, got %s", results[1].DueDate)
}
}

View File

@@ -0,0 +1,175 @@
package services
import (
"fmt"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DeadlineRuleService handles deadline rule queries
type DeadlineRuleService struct {
db *sqlx.DB
}
// NewDeadlineRuleService creates a new deadline rule service
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
return &DeadlineRuleService{db: db}
}
// List returns deadline rules, optionally filtered by proceeding type
func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
var err error
if proceedingTypeID != nil {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
}
if err != nil {
return nil, fmt.Errorf("listing deadline rules: %w", err)
}
return rules, nil
}
// RuleTreeNode represents a deadline rule with its children
type RuleTreeNode struct {
models.DeadlineRule
Children []RuleTreeNode `json:"children,omitempty"`
}
// GetRuleTree returns a hierarchical tree of rules for a proceeding type
func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTreeNode, error) {
// First resolve proceeding type code to ID
var pt models.ProceedingType
err := s.db.Get(&pt,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE code = $1 AND is_active = true`, proceedingTypeCode)
if err != nil {
return nil, fmt.Errorf("resolving proceeding type %q: %w", proceedingTypeCode, err)
}
// Get all rules for this proceeding type
var rules []models.DeadlineRule
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID)
if err != nil {
return nil, fmt.Errorf("listing rules for type %q: %w", proceedingTypeCode, err)
}
return buildTree(rules), nil
}
// GetByIDs returns deadline rules by their IDs
func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
if err != nil {
return nil, fmt.Errorf("building IN query: %w", err)
}
query = s.db.Rebind(query)
var rules []models.DeadlineRule
err = s.db.Select(&rules, query, args...)
if err != nil {
return nil, fmt.Errorf("fetching rules by IDs: %w", err)
}
return rules, nil
}
// GetRulesForProceedingType returns all active rules for a proceeding type ID
func (s *DeadlineRuleService) GetRulesForProceedingType(proceedingTypeID int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
err := s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, proceedingTypeID)
if err != nil {
return nil, fmt.Errorf("listing rules for proceeding type %d: %w", proceedingTypeID, err)
}
return rules, nil
}
// ListProceedingTypes returns all active proceeding types
func (s *DeadlineRuleService) ListProceedingTypes() ([]models.ProceedingType, error) {
var types []models.ProceedingType
err := s.db.Select(&types,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE is_active = true
ORDER BY sort_order`)
if err != nil {
return nil, fmt.Errorf("listing proceeding types: %w", err)
}
return types, nil
}
// buildTree converts a flat list of rules into a hierarchical tree
func buildTree(rules []models.DeadlineRule) []RuleTreeNode {
nodeMap := make(map[string]*RuleTreeNode, len(rules))
var roots []RuleTreeNode
// Create nodes
for _, r := range rules {
node := RuleTreeNode{DeadlineRule: r}
nodeMap[r.ID.String()] = &node
}
// Build tree
for _, r := range rules {
node := nodeMap[r.ID.String()]
if r.ParentID != nil {
parentKey := r.ParentID.String()
if parent, ok := nodeMap[parentKey]; ok {
parent.Children = append(parent.Children, *node)
continue
}
}
roots = append(roots, *node)
}
return roots
}

View File

@@ -0,0 +1,203 @@
package services
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DeadlineService handles CRUD operations for case deadlines
type DeadlineService struct {
db *sqlx.DB
audit *AuditService
}
// NewDeadlineService creates a new deadline service
func NewDeadlineService(db *sqlx.DB, audit *AuditService) *DeadlineService {
return &DeadlineService{db: db, audit: audit}
}
// ListAll returns all deadlines for a tenant, ordered by due_date
func (s *DeadlineService) ListAll(tenantID uuid.UUID) ([]models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1
ORDER BY due_date ASC`
var deadlines []models.Deadline
err := s.db.Select(&deadlines, query, tenantID)
if err != nil {
return nil, fmt.Errorf("listing all deadlines: %w", err)
}
return deadlines, nil
}
// ListForCase returns all deadlines for a case, scoped to tenant
func (s *DeadlineService) ListForCase(tenantID, caseID uuid.UUID) ([]models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1 AND case_id = $2
ORDER BY due_date ASC`
var deadlines []models.Deadline
err := s.db.Select(&deadlines, query, tenantID, caseID)
if err != nil {
return nil, fmt.Errorf("listing deadlines for case: %w", err)
}
return deadlines, nil
}
// GetByID returns a single deadline by ID, scoped to tenant
func (s *DeadlineService) GetByID(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1 AND id = $2`
var d models.Deadline
err := s.db.Get(&d, query, tenantID, deadlineID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting deadline: %w", err)
}
return &d, nil
}
// CreateDeadlineInput holds the fields for creating a deadline
type CreateDeadlineInput struct {
CaseID uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DueDate string `json:"due_date"`
WarningDate *string `json:"warning_date,omitempty"`
Source string `json:"source"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// Create inserts a new deadline
func (s *DeadlineService) Create(ctx context.Context, tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
id := uuid.New()
source := input.Source
if source == "" {
source = "manual"
}
query := `INSERT INTO deadlines (id, tenant_id, case_id, title, description, due_date,
warning_date, source, rule_id, status, notes,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, NOW(), NOW())
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err := s.db.Get(&d, query, id, tenantID, input.CaseID, input.Title, input.Description,
input.DueDate, input.WarningDate, source, input.RuleID, input.Notes)
if err != nil {
return nil, fmt.Errorf("creating deadline: %w", err)
}
s.audit.Log(ctx, "create", "deadline", &id, nil, d)
return &d, nil
}
// UpdateDeadlineInput holds the fields for updating a deadline
type UpdateDeadlineInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
DueDate *string `json:"due_date,omitempty"`
WarningDate *string `json:"warning_date,omitempty"`
Notes *string `json:"notes,omitempty"`
Status *string `json:"status,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
}
// Update modifies an existing deadline
func (s *DeadlineService) Update(ctx context.Context, tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
// First check it exists and belongs to tenant
existing, err := s.GetByID(tenantID, deadlineID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, nil
}
query := `UPDATE deadlines SET
title = COALESCE($1, title),
description = COALESCE($2, description),
due_date = COALESCE($3, due_date),
warning_date = COALESCE($4, warning_date),
notes = COALESCE($5, notes),
status = COALESCE($6, status),
rule_id = COALESCE($7, rule_id),
updated_at = NOW()
WHERE id = $8 AND tenant_id = $9
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err = s.db.Get(&d, query, input.Title, input.Description, input.DueDate,
input.WarningDate, input.Notes, input.Status, input.RuleID,
deadlineID, tenantID)
if err != nil {
return nil, fmt.Errorf("updating deadline: %w", err)
}
s.audit.Log(ctx, "update", "deadline", &deadlineID, existing, d)
return &d, nil
}
// Complete marks a deadline as completed
func (s *DeadlineService) Complete(ctx context.Context, tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `UPDATE deadlines SET
status = 'completed',
completed_at = $1,
updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err := s.db.Get(&d, query, time.Now(), deadlineID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("completing deadline: %w", err)
}
s.audit.Log(ctx, "update", "deadline", &deadlineID, map[string]string{"status": "pending"}, map[string]string{"status": "completed"})
return &d, nil
}
// Delete removes a deadline
func (s *DeadlineService) Delete(ctx context.Context, tenantID, deadlineID uuid.UUID) error {
query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2`
result, err := s.db.Exec(query, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("deleting deadline: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking delete result: %w", err)
}
if rows == 0 {
return fmt.Errorf("deadline not found")
}
s.audit.Log(ctx, "delete", "deadline", &deadlineID, nil, nil)
return nil
}

View File

@@ -0,0 +1,166 @@
package services
import (
"context"
"database/sql"
"fmt"
"io"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
const documentBucket = "kanzlai-documents"
type DocumentService struct {
db *sqlx.DB
storage *StorageClient
audit *AuditService
}
func NewDocumentService(db *sqlx.DB, storage *StorageClient, audit *AuditService) *DocumentService {
return &DocumentService{db: db, storage: storage, audit: audit}
}
type CreateDocumentInput struct {
Title string `json:"title"`
DocType string `json:"doc_type"`
Filename string
ContentType string
Size int
Data io.Reader
}
func (s *DocumentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.Document, error) {
var docs []models.Document
err := s.db.SelectContext(ctx, &docs,
"SELECT * FROM documents WHERE tenant_id = $1 AND case_id = $2 ORDER BY created_at DESC",
tenantID, caseID)
if err != nil {
return nil, fmt.Errorf("listing documents: %w", err)
}
return docs, nil
}
func (s *DocumentService) GetByID(ctx context.Context, tenantID, docID uuid.UUID) (*models.Document, error) {
var doc models.Document
err := s.db.GetContext(ctx, &doc,
"SELECT * FROM documents WHERE id = $1 AND tenant_id = $2", docID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting document: %w", err)
}
return &doc, nil
}
func (s *DocumentService) Create(ctx context.Context, tenantID, caseID, userID uuid.UUID, input CreateDocumentInput) (*models.Document, error) {
// Verify case belongs to tenant
var caseExists int
if err := s.db.GetContext(ctx, &caseExists,
"SELECT COUNT(*) FROM cases WHERE id = $1 AND tenant_id = $2",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("verifying case: %w", err)
}
if caseExists == 0 {
return nil, fmt.Errorf("case not found")
}
id := uuid.New()
storagePath := fmt.Sprintf("%s/%s/%s_%s", tenantID, caseID, id, input.Filename)
// Upload to Supabase Storage
if err := s.storage.Upload(ctx, documentBucket, storagePath, input.ContentType, input.Data); err != nil {
return nil, fmt.Errorf("uploading file: %w", err)
}
// Insert metadata record
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO documents (id, tenant_id, case_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`,
id, tenantID, caseID, input.Title, nilIfEmpty(input.DocType), storagePath, input.Size, input.ContentType, userID, now)
if err != nil {
// Best effort: clean up uploaded file
_ = s.storage.Delete(ctx, documentBucket, []string{storagePath})
return nil, fmt.Errorf("inserting document record: %w", err)
}
// Log case event
createEvent(ctx, s.db, tenantID, caseID, userID, "document_uploaded",
fmt.Sprintf("Document uploaded: %s", input.Title), nil)
var doc models.Document
if err := s.db.GetContext(ctx, &doc, "SELECT * FROM documents WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created document: %w", err)
}
s.audit.Log(ctx, "create", "document", &id, nil, doc)
return &doc, nil
}
func (s *DocumentService) Download(ctx context.Context, tenantID, docID uuid.UUID) (io.ReadCloser, string, string, error) {
doc, err := s.GetByID(ctx, tenantID, docID)
if err != nil {
return nil, "", "", err
}
if doc == nil {
return nil, "", "", fmt.Errorf("document not found")
}
if doc.FilePath == nil {
return nil, "", "", fmt.Errorf("document has no file")
}
body, contentType, err := s.storage.Download(ctx, documentBucket, *doc.FilePath)
if err != nil {
return nil, "", "", fmt.Errorf("downloading file: %w", err)
}
// Use stored mime_type if available, fall back to storage response
if doc.MimeType != nil && *doc.MimeType != "" {
contentType = *doc.MimeType
}
return body, contentType, doc.Title, nil
}
func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uuid.UUID) error {
doc, err := s.GetByID(ctx, tenantID, docID)
if err != nil {
return err
}
if doc == nil {
return sql.ErrNoRows
}
// Delete from storage
if doc.FilePath != nil {
if err := s.storage.Delete(ctx, documentBucket, []string{*doc.FilePath}); err != nil {
return fmt.Errorf("deleting file from storage: %w", err)
}
}
// Delete database record
_, err = s.db.ExecContext(ctx,
"DELETE FROM documents WHERE id = $1 AND tenant_id = $2", docID, tenantID)
if err != nil {
return fmt.Errorf("deleting document record: %w", err)
}
// Log case event
createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted",
fmt.Sprintf("Document deleted: %s", doc.Title), nil)
s.audit.Log(ctx, "delete", "document", &docID, doc, nil)
return nil
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -0,0 +1,193 @@
package services
import (
"fmt"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
// Holiday represents a non-working day
type Holiday struct {
Date time.Time
Name string
IsVacation bool // Part of court vacation period
IsClosure bool // Single-day closure (public holiday)
}
// HolidayService manages holiday data and non-working day checks
type HolidayService struct {
db *sqlx.DB
// Cached holidays by year
cache map[int][]Holiday
}
// NewHolidayService creates a holiday service
func NewHolidayService(db *sqlx.DB) *HolidayService {
return &HolidayService{
db: db,
cache: make(map[int][]Holiday),
}
}
// dbHoliday matches the holidays table schema
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 from DB for a given year, merges with
// German federal holidays, and caches the result.
func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) {
if cached, ok := s.cache[year]; ok {
return cached, nil
}
holidays := make([]Holiday, 0, 30)
// Load from DB if available
if s.db != nil {
var dbHolidays []dbHoliday
err := s.db.Select(&dbHolidays,
`SELECT id, date, name, country, state, holiday_type
FROM holidays
WHERE EXTRACT(YEAR FROM date) = $1
ORDER BY date`, year)
if err == nil {
for _, h := range dbHolidays {
holidays = append(holidays, Holiday{
Date: h.Date,
Name: h.Name,
IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure",
IsVacation: h.HolidayType == "vacation",
})
}
}
// If DB query fails, fall through to hardcoded holidays
}
// Always add German federal holidays (if not already present from DB)
federal := germanFederalHolidays(year)
existing := make(map[string]bool, len(holidays))
for _, h := range holidays {
existing[h.Date.Format("2006-01-02")] = true
}
for _, h := range federal {
key := h.Date.Format("2006-01-02")
if !existing[key] {
holidays = append(holidays, h)
}
}
s.cache[year] = holidays
return holidays, nil
}
// IsHoliday checks if a date is a holiday
func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
year := date.Year()
holidays, err := s.LoadHolidaysForYear(year)
if err != nil {
return nil
}
dateStr := date.Format("2006-01-02")
for i := range holidays {
if holidays[i].Date.Format("2006-01-02") == dateStr {
return &holidays[i]
}
}
return nil
}
// IsNonWorkingDay returns true if the date is a weekend or holiday
func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
wd := date.Weekday()
if wd == time.Saturday || wd == time.Sunday {
return true
}
return s.IsHoliday(date) != nil
}
// AdjustForNonWorkingDays moves the date to the next working day
// if it falls on a weekend or holiday.
// Returns adjusted date, original date, and whether adjustment was made.
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
// Safety limit: max 30 days forward
for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ {
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// ClearCache clears the holiday cache (useful after DB updates)
func (s *HolidayService) ClearCache() {
s.cache = make(map[int][]Holiday)
}
// germanFederalHolidays returns all German federal public holidays for a year.
// These are holidays observed in all 16 German states.
func germanFederalHolidays(year int) []Holiday {
easterMonth, easterDay := CalculateEasterSunday(year)
easter := time.Date(year, time.Month(easterMonth), easterDay, 0, 0, 0, 0, time.UTC)
holidays := []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},
}
return holidays
}
// CalculateEasterSunday computes Easter Sunday using the Anonymous Gregorian algorithm.
// Returns month (1-12) and day.
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
}
// GetHolidaysForYear returns all holidays for a year (for API exposure)
func (s *HolidayService) GetHolidaysForYear(year int) ([]Holiday, error) {
return s.LoadHolidaysForYear(year)
}
// FormatHolidayList returns a simple string representation of holidays for debugging
func FormatHolidayList(holidays []Holiday) string {
var b strings.Builder
for _, h := range holidays {
fmt.Fprintf(&b, "%s: %s\n", h.Date.Format("2006-01-02"), h.Name)
}
return b.String()
}

Some files were not shown because too many files have changed in this diff Show More