Compare commits
15 Commits
mai/knuth/
...
mai/linus/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd8cc9e07 | ||
|
|
2c16f26448 | ||
|
|
f0ee5921cf | ||
|
|
ba29fc75c7 | ||
|
|
8350a7e7fb | ||
|
|
42a62d45bf | ||
|
|
0b6bab8512 | ||
|
|
f11c411147 | ||
|
|
bd15b4eb38 | ||
|
|
8049ea3c63 | ||
|
|
1fc0874893 | ||
|
|
193a4cd567 | ||
|
|
792d084b4f | ||
|
|
ff9a6f3866 | ||
|
|
83a18a0a85 |
14
.claude/agents/coder.md
Normal file
14
.claude/agents/coder.md
Normal 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.
|
||||||
14
.claude/agents/researcher.md
Normal file
14
.claude/agents/researcher.md
Normal 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.
|
||||||
14
.claude/agents/reviewer.md
Normal file
14
.claude/agents/reviewer.md
Normal 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
1
.claude/skills/mai-clone
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-clone
|
||||||
1
.claude/skills/mai-coder
Symbolic link
1
.claude/skills/mai-coder
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-coder
|
||||||
1
.claude/skills/mai-commit
Symbolic link
1
.claude/skills/mai-commit
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-commit
|
||||||
1
.claude/skills/mai-consultant
Symbolic link
1
.claude/skills/mai-consultant
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-consultant
|
||||||
1
.claude/skills/mai-daily
Symbolic link
1
.claude/skills/mai-daily
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-daily
|
||||||
1
.claude/skills/mai-debrief
Symbolic link
1
.claude/skills/mai-debrief
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-debrief
|
||||||
1
.claude/skills/mai-enemy
Symbolic link
1
.claude/skills/mai-enemy
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-enemy
|
||||||
1
.claude/skills/mai-excalidraw
Symbolic link
1
.claude/skills/mai-excalidraw
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-excalidraw
|
||||||
1
.claude/skills/mai-fixer
Symbolic link
1
.claude/skills/mai-fixer
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-fixer
|
||||||
1
.claude/skills/mai-gitster
Symbolic link
1
.claude/skills/mai-gitster
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-gitster
|
||||||
1
.claude/skills/mai-head
Symbolic link
1
.claude/skills/mai-head
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-head
|
||||||
1
.claude/skills/mai-init
Symbolic link
1
.claude/skills/mai-init
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-init
|
||||||
1
.claude/skills/mai-inventor
Symbolic link
1
.claude/skills/mai-inventor
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-inventor
|
||||||
1
.claude/skills/mai-lead
Symbolic link
1
.claude/skills/mai-lead
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-lead
|
||||||
1
.claude/skills/mai-maister
Symbolic link
1
.claude/skills/mai-maister
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-maister
|
||||||
1
.claude/skills/mai-member
Symbolic link
1
.claude/skills/mai-member
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-member
|
||||||
1
.claude/skills/mai-researcher
Symbolic link
1
.claude/skills/mai-researcher
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-researcher
|
||||||
1
.claude/skills/mai-think
Symbolic link
1
.claude/skills/mai-think
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-think
|
||||||
1
.claude/skills/mai-web
Symbolic link
1
.claude/skills/mai-web
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/home/m/.mai/skills/mai-web
|
||||||
@@ -7,6 +7,7 @@ PORT=8080
|
|||||||
# Supabase (required for database access)
|
# Supabase (required for database access)
|
||||||
SUPABASE_URL=
|
SUPABASE_URL=
|
||||||
SUPABASE_ANON_KEY=
|
SUPABASE_ANON_KEY=
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
|
||||||
# Claude API (required for AI features)
|
# Claude API (required for AI features)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ tmp/
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.worktrees/
|
||||||
|
|||||||
4
.m/.gitignore
vendored
Normal file
4
.m/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
workers.json
|
||||||
|
spawn.lock
|
||||||
|
session.yaml
|
||||||
|
config.reference.yaml
|
||||||
168
.m/config.yaml
Normal file
168
.m/config.yaml
Normal 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
22
.mcp.json
Normal 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,6 +1,6 @@
|
|||||||
# KanzlAI
|
# KanzlAI-mGMT
|
||||||
|
|
||||||
AI-powered toolkit for patent litigation — UPC case law search, analysis, and AI-assisted legal research.
|
Kanzleimanagement online — law firm management for deadlines (Fristen), appointments (Termine), and case tracking.
|
||||||
|
|
||||||
**Memory group_id:** `kanzlai`
|
**Memory group_id:** `kanzlai`
|
||||||
|
|
||||||
@@ -18,9 +18,8 @@ frontend/ Next.js 15 (TypeScript, Tailwind CSS, App Router)
|
|||||||
|
|
||||||
- **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun
|
- **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun
|
||||||
- **Backend:** Go (standard library HTTP server)
|
- **Backend:** Go (standard library HTTP server)
|
||||||
- **Database:** Supabase (PostgreSQL) — shared instance with other m projects
|
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema in flexsiebels instance
|
||||||
- **AI:** Claude API
|
- **Deploy:** Dokploy on mLake, domain: kanzlai.msbls.de
|
||||||
- **Deploy:** mRiver with Caddy reverse proxy
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -1,6 +1,6 @@
|
|||||||
# KanzlAI
|
# KanzlAI-mGMT
|
||||||
|
|
||||||
AI-powered toolkit for patent litigation — starting with UPC case law search and analysis.
|
Kanzleimanagement online — law firm management for deadlines, appointments, and case tracking.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -12,26 +12,16 @@ frontend/ Next.js 15 (TypeScript, Tailwind CSS)
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
make dev-backend # Go server on :8080
|
||||||
make dev-backend
|
make dev-frontend # Next.js dev server
|
||||||
|
make build # Build both
|
||||||
# Frontend
|
make lint # Lint both
|
||||||
make dev-frontend
|
make test # Test both
|
||||||
|
|
||||||
# Build all
|
|
||||||
make build
|
|
||||||
|
|
||||||
# Lint all
|
|
||||||
make lint
|
|
||||||
|
|
||||||
# Test all
|
|
||||||
make test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
|
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
|
||||||
- **Backend:** Go
|
- **Backend:** Go
|
||||||
- **Database:** Supabase (PostgreSQL)
|
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema
|
||||||
- **AI:** Claude API
|
- **Deploy:** Dokploy on mLake (kanzlai.msbls.de)
|
||||||
- **Deploy:** mRiver + Caddy
|
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
|
"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/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := os.Getenv("PORT")
|
cfg, err := config.Load()
|
||||||
if port == "" {
|
if err != nil {
|
||||||
port = "8080"
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
database, err := db.Connect(cfg.DatabaseURL)
|
||||||
w.WriteHeader(http.StatusOK)
|
if err != nil {
|
||||||
fmt.Fprintf(w, "ok")
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
})
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
log.Printf("Starting KanzlAI API server on :%s", port)
|
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
|
||||||
if err := http.ListenAndServe(":"+port, nil); err != nil {
|
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 {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
module mgit.msbls.de/m/KanzlAI
|
module mgit.msbls.de/m/KanzlAI-mGMT
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||||
|
github.com/lib/pq v1.12.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
12
backend/go.sum
Normal file
12
backend/go.sum
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
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/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
32
backend/internal/auth/context.go
Normal file
32
backend/internal/auth/context.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
userIDKey contextKey = "user_id"
|
||||||
|
tenantIDKey contextKey = "tenant_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
102
backend/internal/auth/middleware.go
Normal file
102
backend/internal/auth/middleware.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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, "missing authorization token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := m.verifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
61
backend/internal/auth/tenant_resolver.go
Normal file
61
backend/internal/auth/tenant_resolver.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantLookup resolves the default tenant 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
|
||||||
|
// or defaults to the user's first tenant.
|
||||||
|
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, "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, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID = parsed
|
||||||
|
} else {
|
||||||
|
// Default to user's first tenant
|
||||||
|
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if first == nil {
|
||||||
|
http.Error(w, "no tenant found for user", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID = *first
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := ContextWithTenantID(r.Context(), tenantID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
124
backend/internal/auth/tenant_resolver_test.go
Normal file
124
backend/internal/auth/tenant_resolver_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockTenantLookup struct {
|
||||||
|
tenantID *uuid.UUID
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
||||||
|
return m.tenantID, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantResolver_FromHeader(t *testing.T) {
|
||||||
|
tenantID := uuid.New()
|
||||||
|
tr := NewTenantResolver(&mockTenantLookup{})
|
||||||
|
|
||||||
|
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_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/internal/config/config.go
Normal file
44
backend/internal/config/config.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DatabaseURL string
|
||||||
|
SupabaseURL string
|
||||||
|
SupabaseAnonKey string
|
||||||
|
SupabaseServiceKey string
|
||||||
|
SupabaseJWTSecret string
|
||||||
|
AnthropicAPIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
26
backend/internal/db/connection.go
Normal file
26
backend/internal/db/connection.go
Normal 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
|
||||||
|
}
|
||||||
205
backend/internal/handlers/appointments.go
Normal file
205
backend/internal/handlers/appointments.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
89
backend/internal/handlers/calculate.go
Normal file
89
backend/internal/handlers/calculate.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
backend/internal/handlers/cases.go
Normal file
158
backend/internal/handlers/cases.go
Normal 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"})
|
||||||
|
}
|
||||||
58
backend/internal/handlers/deadline_rules.go
Normal file
58
backend/internal/handlers/deadline_rules.go
Normal 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)
|
||||||
|
}
|
||||||
162
backend/internal/handlers/deadlines.go
Normal file
162
backend/internal/handlers/deadlines.go
Normal 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"})
|
||||||
|
}
|
||||||
183
backend/internal/handlers/documents.go
Normal file
183
backend/internal/handlers/documents.go
Normal 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"})
|
||||||
|
}
|
||||||
85
backend/internal/handlers/helpers.go
Normal file
85
backend/internal/handlers/helpers.go
Normal 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))
|
||||||
|
}
|
||||||
134
backend/internal/handlers/parties.go
Normal file
134
backend/internal/handlers/parties.go
Normal 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)
|
||||||
|
}
|
||||||
243
backend/internal/handlers/tenant_handler.go
Normal file
243
backend/internal/handlers/tenant_handler.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
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 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 {
|
||||||
|
jsonError(w, err.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 {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
jsonError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := h.svc.GetByID(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tenant == nil {
|
||||||
|
jsonError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 admins can invite
|
||||||
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role != "owner" && role != "admin" {
|
||||||
|
jsonError(w, "only owners and admins 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 = "member"
|
||||||
|
}
|
||||||
|
if req.Role != "member" && req.Role != "admin" {
|
||||||
|
jsonError(w, "role must be member or admin", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, err.Error(), 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 admins can remove members (or user removing themselves)
|
||||||
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role != "owner" && role != "admin" && userID != memberID {
|
||||||
|
jsonError(w, "insufficient permissions", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]string{"status": "removed"}, 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 {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
jsonError(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := h.svc.ListMembers(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, members, 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})
|
||||||
|
}
|
||||||
132
backend/internal/handlers/tenant_handler_test.go
Normal file
132
backend/internal/handlers/tenant_handler_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/internal/models/appointment.go
Normal file
23
backend/internal/models/appointment.go
Normal 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"`
|
||||||
|
}
|
||||||
23
backend/internal/models/case.go
Normal file
23
backend/internal/models/case.go
Normal 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"`
|
||||||
|
}
|
||||||
22
backend/internal/models/case_event.go
Normal file
22
backend/internal/models/case_event.go
Normal 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"`
|
||||||
|
}
|
||||||
27
backend/internal/models/deadline.go
Normal file
27
backend/internal/models/deadline.go
Normal 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"`
|
||||||
|
}
|
||||||
43
backend/internal/models/deadline_rule.go
Normal file
43
backend/internal/models/deadline_rule.go
Normal 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"`
|
||||||
|
}
|
||||||
23
backend/internal/models/document.go
Normal file
23
backend/internal/models/document.go
Normal 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"`
|
||||||
|
}
|
||||||
17
backend/internal/models/party.go
Normal file
17
backend/internal/models/party.go
Normal 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"`
|
||||||
|
}
|
||||||
30
backend/internal/models/tenant.go
Normal file
30
backend/internal/models/tenant.go
Normal 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"`
|
||||||
|
}
|
||||||
119
backend/internal/router/router.go
Normal file
119
backend/internal/router/router.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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, 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))
|
||||||
|
|
||||||
|
// 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("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)
|
||||||
|
|
||||||
|
// Tenant-scoped routes (require tenant context)
|
||||||
|
scoped := http.NewServeMux()
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
mux.Handle("/api/", authMW.RequireAuth(api))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
135
backend/internal/services/appointment_service.go
Normal file
135
backend/internal/services/appointment_service.go
Normal 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
|
||||||
|
}
|
||||||
277
backend/internal/services/case_service.go
Normal file
277
backend/internal/services/case_service.go
Normal 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, ¤t,
|
||||||
|
"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 ¤t, 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
|
||||||
|
}
|
||||||
99
backend/internal/services/deadline_calculator.go
Normal file
99
backend/internal/services/deadline_calculator.go
Normal 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
|
||||||
|
}
|
||||||
141
backend/internal/services/deadline_calculator_test.go
Normal file
141
backend/internal/services/deadline_calculator_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
175
backend/internal/services/deadline_rule_service.go
Normal file
175
backend/internal/services/deadline_rule_service.go
Normal 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
|
||||||
|
}
|
||||||
180
backend/internal/services/deadline_service.go
Normal file
180
backend/internal/services/deadline_service.go
Normal 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
|
||||||
|
}
|
||||||
163
backend/internal/services/document_service.go
Normal file
163
backend/internal/services/document_service.go
Normal 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
|
||||||
|
}
|
||||||
193
backend/internal/services/holidays.go
Normal file
193
backend/internal/services/holidays.go
Normal 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()
|
||||||
|
}
|
||||||
121
backend/internal/services/holidays_test.go
Normal file
121
backend/internal/services/holidays_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
152
backend/internal/services/party_service.go
Normal file
152
backend/internal/services/party_service.go
Normal 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, ¤t,
|
||||||
|
"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 ¤t, 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
|
||||||
|
}
|
||||||
112
backend/internal/services/storage.go
Normal file
112
backend/internal/services/storage.go
Normal 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
|
||||||
|
}
|
||||||
211
backend/internal/services/tenant_service.go
Normal file
211
backend/internal/services/tenant_service.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TenantService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTenantService(db *sqlx.DB) *TenantService {
|
||||||
|
return &TenantService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new tenant and assigns the creator as owner.
|
||||||
|
func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug string) (*models.Tenant, error) {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var tenant models.Tenant
|
||||||
|
err = tx.QueryRowxContext(ctx,
|
||||||
|
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id, name, slug, settings, created_at, updated_at`,
|
||||||
|
name, slug,
|
||||||
|
).StructScan(&tenant)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert tenant: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, 'owner')`,
|
||||||
|
userID, tenant.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("assign owner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tenant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForUser returns all tenants the user belongs to.
|
||||||
|
func (s *TenantService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.TenantWithRole, error) {
|
||||||
|
var tenants []models.TenantWithRole
|
||||||
|
err := s.db.SelectContext(ctx, &tenants,
|
||||||
|
`SELECT t.id, t.name, t.slug, t.settings, t.created_at, t.updated_at, ut.role
|
||||||
|
FROM tenants t
|
||||||
|
JOIN user_tenants ut ON ut.tenant_id = t.id
|
||||||
|
WHERE ut.user_id = $1
|
||||||
|
ORDER BY t.name`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tenants: %w", err)
|
||||||
|
}
|
||||||
|
return tenants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a single tenant. The caller must verify the user has access.
|
||||||
|
func (s *TenantService) GetByID(ctx context.Context, tenantID uuid.UUID) (*models.Tenant, error) {
|
||||||
|
var tenant models.Tenant
|
||||||
|
err := s.db.GetContext(ctx, &tenant,
|
||||||
|
`SELECT id, name, slug, settings, created_at, updated_at FROM tenants WHERE id = $1`,
|
||||||
|
tenantID,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get tenant: %w", err)
|
||||||
|
}
|
||||||
|
return &tenant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserRole returns the user's role in a tenant, or empty string if not a member.
|
||||||
|
func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
|
||||||
|
var role string
|
||||||
|
err := s.db.GetContext(ctx, &role,
|
||||||
|
`SELECT role FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
|
||||||
|
userID, tenantID,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get user role: %w", err)
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstTenantForUser returns the user's first tenant (by name), used as default.
|
||||||
|
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
||||||
|
var tenantID uuid.UUID
|
||||||
|
err := s.db.GetContext(ctx, &tenantID,
|
||||||
|
`SELECT t.id FROM tenants t
|
||||||
|
JOIN user_tenants ut ON ut.tenant_id = t.id
|
||||||
|
WHERE ut.user_id = $1
|
||||||
|
ORDER BY t.name LIMIT 1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("first tenant: %w", err)
|
||||||
|
}
|
||||||
|
return &tenantID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembers returns all members of a tenant.
|
||||||
|
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
|
||||||
|
var members []models.UserTenant
|
||||||
|
err := s.db.SelectContext(ctx, &members,
|
||||||
|
`SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`,
|
||||||
|
tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list members: %w", err)
|
||||||
|
}
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteByEmail looks up a user by email in auth.users and adds them to the tenant.
|
||||||
|
func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, email, role string) (*models.UserTenant, error) {
|
||||||
|
// Look up user in Supabase auth.users
|
||||||
|
var userID uuid.UUID
|
||||||
|
err := s.db.GetContext(ctx, &userID,
|
||||||
|
`SELECT id FROM auth.users WHERE email = $1`,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("no user found with email %s", email)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lookup user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already a member
|
||||||
|
var exists bool
|
||||||
|
err = s.db.GetContext(ctx, &exists,
|
||||||
|
`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 exists {
|
||||||
|
return nil, fmt.Errorf("user is already a member of this tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ut models.UserTenant
|
||||||
|
err = s.db.QueryRowxContext(ctx,
|
||||||
|
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, $3)
|
||||||
|
RETURNING user_id, tenant_id, role, created_at`,
|
||||||
|
userID, tenantID, role,
|
||||||
|
).StructScan(&ut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invite user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ut, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
|
||||||
|
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
|
||||||
|
// Check if the user being removed is an owner
|
||||||
|
role, err := s.GetUserRole(ctx, userID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check role: %w", err)
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
return fmt.Errorf("user is not a member of this tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
if role == "owner" {
|
||||||
|
// Count owners — prevent removing the last one
|
||||||
|
var ownerCount int
|
||||||
|
err := s.db.GetContext(ctx, &ownerCount,
|
||||||
|
`SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`,
|
||||||
|
tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("count owners: %w", err)
|
||||||
|
}
|
||||||
|
if ownerCount <= 1 {
|
||||||
|
return fmt.Errorf("cannot remove the last owner of a tenant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
|
||||||
|
userID, tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove member: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
expose:
|
expose:
|
||||||
- "8080"
|
- "8080"
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -18,15 +16,15 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
ports:
|
expose:
|
||||||
- "3000:3000"
|
- "3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
env_file:
|
environment:
|
||||||
- .env
|
- API_URL=http://backend:8080
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{if(!r.ok)throw r.status;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "kanzlai-mgmt",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "KanzlAI",
|
title: "KanzlAI-mGMT",
|
||||||
description: "AI-powered toolkit for patent litigation",
|
description: "Kanzleimanagement online",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center">
|
<main className="flex min-h-screen items-center justify-center">
|
||||||
<h1 className="text-4xl font-bold">KanzlAI</h1>
|
<h1 className="text-4xl font-bold">KanzlAI-mGMT</h1>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user