diff --git a/.claude/agents/coder.md b/.claude/agents/coder.md new file mode 100644 index 0000000..400d943 --- /dev/null +++ b/.claude/agents/coder.md @@ -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. diff --git a/.claude/agents/researcher.md b/.claude/agents/researcher.md new file mode 100644 index 0000000..d25b77e --- /dev/null +++ b/.claude/agents/researcher.md @@ -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. diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md new file mode 100644 index 0000000..a2af646 --- /dev/null +++ b/.claude/agents/reviewer.md @@ -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. diff --git a/.claude/skills/mai-clone b/.claude/skills/mai-clone new file mode 120000 index 0000000..d622052 --- /dev/null +++ b/.claude/skills/mai-clone @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-clone \ No newline at end of file diff --git a/.claude/skills/mai-coder b/.claude/skills/mai-coder new file mode 120000 index 0000000..261b952 --- /dev/null +++ b/.claude/skills/mai-coder @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-coder \ No newline at end of file diff --git a/.claude/skills/mai-commit b/.claude/skills/mai-commit new file mode 120000 index 0000000..8446727 --- /dev/null +++ b/.claude/skills/mai-commit @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-commit \ No newline at end of file diff --git a/.claude/skills/mai-consultant b/.claude/skills/mai-consultant new file mode 120000 index 0000000..c336f9d --- /dev/null +++ b/.claude/skills/mai-consultant @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-consultant \ No newline at end of file diff --git a/.claude/skills/mai-daily b/.claude/skills/mai-daily new file mode 120000 index 0000000..b1ee4e1 --- /dev/null +++ b/.claude/skills/mai-daily @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-daily \ No newline at end of file diff --git a/.claude/skills/mai-debrief b/.claude/skills/mai-debrief new file mode 120000 index 0000000..9f8d783 --- /dev/null +++ b/.claude/skills/mai-debrief @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-debrief \ No newline at end of file diff --git a/.claude/skills/mai-enemy b/.claude/skills/mai-enemy new file mode 120000 index 0000000..b7847f4 --- /dev/null +++ b/.claude/skills/mai-enemy @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-enemy \ No newline at end of file diff --git a/.claude/skills/mai-excalidraw b/.claude/skills/mai-excalidraw new file mode 120000 index 0000000..8ce79cd --- /dev/null +++ b/.claude/skills/mai-excalidraw @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-excalidraw \ No newline at end of file diff --git a/.claude/skills/mai-fixer b/.claude/skills/mai-fixer new file mode 120000 index 0000000..a071909 --- /dev/null +++ b/.claude/skills/mai-fixer @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-fixer \ No newline at end of file diff --git a/.claude/skills/mai-gitster b/.claude/skills/mai-gitster new file mode 120000 index 0000000..f7edfe0 --- /dev/null +++ b/.claude/skills/mai-gitster @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-gitster \ No newline at end of file diff --git a/.claude/skills/mai-head b/.claude/skills/mai-head new file mode 120000 index 0000000..ac0e255 --- /dev/null +++ b/.claude/skills/mai-head @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-head \ No newline at end of file diff --git a/.claude/skills/mai-init b/.claude/skills/mai-init new file mode 120000 index 0000000..62fadcd --- /dev/null +++ b/.claude/skills/mai-init @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-init \ No newline at end of file diff --git a/.claude/skills/mai-inventor b/.claude/skills/mai-inventor new file mode 120000 index 0000000..488b600 --- /dev/null +++ b/.claude/skills/mai-inventor @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-inventor \ No newline at end of file diff --git a/.claude/skills/mai-lead b/.claude/skills/mai-lead new file mode 120000 index 0000000..c9fc1a4 --- /dev/null +++ b/.claude/skills/mai-lead @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-lead \ No newline at end of file diff --git a/.claude/skills/mai-maister b/.claude/skills/mai-maister new file mode 120000 index 0000000..4c3817f --- /dev/null +++ b/.claude/skills/mai-maister @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-maister \ No newline at end of file diff --git a/.claude/skills/mai-member b/.claude/skills/mai-member new file mode 120000 index 0000000..c875233 --- /dev/null +++ b/.claude/skills/mai-member @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-member \ No newline at end of file diff --git a/.claude/skills/mai-researcher b/.claude/skills/mai-researcher new file mode 120000 index 0000000..1cefb30 --- /dev/null +++ b/.claude/skills/mai-researcher @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-researcher \ No newline at end of file diff --git a/.claude/skills/mai-think b/.claude/skills/mai-think new file mode 120000 index 0000000..a8eff99 --- /dev/null +++ b/.claude/skills/mai-think @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-think \ No newline at end of file diff --git a/.claude/skills/mai-web b/.claude/skills/mai-web new file mode 120000 index 0000000..79fc5fa --- /dev/null +++ b/.claude/skills/mai-web @@ -0,0 +1 @@ +/home/m/.mai/skills/mai-web \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4b82cb0..bb3e3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ tmp/ # TypeScript *.tsbuildinfo +.worktrees/ diff --git a/.m/.gitignore b/.m/.gitignore new file mode 100644 index 0000000..1864979 --- /dev/null +++ b/.m/.gitignore @@ -0,0 +1,4 @@ +workers.json +spawn.lock +session.yaml +config.reference.yaml diff --git a/.m/config.yaml b/.m/config.yaml new file mode 100644 index 0000000..3d11d2b --- /dev/null +++ b/.m/config.yaml @@ -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 diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..97eb151 --- /dev/null +++ b/.mcp.json @@ -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" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..ac55cbd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.claude/CLAUDE.md \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6f99f13..7bffddc 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 26896e1..4f31eb6 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -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)) }) } diff --git a/backend/internal/handlers/cases.go b/backend/internal/handlers/cases.go new file mode 100644 index 0000000..a10d9d5 --- /dev/null +++ b/backend/internal/handlers/cases.go @@ -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"}) +} diff --git a/backend/internal/handlers/helpers.go b/backend/internal/handlers/helpers.go new file mode 100644 index 0000000..ce23c1d --- /dev/null +++ b/backend/internal/handlers/helpers.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/backend/internal/handlers/parties.go b/backend/internal/handlers/parties.go new file mode 100644 index 0000000..178e06b --- /dev/null +++ b/backend/internal/handlers/parties.go @@ -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) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 296dccf..31c31a5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -16,12 +16,16 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { // Services tenantSvc := services.NewTenantService(db) + caseSvc := services.NewCaseService(db) + partySvc := services.NewPartyService(db) // Middleware tenantResolver := auth.NewTenantResolver(tenantSvc) // Handlers tenantH := handlers.NewTenantHandler(tenantSvc) + caseH := handlers.NewCaseHandler(caseSvc) + partyH := handlers.NewPartyHandler(partySvc) // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -39,7 +43,21 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { // Tenant-scoped routes (require tenant context) scoped := http.NewServeMux() - scoped.HandleFunc("GET /api/cases", placeholder("cases")) + + // 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) + + // Placeholder routes for future phases scoped.HandleFunc("GET /api/deadlines", placeholder("deadlines")) scoped.HandleFunc("GET /api/appointments", placeholder("appointments")) scoped.HandleFunc("GET /api/documents", placeholder("documents")) diff --git a/backend/internal/services/case_service.go b/backend/internal/services/case_service.go new file mode 100644 index 0000000..dbed424 --- /dev/null +++ b/backend/internal/services/case_service.go @@ -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 +} diff --git a/backend/internal/services/party_service.go b/backend/internal/services/party_service.go new file mode 100644 index 0000000..8aeb3a3 --- /dev/null +++ b/backend/internal/services/party_service.go @@ -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 +}