From 1fc0874893f385662b975e0120f4f8d73f67d43f Mon Sep 17 00:00:00 2001 From: m Date: Wed, 25 Mar 2026 13:17:33 +0100 Subject: [PATCH] feat: add database schema and backend foundation Part 1 - Database (kanzlai schema in Supabase): - Tenant-scoped tables: tenants, user_tenants, cases, parties, deadlines, appointments, documents, case_events - Global reference tables: proceeding_types, deadline_rules, holidays - RLS policies on all tenant-scoped tables - Seed: UPC proceeding types, 32 deadline rules (INF/CCR/REV/PI/APP), ZPO civil rules (Berufung, Revision, Einspruch), 2026 holidays Part 2 - Backend skeleton: - config: env var loading (DATABASE_URL, SUPABASE_*, ANTHROPIC_API_KEY) - db: sqlx connection pool with kanzlai search_path - auth: JWT verification middleware adapted from youpc.org, context helpers - models: Go structs for all tables with sqlx/json tags - router: route registration with auth middleware, /health + placeholder API routes - Updated main.go to wire everything together --- backend/cmd/server/main.go | 29 +++++--- backend/go.mod | 7 ++ backend/go.sum | 12 ++++ backend/internal/auth/context.go | 32 +++++++++ backend/internal/auth/middleware.go | 89 ++++++++++++++++++++++++ backend/internal/config/config.go | 42 +++++++++++ backend/internal/db/connection.go | 26 +++++++ backend/internal/models/appointment.go | 23 ++++++ backend/internal/models/case.go | 23 ++++++ backend/internal/models/case_event.go | 22 ++++++ backend/internal/models/deadline.go | 27 +++++++ backend/internal/models/deadline_rule.go | 43 ++++++++++++ backend/internal/models/document.go | 23 ++++++ backend/internal/models/party.go | 17 +++++ backend/internal/models/tenant.go | 24 +++++++ backend/internal/router/router.go | 50 +++++++++++++ 16 files changed, 478 insertions(+), 11 deletions(-) create mode 100644 backend/go.sum create mode 100644 backend/internal/auth/context.go create mode 100644 backend/internal/auth/middleware.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/db/connection.go create mode 100644 backend/internal/models/appointment.go create mode 100644 backend/internal/models/case.go create mode 100644 backend/internal/models/case_event.go create mode 100644 backend/internal/models/deadline.go create mode 100644 backend/internal/models/deadline_rule.go create mode 100644 backend/internal/models/document.go create mode 100644 backend/internal/models/party.go create mode 100644 backend/internal/models/tenant.go create mode 100644 backend/internal/router/router.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 83ee08b..6f99f13 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,25 +1,32 @@ package main import ( - "fmt" "log" "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() { - port := os.Getenv("PORT") - if port == "" { - port = "8080" + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) } - http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "ok") - }) + database, err := db.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer database.Close() - log.Printf("Starting KanzlAI API server on :%s", port) - if err := http.ListenAndServe(":"+port, nil); err != nil { + authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret) + 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) } } diff --git a/backend/go.mod b/backend/go.mod index e3fb1fa..16c55c7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,10 @@ module mgit.msbls.de/m/KanzlAI-mGMT 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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..f8b06eb --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go new file mode 100644 index 0000000..a42a6c3 --- /dev/null +++ b/backend/internal/auth/context.go @@ -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 +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..26896e1 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,89 @@ +package auth + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Middleware struct { + jwtSecret []byte +} + +func NewMiddleware(jwtSecret string) *Middleware { + return &Middleware{jwtSecret: []byte(jwtSecret)} +} + +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) + 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] +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..1569aa2 --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/db/connection.go b/backend/internal/db/connection.go new file mode 100644 index 0000000..97856f0 --- /dev/null +++ b/backend/internal/db/connection.go @@ -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 +} diff --git a/backend/internal/models/appointment.go b/backend/internal/models/appointment.go new file mode 100644 index 0000000..7cb2688 --- /dev/null +++ b/backend/internal/models/appointment.go @@ -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"` +} diff --git a/backend/internal/models/case.go b/backend/internal/models/case.go new file mode 100644 index 0000000..4370051 --- /dev/null +++ b/backend/internal/models/case.go @@ -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"` +} diff --git a/backend/internal/models/case_event.go b/backend/internal/models/case_event.go new file mode 100644 index 0000000..d931db8 --- /dev/null +++ b/backend/internal/models/case_event.go @@ -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"` +} diff --git a/backend/internal/models/deadline.go b/backend/internal/models/deadline.go new file mode 100644 index 0000000..812b8cb --- /dev/null +++ b/backend/internal/models/deadline.go @@ -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"` +} diff --git a/backend/internal/models/deadline_rule.go b/backend/internal/models/deadline_rule.go new file mode 100644 index 0000000..a582e4b --- /dev/null +++ b/backend/internal/models/deadline_rule.go @@ -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"` +} diff --git a/backend/internal/models/document.go b/backend/internal/models/document.go new file mode 100644 index 0000000..b85067b --- /dev/null +++ b/backend/internal/models/document.go @@ -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"` +} diff --git a/backend/internal/models/party.go b/backend/internal/models/party.go new file mode 100644 index 0000000..9964950 --- /dev/null +++ b/backend/internal/models/party.go @@ -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"` +} diff --git a/backend/internal/models/tenant.go b/backend/internal/models/tenant.go new file mode 100644 index 0000000..5b928a4 --- /dev/null +++ b/backend/internal/models/tenant.go @@ -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"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..530bc76 --- /dev/null +++ b/backend/internal/router/router.go @@ -0,0 +1,50 @@ +package router + +import ( + "encoding/json" + "net/http" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + + "github.com/jmoiron/sqlx" +) + +func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { + mux := http.NewServeMux() + + // Public routes + mux.HandleFunc("GET /health", handleHealth(db)) + + // Authenticated API routes + api := http.NewServeMux() + api.HandleFunc("GET /api/cases", placeholder("cases")) + 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, + }) + } +}