Compare commits

..

8 Commits

Author SHA1 Message Date
m
e53e1389f9 feat: add dashboard aggregation endpoint (Phase 2I)
GET /api/dashboard returns aggregated data:
- deadline_summary: overdue, due this/next week, ok counts
- case_summary: active, new this month, closed counts
- upcoming_deadlines: next 7 days with case info
- upcoming_appointments: next 7 days
- recent_activity: last 10 case events

Uses efficient CTE query for summaries. Also fixes duplicate
writeJSON/writeError declarations in appointments handler.
2026-03-25 13:37:06 +01:00
m
2c16f26448 feat: add deadline CRUD, calculator, and holiday services (Phase 1C) 2026-03-25 13:33:57 +01:00
m
f0ee5921cf feat: add appointment CRUD backend (Phase 1D) 2026-03-25 13:32:51 +01:00
m
ba29fc75c7 feat: add case + party CRUD with case events (Phase 1B) 2026-03-25 13:32:15 +01:00
m
8350a7e7fb feat: add tenant + auth backend endpoints (Phase 1A) 2026-03-25 13:31:38 +01:00
m
0b6bab8512 feat: add tenant + auth backend endpoints (Phase 1A)
Tenant management:
- POST /api/tenants — create tenant (creator becomes owner)
- GET /api/tenants — list tenants for authenticated user
- GET /api/tenants/:id — tenant details with access check
- POST /api/tenants/:id/invite — invite user by email (owner/admin)
- DELETE /api/tenants/:id/members/:uid — remove member
- GET /api/tenants/:id/members — list members

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

Authorization: owners/admins can invite and remove members. Cannot
remove the last owner. Users can remove themselves. TenantResolver
applies to resource routes (cases, deadlines, etc.) but not tenant
management routes.
2026-03-25 13:27:39 +01:00
m
f11c411147 feat: add case + party CRUD with case events (Phase 1B)
- CaseService: list (paginated, filterable), get detail (with parties,
  events, deadline count), create, update, soft-delete (archive)
- PartyService: list by case, create, update, delete
- Auto-create case_events on case creation, status change, party add,
  and case archive
- Auth middleware now resolves tenant_id from user_tenants table
- All operations scoped to tenant_id from auth context
2026-03-25 13:26:50 +01:00
m
bd15b4eb38 feat: add appointment CRUD backend (Phase 1D)
- AppointmentService with tenant-scoped List, GetByID, Create, Update, Delete
- List supports filtering by case_id, appointment_type, and date range (start_from/start_to)
- AppointmentHandler with JSON request/response handling and input validation
- Router wired up: GET/POST /api/appointments, PUT/DELETE /api/appointments/{id}
2026-03-25 13:25:46 +01:00
45 changed files with 2391 additions and 19 deletions

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

1
.gitignore vendored
View File

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

4
.m/.gitignore vendored Normal file
View File

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

168
.m/config.yaml Normal file
View File

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

22
.mcp.json Normal file
View File

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

1
AGENTS.md Symbolic link
View File

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

View File

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

View File

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

View File

@@ -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))
})
}

View 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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})
}

View File

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

View File

@@ -22,3 +22,9 @@ type UserTenant struct {
Role string `db:"role" json:"role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// TenantWithRole is a Tenant joined with the user's role in that tenant.
type TenantWithRole struct {
Tenant
Role string `db:"role" json:"role"`
}

View File

@@ -12,18 +12,32 @@ import (
)
func New(db *sqlx.DB, authMW *auth.Middleware) 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)
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
dashboardSvc := services.NewDashboardService(db)
// 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)
mux := http.NewServeMux()
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
@@ -31,24 +45,58 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
// Authenticated API routes
api := http.NewServeMux()
// Deadline CRUD (case-scoped)
api.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
api.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
api.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
api.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
api.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
// 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)
// Deadline rules (public reference data, but behind auth)
api.HandleFunc("GET /api/deadline-rules", ruleH.List)
api.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
// 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
api.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Placeholder routes (not yet implemented)
api.HandleFunc("GET /api/cases", placeholder("cases"))
api.HandleFunc("GET /api/appointments", placeholder("appointments"))
api.HandleFunc("GET /api/documents", placeholder("documents"))
// 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)
// Dashboard
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Placeholder routes for future phases
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
api.Handle("/api/", tenantResolver.Resolve(scoped))
mux.Handle("/api/", authMW.RequireAuth(api))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
}