Compare commits

..

8 Commits

Author SHA1 Message Date
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
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
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
51 changed files with 3163 additions and 24 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

@@ -7,6 +7,7 @@ PORT=8080
# Supabase (required for database access)
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_KEY=
# Claude API (required for AI features)
ANTHROPIC_API_KEY=

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

View File

@@ -22,8 +22,8 @@ func main() {
}
defer database.Close()
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret)
handler := router.New(database, authMW)
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
handler := router.New(database, authMW, cfg)
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {

View File

@@ -8,14 +8,16 @@ import (
"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) *Middleware {
return &Middleware{jwtSecret: []byte(jwtSecret)}
func NewMiddleware(jwtSecret string, db *sqlx.DB) *Middleware {
return &Middleware{jwtSecret: []byte(jwtSecret), db: db}
}
func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
@@ -33,6 +35,17 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
}
ctx := ContextWithUserID(r.Context(), userID)
// Resolve tenant from user_tenants
var tenantID uuid.UUID
err = m.db.GetContext(r.Context(), &tenantID,
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -10,6 +10,7 @@ type Config struct {
DatabaseURL string
SupabaseURL string
SupabaseAnonKey string
SupabaseServiceKey string
SupabaseJWTSecret string
AnthropicAPIKey string
}
@@ -20,6 +21,7 @@ func Load() (*Config, error) {
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"),
}

View File

@@ -0,0 +1,205 @@
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}
}
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 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 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,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,158 @@
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"))
filter := services.CaseFilter{
Status: r.URL.Query().Get("status"),
Type: r.URL.Query().Get("type"),
Search: r.URL.Query().Get("search"),
Limit: limit,
Offset: offset,
}
cases, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
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
}
c, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
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 {
writeError(w, http.StatusInternalServerError, err.Error())
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
}
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
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,58 @@
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)
}
// 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,162 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DeadlineHandlers holds handlers for deadline CRUD endpoints
type DeadlineHandlers struct {
deadlines *services.DeadlineService
db *sqlx.DB
}
// NewDeadlineHandlers creates deadline handlers
func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHandlers {
return &DeadlineHandlers{deadlines: ds, db: db}
}
// ListForCase handles GET /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
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 {
writeError(w, http.StatusInternalServerError, "failed to list deadlines")
return
}
writeJSON(w, http.StatusOK, deadlines)
}
// Create handles POST /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
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
}
deadline, err := h.deadlines.Create(tenantID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create deadline")
return
}
writeJSON(w, http.StatusCreated, deadline)
}
// Update handles PUT /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
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(tenantID, deadlineID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update deadline")
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, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete deadline")
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, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
err = h.deadlines.Delete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,183 @@
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 {
writeError(w, http.StatusInternalServerError, err.Error())
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
}
writeError(w, http.StatusInternalServerError, err.Error())
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, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, 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 {
writeError(w, http.StatusInternalServerError, err.Error())
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())
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document ID")
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,85 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
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})
}
// resolveTenant gets the tenant ID for the authenticated user.
// Checks X-Tenant-ID header first, then falls back to user's first tenant.
func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
return uuid.Nil, errUnauthorized
}
// Check header first
if headerVal := r.Header.Get("X-Tenant-ID"); headerVal != "" {
tenantID, err := uuid.Parse(headerVal)
if err != nil {
return uuid.Nil, errInvalidTenant
}
// Verify user has access to this tenant
var count int
err = db.Get(&count,
`SELECT COUNT(*) FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil || count == 0 {
return uuid.Nil, errTenantAccess
}
return tenantID, nil
}
// Fall back to user's first tenant
var tenantID uuid.UUID
err := db.Get(&tenantID,
`SELECT tenant_id FROM user_tenants WHERE user_id = $1 ORDER BY created_at LIMIT 1`,
userID)
if err != nil {
return uuid.Nil, errNoTenant
}
return tenantID, nil
}
type apiError struct {
msg string
status int
}
func (e *apiError) Error() string { return e.msg }
var (
errUnauthorized = &apiError{msg: "unauthorized", status: http.StatusUnauthorized}
errInvalidTenant = &apiError{msg: "invalid tenant ID", status: http.StatusBadRequest}
errTenantAccess = &apiError{msg: "no access to tenant", status: http.StatusForbidden}
errNoTenant = &apiError{msg: "no tenant found for user", status: http.StatusBadRequest}
)
// handleTenantError writes the appropriate error response for tenant resolution errors
func handleTenantError(w http.ResponseWriter, err error) {
if ae, ok := err.(*apiError); ok {
writeError(w, ae.status, ae.msg)
return
}
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))
}

View File

@@ -0,0 +1,134 @@
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 {
writeError(w, http.StatusInternalServerError, err.Error())
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
}
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
}
writeError(w, http.StatusInternalServerError, err.Error())
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 {
writeError(w, http.StatusInternalServerError, err.Error())
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

@@ -7,21 +7,38 @@ import (
"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/services"
)
func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) http.Handler {
mux := http.NewServeMux()
// Services
tenantSvc := services.NewTenantService(db)
caseSvc := services.NewCaseService(db)
partySvc := services.NewPartyService(db)
appointmentSvc := services.NewAppointmentService(db)
holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli)
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
// Handlers
tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc)
apptH := handlers.NewAppointmentHandler(appointmentSvc)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
docH := handlers.NewDocumentHandler(documentSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
@@ -39,10 +56,46 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
// Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux()
scoped.HandleFunc("GET /api/cases", placeholder("cases"))
scoped.HandleFunc("GET /api/deadlines", placeholder("deadlines"))
scoped.HandleFunc("GET /api/appointments", placeholder("appointments"))
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
// Cases
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", caseH.Create)
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
// Parties
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
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
// Deadline rules (reference data)
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
// Deadline calculator
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", apptH.Create)
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
// Documents
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", 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)
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
api.Handle("/api/", tenantResolver.Resolve(scoped))
@@ -64,12 +117,3 @@ func handleHealth(db *sqlx.DB) http.HandlerFunc {
}
}
func placeholder(resource string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "not_implemented",
"resource": resource,
})
}
}

View File

@@ -0,0 +1,135 @@
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
}
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
return &AppointmentService{db: db}
}
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)
}
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")
}
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")
}
return nil
}

View File

@@ -0,0 +1,277 @@
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
}
func NewCaseService(db *sqlx.DB) *CaseService {
return &CaseService{db: db}
}
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)
}
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)
}
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)
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,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,180 @@
package services
import (
"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
}
// NewDeadlineService creates a new deadline service
func NewDeadlineService(db *sqlx.DB) *DeadlineService {
return &DeadlineService{db: db}
}
// 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(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)
}
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(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)
}
return &d, nil
}
// Complete marks a deadline as completed
func (s *DeadlineService) Complete(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)
}
return &d, nil
}
// Delete removes a deadline
func (s *DeadlineService) Delete(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")
}
return nil
}

View File

@@ -0,0 +1,163 @@
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
}
func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService {
return &DocumentService{db: db, storage: storage}
}
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)
}
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)
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()
}

View File

@@ -0,0 +1,121 @@
package services
import (
"testing"
"time"
)
func TestCalculateEasterSunday(t *testing.T) {
tests := []struct {
year int
wantMonth int
wantDay int
}{
{2024, 3, 31},
{2025, 4, 20},
{2026, 4, 5},
{2027, 3, 28},
}
for _, tt := range tests {
m, d := CalculateEasterSunday(tt.year)
if m != tt.wantMonth || d != tt.wantDay {
t.Errorf("CalculateEasterSunday(%d) = %d-%02d, want %d-%02d",
tt.year, m, d, tt.wantMonth, tt.wantDay)
}
}
}
func TestGermanFederalHolidays(t *testing.T) {
holidays := germanFederalHolidays(2026)
// Should have 11 federal holidays
if len(holidays) != 11 {
t.Fatalf("expected 11 federal holidays, got %d", len(holidays))
}
// Check Neujahr
if holidays[0].Name != "Neujahr" {
t.Errorf("first holiday should be Neujahr, got %s", holidays[0].Name)
}
if holidays[0].Date != time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) {
t.Errorf("Neujahr should be Jan 1, got %s", holidays[0].Date)
}
// Check Karfreitag 2026 (Easter = Apr 5, so Good Friday = Apr 3)
found := false
for _, h := range holidays {
if h.Name == "Karfreitag" {
found = true
expected := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
if h.Date != expected {
t.Errorf("Karfreitag 2026 should be %s, got %s", expected, h.Date)
}
}
}
if !found {
t.Error("Karfreitag not found in holidays")
}
}
func TestHolidayServiceIsNonWorkingDay(t *testing.T) {
svc := NewHolidayService(nil) // no DB, uses hardcoded holidays
// Saturday
sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(sat) {
t.Error("Saturday should be non-working day")
}
// Sunday
sun := time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(sun) {
t.Error("Sunday should be non-working day")
}
// Regular Monday
mon := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC)
if svc.IsNonWorkingDay(mon) {
t.Error("regular Monday should be a working day")
}
// Christmas (Friday Dec 25, 2026)
xmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(xmas) {
t.Error("Christmas should be non-working day")
}
// New Year
newyear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(newyear) {
t.Error("New Year should be non-working day")
}
}
func TestAdjustForNonWorkingDays(t *testing.T) {
svc := NewHolidayService(nil)
// Saturday -> Monday
sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC)
adj, orig, adjusted := svc.AdjustForNonWorkingDays(sat)
if !adjusted {
t.Error("Saturday should be adjusted")
}
if orig != sat {
t.Error("original should be unchanged")
}
expected := time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC)
if adj != expected {
t.Errorf("Saturday should adjust to Monday %s, got %s", expected, adj)
}
// Regular Wednesday -> no adjustment
wed := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
adj, _, adjusted = svc.AdjustForNonWorkingDays(wed)
if adjusted {
t.Error("Wednesday should not be adjusted")
}
if adj != wed {
t.Error("non-adjusted date should be unchanged")
}
}

View File

@@ -0,0 +1,152 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type PartyService struct {
db *sqlx.DB
}
func NewPartyService(db *sqlx.DB) *PartyService {
return &PartyService{db: db}
}
type CreatePartyInput struct {
Name string `json:"name"`
Role *string `json:"role,omitempty"`
Representative *string `json:"representative,omitempty"`
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
type UpdatePartyInput struct {
Name *string `json:"name,omitempty"`
Role *string `json:"role,omitempty"`
Representative *string `json:"representative,omitempty"`
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
func (s *PartyService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.Party, error) {
var parties []models.Party
err := s.db.SelectContext(ctx, &parties,
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("listing parties: %w", err)
}
return parties, nil
}
func (s *PartyService) Create(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input CreatePartyInput) (*models.Party, error) {
// Verify case exists and belongs to tenant
var exists bool
err := s.db.GetContext(ctx, &exists,
"SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)", caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("checking case: %w", err)
}
if !exists {
return nil, sql.ErrNoRows
}
id := uuid.New()
contactInfo := input.ContactInfo
if contactInfo == nil {
contactInfo = json.RawMessage("{}")
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO parties (id, tenant_id, case_id, name, role, representative, contact_info)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, tenantID, caseID, input.Name, input.Role, input.Representative, contactInfo)
if err != nil {
return nil, fmt.Errorf("creating party: %w", err)
}
// Log event
desc := fmt.Sprintf("Party added: %s", input.Name)
createEvent(ctx, s.db, tenantID, caseID, userID, "party_added", desc, nil)
var party models.Party
if err := s.db.GetContext(ctx, &party, "SELECT * FROM parties WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created party: %w", err)
}
return &party, nil
}
func (s *PartyService) Update(ctx context.Context, tenantID, partyID uuid.UUID, input UpdatePartyInput) (*models.Party, error) {
// Verify party exists and belongs to tenant
var current models.Party
err := s.db.GetContext(ctx, &current,
"SELECT * FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("fetching party: %w", err)
}
sets := []string{}
args := []interface{}{}
argIdx := 1
if input.Name != nil {
sets = append(sets, fmt.Sprintf("name = $%d", argIdx))
args = append(args, *input.Name)
argIdx++
}
if input.Role != nil {
sets = append(sets, fmt.Sprintf("role = $%d", argIdx))
args = append(args, *input.Role)
argIdx++
}
if input.Representative != nil {
sets = append(sets, fmt.Sprintf("representative = $%d", argIdx))
args = append(args, *input.Representative)
argIdx++
}
if input.ContactInfo != nil {
sets = append(sets, fmt.Sprintf("contact_info = $%d", argIdx))
args = append(args, input.ContactInfo)
argIdx++
}
if len(sets) == 0 {
return &current, nil
}
query := fmt.Sprintf("UPDATE parties SET %s WHERE id = $%d AND tenant_id = $%d",
joinStrings(sets, ", "), argIdx, argIdx+1)
args = append(args, partyID, tenantID)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("updating party: %w", err)
}
var updated models.Party
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM parties WHERE id = $1", partyID); err != nil {
return nil, fmt.Errorf("fetching updated party: %w", err)
}
return &updated, nil
}
func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
"DELETE FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
if err != nil {
return fmt.Errorf("deleting party: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -0,0 +1,112 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// StorageClient interacts with Supabase Storage via REST API.
type StorageClient struct {
baseURL string
serviceKey string
httpClient *http.Client
}
func NewStorageClient(supabaseURL, serviceKey string) *StorageClient {
return &StorageClient{
baseURL: supabaseURL,
serviceKey: serviceKey,
httpClient: &http.Client{},
}
}
// Upload stores a file in the given bucket at the specified path.
func (s *StorageClient) Upload(ctx context.Context, bucket, path, contentType string, data io.Reader) error {
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, "POST", url, data)
if err != nil {
return fmt.Errorf("creating upload request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", contentType)
req.Header.Set("x-upsert", "true")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("uploading to storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage upload failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// Download retrieves a file from storage. Caller must close the returned ReadCloser.
func (s *StorageClient) Download(ctx context.Context, bucket, path string) (io.ReadCloser, string, error) {
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", fmt.Errorf("creating download request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("downloading from storage: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, "", fmt.Errorf("file not found in storage")
}
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("storage download failed (status %d): %s", resp.StatusCode, string(body))
}
ct := resp.Header.Get("Content-Type")
return resp.Body, ct, nil
}
// Delete removes files from storage by their paths.
func (s *StorageClient) Delete(ctx context.Context, bucket string, paths []string) error {
url := fmt.Sprintf("%s/storage/v1/object/%s", s.baseURL, bucket)
body, err := json.Marshal(map[string][]string{"prefixes": paths})
if err != nil {
return fmt.Errorf("marshaling delete request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "DELETE", url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("deleting from storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage delete failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}