Compare commits
3 Commits
193a4cd567
...
mai/linus/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd15b4eb38 | ||
|
|
8049ea3c63 | ||
|
|
1fc0874893 |
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
89
backend/internal/auth/middleware.go
Normal file
89
backend/internal/auth/middleware.go
Normal file
@@ -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]
|
||||
}
|
||||
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
|
||||
}
|
||||
216
backend/internal/handlers/appointments.go
Normal file
216
backend/internal/handlers/appointments.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||
)
|
||||
|
||||
type AppointmentHandler struct {
|
||||
svc *services.AppointmentService
|
||||
}
|
||||
|
||||
func NewAppointmentHandler(svc *services.AppointmentService) *AppointmentHandler {
|
||||
return &AppointmentHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
filter := services.AppointmentFilter{}
|
||||
|
||||
if v := r.URL.Query().Get("case_id"); v != "" {
|
||||
id, err := uuid.Parse(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid case_id")
|
||||
return
|
||||
}
|
||||
filter.CaseID = &id
|
||||
}
|
||||
if v := r.URL.Query().Get("type"); v != "" {
|
||||
filter.Type = &v
|
||||
}
|
||||
if v := r.URL.Query().Get("start_from"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid start_from (use RFC3339)")
|
||||
return
|
||||
}
|
||||
filter.StartFrom = &t
|
||||
}
|
||||
if v := r.URL.Query().Get("start_to"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid start_to (use RFC3339)")
|
||||
return
|
||||
}
|
||||
filter.StartTo = &t
|
||||
}
|
||||
|
||||
appointments, err := h.svc.List(r.Context(), tenantID, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list appointments")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, appointments)
|
||||
}
|
||||
|
||||
type createAppointmentRequest struct {
|
||||
CaseID *uuid.UUID `json:"case_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt *time.Time `json:"end_at"`
|
||||
Location *string `json:"location"`
|
||||
AppointmentType *string `json:"appointment_type"`
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
var req createAppointmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if req.StartAt.IsZero() {
|
||||
writeError(w, http.StatusBadRequest, "start_at is required")
|
||||
return
|
||||
}
|
||||
|
||||
appt := &models.Appointment{
|
||||
TenantID: tenantID,
|
||||
CaseID: req.CaseID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
StartAt: req.StartAt,
|
||||
EndAt: req.EndAt,
|
||||
Location: req.Location,
|
||||
AppointmentType: req.AppointmentType,
|
||||
}
|
||||
|
||||
if err := h.svc.Create(r.Context(), appt); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create appointment")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, appt)
|
||||
}
|
||||
|
||||
type updateAppointmentRequest struct {
|
||||
CaseID *uuid.UUID `json:"case_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt *time.Time `json:"end_at"`
|
||||
Location *string `json:"location"`
|
||||
AppointmentType *string `json:"appointment_type"`
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid appointment id")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch existing to verify ownership
|
||||
existing, err := h.svc.GetByID(r.Context(), tenantID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "appointment not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to fetch appointment")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateAppointmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if req.StartAt.IsZero() {
|
||||
writeError(w, http.StatusBadRequest, "start_at is required")
|
||||
return
|
||||
}
|
||||
|
||||
existing.CaseID = req.CaseID
|
||||
existing.Title = req.Title
|
||||
existing.Description = req.Description
|
||||
existing.StartAt = req.StartAt
|
||||
existing.EndAt = req.EndAt
|
||||
existing.Location = req.Location
|
||||
existing.AppointmentType = req.AppointmentType
|
||||
|
||||
if err := h.svc.Update(r.Context(), existing); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update appointment")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, existing)
|
||||
}
|
||||
|
||||
func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid appointment id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), tenantID, id); err != nil {
|
||||
writeError(w, http.StatusNotFound, "appointment not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
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"`
|
||||
}
|
||||
63
backend/internal/router/router.go
Normal file
63
backend/internal/router/router.go
Normal file
@@ -0,0 +1,63 @@
|
||||
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()
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("GET /health", handleHealth(db))
|
||||
|
||||
// Services
|
||||
appointmentSvc := services.NewAppointmentService(db)
|
||||
|
||||
// Handlers
|
||||
apptH := handlers.NewAppointmentHandler(appointmentSvc)
|
||||
|
||||
// Authenticated API routes
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /api/cases", placeholder("cases"))
|
||||
api.HandleFunc("GET /api/deadlines", placeholder("deadlines"))
|
||||
api.HandleFunc("GET /api/documents", placeholder("documents"))
|
||||
|
||||
// Appointments CRUD
|
||||
api.HandleFunc("GET /api/appointments", apptH.List)
|
||||
api.HandleFunc("POST /api/appointments", apptH.Create)
|
||||
api.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
||||
api.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
135
backend/internal/services/appointment_service.go
Normal file
135
backend/internal/services/appointment_service.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||
)
|
||||
|
||||
type AppointmentService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
|
||||
return &AppointmentService{db: db}
|
||||
}
|
||||
|
||||
type AppointmentFilter struct {
|
||||
CaseID *uuid.UUID
|
||||
Type *string
|
||||
StartFrom *time.Time
|
||||
StartTo *time.Time
|
||||
}
|
||||
|
||||
func (s *AppointmentService) List(ctx context.Context, tenantID uuid.UUID, filter AppointmentFilter) ([]models.Appointment, error) {
|
||||
query := "SELECT * FROM appointments WHERE tenant_id = $1"
|
||||
args := []any{tenantID}
|
||||
argN := 2
|
||||
|
||||
if filter.CaseID != nil {
|
||||
query += fmt.Sprintf(" AND case_id = $%d", argN)
|
||||
args = append(args, *filter.CaseID)
|
||||
argN++
|
||||
}
|
||||
if filter.Type != nil {
|
||||
query += fmt.Sprintf(" AND appointment_type = $%d", argN)
|
||||
args = append(args, *filter.Type)
|
||||
argN++
|
||||
}
|
||||
if filter.StartFrom != nil {
|
||||
query += fmt.Sprintf(" AND start_at >= $%d", argN)
|
||||
args = append(args, *filter.StartFrom)
|
||||
argN++
|
||||
}
|
||||
if filter.StartTo != nil {
|
||||
query += fmt.Sprintf(" AND start_at <= $%d", argN)
|
||||
args = append(args, *filter.StartTo)
|
||||
argN++
|
||||
}
|
||||
|
||||
query += " ORDER BY start_at ASC"
|
||||
|
||||
var appointments []models.Appointment
|
||||
if err := s.db.SelectContext(ctx, &appointments, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("listing appointments: %w", err)
|
||||
}
|
||||
if appointments == nil {
|
||||
appointments = []models.Appointment{}
|
||||
}
|
||||
return appointments, nil
|
||||
}
|
||||
|
||||
func (s *AppointmentService) GetByID(ctx context.Context, tenantID, id uuid.UUID) (*models.Appointment, error) {
|
||||
var a models.Appointment
|
||||
err := s.db.GetContext(ctx, &a, "SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting appointment: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment) error {
|
||||
a.ID = uuid.New()
|
||||
now := time.Now().UTC()
|
||||
a.CreatedAt = now
|
||||
a.UpdatedAt = now
|
||||
|
||||
_, err := s.db.NamedExecContext(ctx, `
|
||||
INSERT INTO appointments (id, tenant_id, case_id, title, description, start_at, end_at, location, appointment_type, caldav_uid, caldav_etag, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :case_id, :title, :description, :start_at, :end_at, :location, :appointment_type, :caldav_uid, :caldav_etag, :created_at, :updated_at)
|
||||
`, a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating appointment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment) error {
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
|
||||
result, err := s.db.NamedExecContext(ctx, `
|
||||
UPDATE appointments SET
|
||||
case_id = :case_id,
|
||||
title = :title,
|
||||
description = :description,
|
||||
start_at = :start_at,
|
||||
end_at = :end_at,
|
||||
location = :location,
|
||||
appointment_type = :appointment_type,
|
||||
caldav_uid = :caldav_uid,
|
||||
caldav_etag = :caldav_etag,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
`, a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating appointment: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("appointment not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID) error {
|
||||
result, err := s.db.ExecContext(ctx, "DELETE FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting appointment: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("appointment not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user