Compare commits
9 Commits
mai/knuth/
...
mai/ritchi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11c411147 | ||
|
|
8049ea3c63 | ||
|
|
1fc0874893 | ||
|
|
193a4cd567 | ||
|
|
792d084b4f | ||
|
|
ff9a6f3866 | ||
|
|
83a18a0a85 | ||
|
|
b797b349e7 | ||
|
|
b2139b046e |
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# KanzlAI Environment Variables
|
||||||
|
# Copy to .env and fill in values: cp .env.example .env
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Supabase (required for database access)
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
# Claude API (required for AI features)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# KanzlAI
|
# KanzlAI-mGMT
|
||||||
|
|
||||||
AI-powered toolkit for patent litigation — UPC case law search, analysis, and AI-assisted legal research.
|
Kanzleimanagement online — law firm management for deadlines (Fristen), appointments (Termine), and case tracking.
|
||||||
|
|
||||||
**Memory group_id:** `kanzlai`
|
**Memory group_id:** `kanzlai`
|
||||||
|
|
||||||
@@ -18,9 +18,8 @@ frontend/ Next.js 15 (TypeScript, Tailwind CSS, App Router)
|
|||||||
|
|
||||||
- **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun
|
- **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun
|
||||||
- **Backend:** Go (standard library HTTP server)
|
- **Backend:** Go (standard library HTTP server)
|
||||||
- **Database:** Supabase (PostgreSQL) — shared instance with other m projects
|
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema in flexsiebels instance
|
||||||
- **AI:** Claude API
|
- **Deploy:** Dokploy on mLake, domain: kanzlai.msbls.de
|
||||||
- **Deploy:** mRiver with Caddy reverse proxy
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -1,6 +1,6 @@
|
|||||||
# KanzlAI
|
# KanzlAI-mGMT
|
||||||
|
|
||||||
AI-powered toolkit for patent litigation — starting with UPC case law search and analysis.
|
Kanzleimanagement online — law firm management for deadlines, appointments, and case tracking.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -12,26 +12,16 @@ frontend/ Next.js 15 (TypeScript, Tailwind CSS)
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
make dev-backend # Go server on :8080
|
||||||
make dev-backend
|
make dev-frontend # Next.js dev server
|
||||||
|
make build # Build both
|
||||||
# Frontend
|
make lint # Lint both
|
||||||
make dev-frontend
|
make test # Test both
|
||||||
|
|
||||||
# Build all
|
|
||||||
make build
|
|
||||||
|
|
||||||
# Lint all
|
|
||||||
make lint
|
|
||||||
|
|
||||||
# Test all
|
|
||||||
make test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
|
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
|
||||||
- **Backend:** Go
|
- **Backend:** Go
|
||||||
- **Database:** Supabase (PostgreSQL)
|
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema
|
||||||
- **AI:** Claude API
|
- **Deploy:** Dokploy on mLake (kanzlai.msbls.de)
|
||||||
- **Deploy:** mRiver + Caddy
|
|
||||||
|
|||||||
6
backend/.dockerignore
Normal file
6
backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Build
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
|
||||||
|
|
||||||
|
# Run
|
||||||
|
FROM alpine:3
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/server .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./server"]
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := os.Getenv("PORT")
|
cfg, err := config.Load()
|
||||||
if port == "" {
|
if err != nil {
|
||||||
port = "8080"
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
database, err := db.Connect(cfg.DatabaseURL)
|
||||||
w.WriteHeader(http.StatusOK)
|
if err != nil {
|
||||||
fmt.Fprintf(w, "ok")
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
})
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
log.Printf("Starting KanzlAI API server on :%s", port)
|
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
|
||||||
if err := http.ListenAndServe(":"+port, nil); err != nil {
|
handler := router.New(database, authMW)
|
||||||
|
|
||||||
|
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
|
||||||
|
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
module mgit.msbls.de/m/KanzlAI
|
module mgit.msbls.de/m/KanzlAI-mGMT
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||||
|
github.com/lib/pq v1.12.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
12
backend/go.sum
Normal file
12
backend/go.sum
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
|
||||||
|
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
32
backend/internal/auth/context.go
Normal file
32
backend/internal/auth/context.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
userIDKey contextKey = "user_id"
|
||||||
|
tenantIDKey contextKey = "tenant_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
|
||||||
|
return context.WithValue(ctx, userIDKey, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContextWithTenantID(ctx context.Context, tenantID uuid.UUID) context.Context {
|
||||||
|
return context.WithValue(ctx, tenantIDKey, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||||
|
id, ok := ctx.Value(userIDKey).(uuid.UUID)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||||
|
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
102
backend/internal/auth/middleware.go
Normal file
102
backend/internal/auth/middleware.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Middleware struct {
|
||||||
|
jwtSecret []byte
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMiddleware(jwtSecret string, db *sqlx.DB) *Middleware {
|
||||||
|
return &Middleware{jwtSecret: []byte(jwtSecret), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "missing authorization token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := m.verifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := ContextWithUserID(r.Context(), userID)
|
||||||
|
|
||||||
|
// Resolve tenant from user_tenants
|
||||||
|
var tenantID uuid.UUID
|
||||||
|
err = m.db.GetContext(r.Context(), &tenantID,
|
||||||
|
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "no tenant found for user", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx = ContextWithTenantID(ctx, tenantID)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) verifyJWT(tokenStr string) (uuid.UUID, error) {
|
||||||
|
parsedToken, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return m.jwtSecret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("parsing JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !parsedToken.Valid {
|
||||||
|
return uuid.Nil, fmt.Errorf("invalid JWT token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return uuid.Nil, fmt.Errorf("extracting JWT claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
if exp, ok := claims["exp"].(float64); ok {
|
||||||
|
if time.Now().Unix() > int64(exp) {
|
||||||
|
return uuid.Nil, fmt.Errorf("JWT token has expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, ok := claims["sub"].(string)
|
||||||
|
if !ok {
|
||||||
|
return uuid.Nil, fmt.Errorf("missing sub claim in JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := uuid.Parse(sub)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("invalid user ID format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBearerToken(r *http.Request) string {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
42
backend/internal/config/config.go
Normal file
42
backend/internal/config/config.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DatabaseURL string
|
||||||
|
SupabaseURL string
|
||||||
|
SupabaseAnonKey string
|
||||||
|
SupabaseJWTSecret string
|
||||||
|
AnthropicAPIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
|
SupabaseURL: os.Getenv("SUPABASE_URL"),
|
||||||
|
SupabaseAnonKey: os.Getenv("SUPABASE_ANON_KEY"),
|
||||||
|
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
||||||
|
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
if cfg.SupabaseJWTSecret == "" {
|
||||||
|
return nil, fmt.Errorf("SUPABASE_JWT_SECRET is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
26
backend/internal/db/connection.go
Normal file
26
backend/internal/db/connection.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Connect(databaseURL string) (*sqlx.DB, error) {
|
||||||
|
db, err := sqlx.Connect("postgres", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connecting to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set search_path so queries use kanzlai schema by default
|
||||||
|
if _, err := db.Exec("SET search_path TO kanzlai, public"); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("setting search_path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(25)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
158
backend/internal/handlers/cases.go
Normal file
158
backend/internal/handlers/cases.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaseHandler struct {
|
||||||
|
svc *services.CaseService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaseHandler(svc *services.CaseService) *CaseHandler {
|
||||||
|
return &CaseHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaseHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
|
||||||
|
filter := services.CaseFilter{
|
||||||
|
Status: r.URL.Query().Get("status"),
|
||||||
|
Type: r.URL.Query().Get("type"),
|
||||||
|
Search: r.URL.Query().Get("search"),
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
cases, total, err := h.svc.List(r.Context(), tenantID, filter)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"cases": cases,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaseHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := auth.UserFromContext(r.Context())
|
||||||
|
|
||||||
|
var input services.CreateCaseInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.CaseNumber == "" || input.Title == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "case_number and title are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := h.svc.Create(r.Context(), tenantID, userID, input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaseHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caseID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if detail == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "case not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaseHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := auth.UserFromContext(r.Context())
|
||||||
|
|
||||||
|
caseID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input services.UpdateCaseInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "case not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CaseHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := auth.UserFromContext(r.Context())
|
||||||
|
|
||||||
|
caseID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.Delete(r.Context(), tenantID, caseID, userID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "case not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "archived"})
|
||||||
|
}
|
||||||
16
backend/internal/handlers/helpers.go
Normal file
16
backend/internal/handlers/helpers.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
134
backend/internal/handlers/parties.go
Normal file
134
backend/internal/handlers/parties.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PartyHandler struct {
|
||||||
|
svc *services.PartyService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPartyHandler(svc *services.PartyService) *PartyHandler {
|
||||||
|
return &PartyHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PartyHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caseID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"parties": parties,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PartyHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := auth.UserFromContext(r.Context())
|
||||||
|
|
||||||
|
caseID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid case ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input services.CreatePartyInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "case not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, party)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PartyHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
partyID, err := uuid.Parse(r.PathValue("partyId"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid party ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input services.UpdatePartyInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.svc.Update(r.Context(), tenantID, partyID, input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "party not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PartyHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
partyID, err := uuid.Parse(r.PathValue("partyId"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid party ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.Delete(r.Context(), tenantID, partyID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "party not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
23
backend/internal/models/appointment.go
Normal file
23
backend/internal/models/appointment.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Appointment struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
|
StartAt time.Time `db:"start_at" json:"start_at"`
|
||||||
|
EndAt *time.Time `db:"end_at" json:"end_at,omitempty"`
|
||||||
|
Location *string `db:"location" json:"location,omitempty"`
|
||||||
|
AppointmentType *string `db:"appointment_type" json:"appointment_type,omitempty"`
|
||||||
|
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||||
|
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
23
backend/internal/models/case.go
Normal file
23
backend/internal/models/case.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Case struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
CaseNumber string `db:"case_number" json:"case_number"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
CaseType *string `db:"case_type" json:"case_type,omitempty"`
|
||||||
|
Court *string `db:"court" json:"court,omitempty"`
|
||||||
|
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||||
|
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
22
backend/internal/models/case_event.go
Normal file
22
backend/internal/models/case_event.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaseEvent struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
CaseID uuid.UUID `db:"case_id" json:"case_id"`
|
||||||
|
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
|
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
|
||||||
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||||
|
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
27
backend/internal/models/deadline.go
Normal file
27
backend/internal/models/deadline.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Deadline struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
CaseID uuid.UUID `db:"case_id" json:"case_id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
|
DueDate string `db:"due_date" json:"due_date"`
|
||||||
|
OriginalDueDate *string `db:"original_due_date" json:"original_due_date,omitempty"`
|
||||||
|
WarningDate *string `db:"warning_date" json:"warning_date,omitempty"`
|
||||||
|
Source string `db:"source" json:"source"`
|
||||||
|
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||||
|
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||||
|
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
||||||
|
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
43
backend/internal/models/deadline_rule.go
Normal file
43
backend/internal/models/deadline_rule.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeadlineRule struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||||
|
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
|
||||||
|
Code *string `db:"code" json:"code,omitempty"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
|
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
|
||||||
|
EventType *string `db:"event_type" json:"event_type,omitempty"`
|
||||||
|
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
|
||||||
|
DurationValue int `db:"duration_value" json:"duration_value"`
|
||||||
|
DurationUnit string `db:"duration_unit" json:"duration_unit"`
|
||||||
|
Timing *string `db:"timing" json:"timing,omitempty"`
|
||||||
|
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||||
|
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||||
|
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||||
|
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
|
||||||
|
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||||
|
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||||
|
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||||
|
IsActive bool `db:"is_active" json:"is_active"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProceedingType struct {
|
||||||
|
ID int `db:"id" json:"id"`
|
||||||
|
Code string `db:"code" json:"code"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
|
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
|
||||||
|
DefaultColor string `db:"default_color" json:"default_color"`
|
||||||
|
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||||
|
IsActive bool `db:"is_active" json:"is_active"`
|
||||||
|
}
|
||||||
23
backend/internal/models/document.go
Normal file
23
backend/internal/models/document.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Document struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
CaseID uuid.UUID `db:"case_id" json:"case_id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
DocType *string `db:"doc_type" json:"doc_type,omitempty"`
|
||||||
|
FilePath *string `db:"file_path" json:"file_path,omitempty"`
|
||||||
|
FileSize *int `db:"file_size" json:"file_size,omitempty"`
|
||||||
|
MimeType *string `db:"mime_type" json:"mime_type,omitempty"`
|
||||||
|
AIExtracted *json.RawMessage `db:"ai_extracted" json:"ai_extracted,omitempty"`
|
||||||
|
UploadedBy *uuid.UUID `db:"uploaded_by" json:"uploaded_by,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
17
backend/internal/models/party.go
Normal file
17
backend/internal/models/party.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Party struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
CaseID uuid.UUID `db:"case_id" json:"case_id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Role *string `db:"role" json:"role,omitempty"`
|
||||||
|
Representative *string `db:"representative" json:"representative,omitempty"`
|
||||||
|
ContactInfo json.RawMessage `db:"contact_info" json:"contact_info"`
|
||||||
|
}
|
||||||
24
backend/internal/models/tenant.go
Normal file
24
backend/internal/models/tenant.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tenant struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Slug string `db:"slug" json:"slug"`
|
||||||
|
Settings json.RawMessage `db:"settings" json:"settings"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserTenant struct {
|
||||||
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||||
|
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||||
|
Role string `db:"role" json:"role"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
74
backend/internal/router/router.go
Normal file
74
backend/internal/router/router.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Services
|
||||||
|
caseSvc := services.NewCaseService(db)
|
||||||
|
partySvc := services.NewPartyService(db)
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
caseH := handlers.NewCaseHandler(caseSvc)
|
||||||
|
partyH := handlers.NewPartyHandler(partySvc)
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
mux.HandleFunc("GET /health", handleHealth(db))
|
||||||
|
|
||||||
|
// Authenticated API routes
|
||||||
|
api := http.NewServeMux()
|
||||||
|
|
||||||
|
// Cases
|
||||||
|
api.HandleFunc("GET /api/cases", caseH.List)
|
||||||
|
api.HandleFunc("POST /api/cases", caseH.Create)
|
||||||
|
api.HandleFunc("GET /api/cases/{id}", caseH.Get)
|
||||||
|
api.HandleFunc("PUT /api/cases/{id}", caseH.Update)
|
||||||
|
api.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
|
||||||
|
|
||||||
|
// Parties (nested under cases for creation/listing, top-level for update/delete)
|
||||||
|
api.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
|
||||||
|
api.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
|
||||||
|
api.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
|
||||||
|
api.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
|
||||||
|
|
||||||
|
// Placeholder routes for future phases
|
||||||
|
api.HandleFunc("GET /api/deadlines", placeholder("deadlines"))
|
||||||
|
api.HandleFunc("GET /api/appointments", placeholder("appointments"))
|
||||||
|
api.HandleFunc("GET /api/documents", placeholder("documents"))
|
||||||
|
|
||||||
|
mux.Handle("/api/", authMW.RequireAuth(api))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "error", "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholder(resource string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "not_implemented",
|
||||||
|
"resource": resource,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
277
backend/internal/services/case_service.go
Normal file
277
backend/internal/services/case_service.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaseService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaseService(db *sqlx.DB) *CaseService {
|
||||||
|
return &CaseService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseFilter struct {
|
||||||
|
Status string
|
||||||
|
Type string
|
||||||
|
Search string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseDetail struct {
|
||||||
|
models.Case
|
||||||
|
Parties []models.Party `json:"parties"`
|
||||||
|
RecentEvents []models.CaseEvent `json:"recent_events"`
|
||||||
|
DeadlinesCount int `json:"deadlines_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCaseInput struct {
|
||||||
|
CaseNumber string `json:"case_number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CaseType *string `json:"case_type,omitempty"`
|
||||||
|
Court *string `json:"court,omitempty"`
|
||||||
|
CourtRef *string `json:"court_ref,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCaseInput struct {
|
||||||
|
CaseNumber *string `json:"case_number,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
CaseType *string `json:"case_type,omitempty"`
|
||||||
|
Court *string `json:"court,omitempty"`
|
||||||
|
CourtRef *string `json:"court_ref,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaseService) List(ctx context.Context, tenantID uuid.UUID, filter CaseFilter) ([]models.Case, int, error) {
|
||||||
|
if filter.Limit <= 0 {
|
||||||
|
filter.Limit = 20
|
||||||
|
}
|
||||||
|
if filter.Limit > 100 {
|
||||||
|
filter.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WHERE clause
|
||||||
|
where := "WHERE tenant_id = $1"
|
||||||
|
args := []interface{}{tenantID}
|
||||||
|
argIdx := 2
|
||||||
|
|
||||||
|
if filter.Status != "" {
|
||||||
|
where += fmt.Sprintf(" AND status = $%d", argIdx)
|
||||||
|
args = append(args, filter.Status)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filter.Type != "" {
|
||||||
|
where += fmt.Sprintf(" AND case_type = $%d", argIdx)
|
||||||
|
args = append(args, filter.Type)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if filter.Search != "" {
|
||||||
|
where += fmt.Sprintf(" AND (title ILIKE $%d OR case_number ILIKE $%d)", argIdx, argIdx)
|
||||||
|
args = append(args, "%"+filter.Search+"%")
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
var total int
|
||||||
|
countQuery := "SELECT COUNT(*) FROM cases " + where
|
||||||
|
if err := s.db.GetContext(ctx, &total, countQuery, args...); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("counting cases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch page
|
||||||
|
query := fmt.Sprintf("SELECT * FROM cases %s ORDER BY updated_at DESC LIMIT $%d OFFSET $%d",
|
||||||
|
where, argIdx, argIdx+1)
|
||||||
|
args = append(args, filter.Limit, filter.Offset)
|
||||||
|
|
||||||
|
var cases []models.Case
|
||||||
|
if err := s.db.SelectContext(ctx, &cases, query, args...); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("listing cases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cases, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaseService) GetByID(ctx context.Context, tenantID, caseID uuid.UUID) (*CaseDetail, error) {
|
||||||
|
var c models.Case
|
||||||
|
err := s.db.GetContext(ctx, &c,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := &CaseDetail{Case: c}
|
||||||
|
|
||||||
|
// Parties
|
||||||
|
if err := s.db.SelectContext(ctx, &detail.Parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
|
||||||
|
caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("getting parties: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent events (last 20)
|
||||||
|
if err := s.db.SelectContext(ctx, &detail.RecentEvents,
|
||||||
|
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 20",
|
||||||
|
caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("getting events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadlines count
|
||||||
|
if err := s.db.GetContext(ctx, &detail.DeadlinesCount,
|
||||||
|
"SELECT COUNT(*) FROM deadlines WHERE case_id = $1 AND tenant_id = $2",
|
||||||
|
caseID, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("counting deadlines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaseService) Create(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, input CreateCaseInput) (*models.Case, error) {
|
||||||
|
if input.Status == "" {
|
||||||
|
input.Status = "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.New()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status, metadata, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $9, $9)`,
|
||||||
|
id, tenantID, input.CaseNumber, input.Title, input.CaseType, input.Court, input.CourtRef, input.Status, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create case_created event
|
||||||
|
createEvent(ctx, s.db, tenantID, id, userID, "case_created", "Case created", nil)
|
||||||
|
|
||||||
|
var c models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &c, "SELECT * FROM cases WHERE id = $1", id); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching created case: %w", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaseService) Update(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input UpdateCaseInput) (*models.Case, error) {
|
||||||
|
// Fetch current to detect status change
|
||||||
|
var current models.Case
|
||||||
|
err := s.db.GetContext(ctx, ¤t,
|
||||||
|
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("fetching case for update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SET clause dynamically
|
||||||
|
sets := []string{}
|
||||||
|
args := []interface{}{}
|
||||||
|
argIdx := 1
|
||||||
|
|
||||||
|
if input.CaseNumber != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("case_number = $%d", argIdx))
|
||||||
|
args = append(args, *input.CaseNumber)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.Title != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
|
||||||
|
args = append(args, *input.Title)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.CaseType != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("case_type = $%d", argIdx))
|
||||||
|
args = append(args, *input.CaseType)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.Court != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("court = $%d", argIdx))
|
||||||
|
args = append(args, *input.Court)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.CourtRef != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("court_ref = $%d", argIdx))
|
||||||
|
args = append(args, *input.CourtRef)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.Status != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("status = $%d", argIdx))
|
||||||
|
args = append(args, *input.Status)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
return ¤t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sets = append(sets, fmt.Sprintf("updated_at = $%d", argIdx))
|
||||||
|
args = append(args, time.Now())
|
||||||
|
argIdx++
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE cases SET %s WHERE id = $%d AND tenant_id = $%d",
|
||||||
|
joinStrings(sets, ", "), argIdx, argIdx+1)
|
||||||
|
args = append(args, caseID, tenantID)
|
||||||
|
|
||||||
|
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
||||||
|
return nil, fmt.Errorf("updating case: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log status change event
|
||||||
|
if input.Status != nil && *input.Status != current.Status {
|
||||||
|
desc := fmt.Sprintf("Status changed from %s to %s", current.Status, *input.Status)
|
||||||
|
createEvent(ctx, s.db, tenantID, caseID, userID, "status_changed", desc, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated models.Case
|
||||||
|
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM cases WHERE id = $1", caseID); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching updated case: %w", err)
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID) error {
|
||||||
|
result, err := s.db.ExecContext(ctx,
|
||||||
|
"UPDATE cases SET status = 'archived', updated_at = $1 WHERE id = $2 AND tenant_id = $3 AND status != 'archived'",
|
||||||
|
time.Now(), caseID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("archiving case: %w", err)
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEvent(ctx context.Context, db *sqlx.DB, tenantID, caseID uuid.UUID, userID uuid.UUID, eventType, title string, description *string) {
|
||||||
|
now := time.Now()
|
||||||
|
db.ExecContext(ctx,
|
||||||
|
`INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $7, $7)`,
|
||||||
|
uuid.New(), tenantID, caseID, eventType, title, description, now, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinStrings(strs []string, sep string) string {
|
||||||
|
result := ""
|
||||||
|
for i, s := range strs {
|
||||||
|
if i > 0 {
|
||||||
|
result += sep
|
||||||
|
}
|
||||||
|
result += s
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
152
backend/internal/services/party_service.go
Normal file
152
backend/internal/services/party_service.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PartyService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPartyService(db *sqlx.DB) *PartyService {
|
||||||
|
return &PartyService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePartyInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role *string `json:"role,omitempty"`
|
||||||
|
Representative *string `json:"representative,omitempty"`
|
||||||
|
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePartyInput struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Role *string `json:"role,omitempty"`
|
||||||
|
Representative *string `json:"representative,omitempty"`
|
||||||
|
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PartyService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.Party, error) {
|
||||||
|
var parties []models.Party
|
||||||
|
err := s.db.SelectContext(ctx, &parties,
|
||||||
|
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
|
||||||
|
caseID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing parties: %w", err)
|
||||||
|
}
|
||||||
|
return parties, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PartyService) Create(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input CreatePartyInput) (*models.Party, error) {
|
||||||
|
// Verify case exists and belongs to tenant
|
||||||
|
var exists bool
|
||||||
|
err := s.db.GetContext(ctx, &exists,
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)", caseID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("checking case: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.New()
|
||||||
|
contactInfo := input.ContactInfo
|
||||||
|
if contactInfo == nil {
|
||||||
|
contactInfo = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO parties (id, tenant_id, case_id, name, role, representative, contact_info)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
id, tenantID, caseID, input.Name, input.Role, input.Representative, contactInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating party: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log event
|
||||||
|
desc := fmt.Sprintf("Party added: %s", input.Name)
|
||||||
|
createEvent(ctx, s.db, tenantID, caseID, userID, "party_added", desc, nil)
|
||||||
|
|
||||||
|
var party models.Party
|
||||||
|
if err := s.db.GetContext(ctx, &party, "SELECT * FROM parties WHERE id = $1", id); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching created party: %w", err)
|
||||||
|
}
|
||||||
|
return &party, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PartyService) Update(ctx context.Context, tenantID, partyID uuid.UUID, input UpdatePartyInput) (*models.Party, error) {
|
||||||
|
// Verify party exists and belongs to tenant
|
||||||
|
var current models.Party
|
||||||
|
err := s.db.GetContext(ctx, ¤t,
|
||||||
|
"SELECT * FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("fetching party: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sets := []string{}
|
||||||
|
args := []interface{}{}
|
||||||
|
argIdx := 1
|
||||||
|
|
||||||
|
if input.Name != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("name = $%d", argIdx))
|
||||||
|
args = append(args, *input.Name)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.Role != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("role = $%d", argIdx))
|
||||||
|
args = append(args, *input.Role)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.Representative != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("representative = $%d", argIdx))
|
||||||
|
args = append(args, *input.Representative)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
if input.ContactInfo != nil {
|
||||||
|
sets = append(sets, fmt.Sprintf("contact_info = $%d", argIdx))
|
||||||
|
args = append(args, input.ContactInfo)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sets) == 0 {
|
||||||
|
return ¤t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE parties SET %s WHERE id = $%d AND tenant_id = $%d",
|
||||||
|
joinStrings(sets, ", "), argIdx, argIdx+1)
|
||||||
|
args = append(args, partyID, tenantID)
|
||||||
|
|
||||||
|
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
||||||
|
return nil, fmt.Errorf("updating party: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated models.Party
|
||||||
|
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM parties WHERE id = $1", partyID); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching updated party: %w", err)
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID) error {
|
||||||
|
result, err := s.db.ExecContext(ctx,
|
||||||
|
"DELETE FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting party: %w", err)
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- API_URL=http://backend:8080
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{if(!r.ok)throw r.status;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
9
frontend/.dockerignore
Normal file
9
frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.env*
|
||||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Dependencies
|
||||||
|
FROM oven/bun:1 AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Build
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV API_URL=http://backend:8080
|
||||||
|
RUN mkdir -p public
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Run
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
|
rewrites: async () => [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${process.env.API_URL || "http://localhost:8080"}/:path*`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "kanzlai-mgmt",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "KanzlAI",
|
title: "KanzlAI-mGMT",
|
||||||
description: "AI-powered toolkit for patent litigation",
|
description: "Kanzleimanagement online",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center">
|
<main className="flex min-h-screen items-center justify-center">
|
||||||
<h1 className="text-4xl font-bold">KanzlAI</h1>
|
<h1 className="text-4xl font-bold">KanzlAI-mGMT</h1>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user