Compare commits
1 Commits
mai/pike/p
...
mai/ritchi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53e1389f9 |
@@ -23,7 +23,7 @@ func main() {
|
|||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
|
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
|
||||||
handler := router.New(database, authMW, cfg.AnthropicAPIKey)
|
handler := router.New(database, authMW)
|
||||||
|
|
||||||
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
|
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
|
||||||
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
||||||
|
|||||||
@@ -3,14 +3,8 @@ module mgit.msbls.de/m/KanzlAI-mGMT
|
|||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anthropics/anthropic-sdk-go v1.27.1 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||||
github.com/lib/pq v1.12.0 // indirect
|
github.com/lib/pq v1.12.0 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.27.1 h1:7DgMZ2Ng3C2mPzJGHA30NXQTZolcF07mHd0tGaLwfzk=
|
|
||||||
github.com/anthropics/anthropic-sdk-go v1.27.1/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
@@ -12,15 +10,3 @@ 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 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
|
||||||
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
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=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AIHandler struct {
|
|
||||||
ai *services.AIService
|
|
||||||
db *sqlx.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAIHandler(ai *services.AIService, db *sqlx.DB) *AIHandler {
|
|
||||||
return &AIHandler{ai: ai, db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractDeadlines handles POST /api/ai/extract-deadlines
|
|
||||||
// Accepts either multipart/form-data with a "file" PDF field, or JSON {"text": "..."}.
|
|
||||||
func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
|
|
||||||
contentType := r.Header.Get("Content-Type")
|
|
||||||
|
|
||||||
var pdfData []byte
|
|
||||||
var text string
|
|
||||||
|
|
||||||
// Check if multipart (PDF upload)
|
|
||||||
if len(contentType) >= 9 && contentType[:9] == "multipart" {
|
|
||||||
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
|
|
||||||
writeError(w, http.StatusBadRequest, "failed to parse multipart form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file, _, err := r.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "missing 'file' field in multipart form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
pdfData, err = io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "failed to read uploaded file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Assume JSON body
|
|
||||||
var body struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
text = body.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pdfData) == 0 && text == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "provide either a PDF file or text")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "AI extraction failed: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"deadlines": deadlines,
|
|
||||||
"count": len(deadlines),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SummarizeCase handles POST /api/ai/summarize-case
|
|
||||||
// Accepts JSON {"case_id": "uuid"}.
|
|
||||||
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
|
||||||
if err != nil {
|
|
||||||
handleTenantError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
CaseID string `json:"case_id"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.CaseID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "case_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
caseID, err := parseUUID(body.CaseID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid case_id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "AI summarization failed: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
|
||||||
"case_id": caseID.String(),
|
|
||||||
"summary": summary,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
32
backend/internal/handlers/dashboard.go
Normal file
32
backend/internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardHandler struct {
|
||||||
|
svc *services.DashboardService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardHandler(svc *services.DashboardService) *DashboardHandler {
|
||||||
|
return &DashboardHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.svc.Get(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, data)
|
||||||
|
}
|
||||||
@@ -83,8 +83,3 @@ func handleTenantError(w http.ResponseWriter, err error) {
|
|||||||
func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
|
func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
|
||||||
return uuid.Parse(r.PathValue(key))
|
return uuid.Parse(r.PathValue(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseUUID parses a UUID string
|
|
||||||
func parseUUID(s string) (uuid.UUID, error) {
|
|
||||||
return uuid.Parse(s)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(db *sqlx.DB, authMW *auth.Middleware, anthropicAPIKey string) http.Handler {
|
func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@@ -24,16 +24,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, anthropicAPIKey string) http.Hand
|
|||||||
deadlineRuleSvc := services.NewDeadlineRuleService(db)
|
deadlineRuleSvc := services.NewDeadlineRuleService(db)
|
||||||
calculator := services.NewDeadlineCalculator(holidaySvc)
|
calculator := services.NewDeadlineCalculator(holidaySvc)
|
||||||
|
|
||||||
// AI service (optional — only if API key is configured)
|
|
||||||
var aiH *handlers.AIHandler
|
|
||||||
if anthropicAPIKey != "" {
|
|
||||||
aiSvc := services.NewAIService(anthropicAPIKey, db)
|
|
||||||
aiH = handlers.NewAIHandler(aiSvc, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
||||||
|
|
||||||
|
dashboardSvc := services.NewDashboardService(db)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
tenantH := handlers.NewTenantHandler(tenantSvc)
|
tenantH := handlers.NewTenantHandler(tenantSvc)
|
||||||
caseH := handlers.NewCaseHandler(caseSvc)
|
caseH := handlers.NewCaseHandler(caseSvc)
|
||||||
@@ -42,6 +37,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, anthropicAPIKey string) http.Hand
|
|||||||
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
|
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
|
||||||
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
||||||
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
||||||
|
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
mux.HandleFunc("GET /health", handleHealth(db))
|
mux.HandleFunc("GET /health", handleHealth(db))
|
||||||
@@ -93,11 +89,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware, anthropicAPIKey string) http.Hand
|
|||||||
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
||||||
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
||||||
|
|
||||||
// AI endpoints
|
// Dashboard
|
||||||
if aiH != nil {
|
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
||||||
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiH.ExtractDeadlines)
|
|
||||||
scoped.HandleFunc("POST /api/ai/summarize-case", aiH.SummarizeCase)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placeholder routes for future phases
|
// Placeholder routes for future phases
|
||||||
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
|
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/anthropics/anthropic-sdk-go"
|
|
||||||
"github.com/anthropics/anthropic-sdk-go/option"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AIService struct {
|
|
||||||
client anthropic.Client
|
|
||||||
db *sqlx.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAIService(apiKey string, db *sqlx.DB) *AIService {
|
|
||||||
client := anthropic.NewClient(option.WithAPIKey(apiKey))
|
|
||||||
return &AIService{client: client, db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractedDeadline represents a deadline extracted by AI from a document.
|
|
||||||
type ExtractedDeadline struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
DueDate *string `json:"due_date"`
|
|
||||||
DurationValue int `json:"duration_value"`
|
|
||||||
DurationUnit string `json:"duration_unit"`
|
|
||||||
Timing string `json:"timing"`
|
|
||||||
TriggerEvent string `json:"trigger_event"`
|
|
||||||
RuleReference string `json:"rule_reference"`
|
|
||||||
Confidence float64 `json:"confidence"`
|
|
||||||
SourceQuote string `json:"source_quote"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type extractDeadlinesToolInput struct {
|
|
||||||
Deadlines []ExtractedDeadline `json:"deadlines"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var deadlineExtractionTool = anthropic.ToolParam{
|
|
||||||
Name: "extract_deadlines",
|
|
||||||
Description: anthropic.String("Extract all legal deadlines found in the document. Return each deadline with its details."),
|
|
||||||
InputSchema: anthropic.ToolInputSchemaParam{
|
|
||||||
Properties: map[string]any{
|
|
||||||
"deadlines": map[string]any{
|
|
||||||
"type": "array",
|
|
||||||
"description": "List of extracted deadlines",
|
|
||||||
"items": map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"title": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Short title describing the deadline (e.g. 'Statement of Defence', 'Reply to Counterclaim')",
|
|
||||||
},
|
|
||||||
"due_date": map[string]any{
|
|
||||||
"type": []string{"string", "null"},
|
|
||||||
"description": "Absolute due date in YYYY-MM-DD format if determinable, null otherwise",
|
|
||||||
},
|
|
||||||
"duration_value": map[string]any{
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Numeric duration value (e.g. 3 for '3 months')",
|
|
||||||
},
|
|
||||||
"duration_unit": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"enum": []string{"days", "weeks", "months"},
|
|
||||||
"description": "Unit of the duration period",
|
|
||||||
},
|
|
||||||
"timing": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"enum": []string{"after", "before"},
|
|
||||||
"description": "Whether the deadline is before or after the trigger event",
|
|
||||||
},
|
|
||||||
"trigger_event": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "The event that triggers this deadline (e.g. 'service of the Statement of Claim')",
|
|
||||||
},
|
|
||||||
"rule_reference": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Legal rule reference (e.g. 'Rule 23 RoP', 'Rule 222 RoP', '§ 276 ZPO')",
|
|
||||||
},
|
|
||||||
"confidence": map[string]any{
|
|
||||||
"type": "number",
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 1,
|
|
||||||
"description": "Confidence score from 0.0 to 1.0",
|
|
||||||
},
|
|
||||||
"source_quote": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "The exact quote from the document where this deadline was found",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"title", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Required: []string{"deadlines"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractionSystemPrompt = `You are a legal deadline extraction assistant for German and UPC (Unified Patent Court) patent litigation.
|
|
||||||
|
|
||||||
Your task is to extract all legal deadlines, time limits, and procedural time periods from the provided document.
|
|
||||||
|
|
||||||
For each deadline found, extract:
|
|
||||||
- A clear title describing the deadline
|
|
||||||
- The absolute due date if it can be determined from the document
|
|
||||||
- The duration (value + unit: days/weeks/months)
|
|
||||||
- Whether it runs before or after a trigger event
|
|
||||||
- The trigger event that starts the deadline
|
|
||||||
- The legal rule reference (e.g. Rule 23 RoP, § 276 ZPO)
|
|
||||||
- Your confidence level (0.0-1.0) in the extraction
|
|
||||||
- The exact source quote from the document
|
|
||||||
|
|
||||||
Be thorough: extract every deadline mentioned, including conditional ones. If a deadline references another deadline (e.g. "within 2 months of the defence"), capture that relationship in the trigger_event field.
|
|
||||||
|
|
||||||
If the document contains no deadlines, return an empty list.`
|
|
||||||
|
|
||||||
// ExtractDeadlines sends a document (PDF or text) to Claude for deadline extraction.
|
|
||||||
func (s *AIService) ExtractDeadlines(ctx context.Context, pdfData []byte, text string) ([]ExtractedDeadline, error) {
|
|
||||||
var contentBlocks []anthropic.ContentBlockParamUnion
|
|
||||||
|
|
||||||
if len(pdfData) > 0 {
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(pdfData)
|
|
||||||
contentBlocks = append(contentBlocks, anthropic.ContentBlockParamUnion{
|
|
||||||
OfDocument: &anthropic.DocumentBlockParam{
|
|
||||||
Source: anthropic.DocumentBlockParamSourceUnion{
|
|
||||||
OfBase64: &anthropic.Base64PDFSourceParam{
|
|
||||||
Data: encoded,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from this document."))
|
|
||||||
} else if text != "" {
|
|
||||||
contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from the following text:\n\n"+text))
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("either pdf_data or text must be provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
|
||||||
Model: anthropic.ModelClaudeSonnet4_5,
|
|
||||||
MaxTokens: 4096,
|
|
||||||
System: []anthropic.TextBlockParam{
|
|
||||||
{Text: extractionSystemPrompt},
|
|
||||||
},
|
|
||||||
Messages: []anthropic.MessageParam{
|
|
||||||
anthropic.NewUserMessage(contentBlocks...),
|
|
||||||
},
|
|
||||||
Tools: []anthropic.ToolUnionParam{
|
|
||||||
{OfTool: &deadlineExtractionTool},
|
|
||||||
},
|
|
||||||
ToolChoice: anthropic.ToolChoiceParamOfTool("extract_deadlines"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("claude API call: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the tool_use block in the response
|
|
||||||
for _, block := range msg.Content {
|
|
||||||
if block.Type == "tool_use" && block.Name == "extract_deadlines" {
|
|
||||||
var input extractDeadlinesToolInput
|
|
||||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing tool output: %w", err)
|
|
||||||
}
|
|
||||||
return input.Deadlines, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("no tool_use block in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
const summarizeSystemPrompt = `You are a legal case summary assistant for German and UPC patent litigation case management.
|
|
||||||
|
|
||||||
Given a case's details, recent events, and deadlines, produce a concise 2-3 sentence summary of what matters right now. Focus on:
|
|
||||||
- The most urgent upcoming deadline
|
|
||||||
- Recent significant events
|
|
||||||
- The current procedural stage
|
|
||||||
|
|
||||||
Write in clear, professional language suitable for a lawyer reviewing their case list. Be specific about dates and deadlines.`
|
|
||||||
|
|
||||||
// SummarizeCase generates an AI summary for a case and caches it in the database.
|
|
||||||
func (s *AIService) SummarizeCase(ctx context.Context, tenantID, caseID uuid.UUID) (string, error) {
|
|
||||||
// Load case
|
|
||||||
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 {
|
|
||||||
return "", fmt.Errorf("loading case: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load recent events
|
|
||||||
var events []models.CaseEvent
|
|
||||||
if err := s.db.SelectContext(ctx, &events,
|
|
||||||
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 10",
|
|
||||||
caseID, tenantID); err != nil {
|
|
||||||
return "", fmt.Errorf("loading events: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load active deadlines
|
|
||||||
var deadlines []models.Deadline
|
|
||||||
if err := s.db.SelectContext(ctx, &deadlines,
|
|
||||||
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY due_date ASC LIMIT 10",
|
|
||||||
caseID, tenantID); err != nil {
|
|
||||||
return "", fmt.Errorf("loading deadlines: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build context text
|
|
||||||
caseInfo := fmt.Sprintf("Case: %s — %s\nStatus: %s", c.CaseNumber, c.Title, c.Status)
|
|
||||||
if c.Court != nil {
|
|
||||||
caseInfo += fmt.Sprintf("\nCourt: %s", *c.Court)
|
|
||||||
}
|
|
||||||
if c.CourtRef != nil {
|
|
||||||
caseInfo += fmt.Sprintf("\nCourt Reference: %s", *c.CourtRef)
|
|
||||||
}
|
|
||||||
if c.CaseType != nil {
|
|
||||||
caseInfo += fmt.Sprintf("\nType: %s", *c.CaseType)
|
|
||||||
}
|
|
||||||
|
|
||||||
eventText := "\n\nRecent Events:"
|
|
||||||
if len(events) == 0 {
|
|
||||||
eventText += "\nNo events recorded."
|
|
||||||
}
|
|
||||||
for _, e := range events {
|
|
||||||
eventText += fmt.Sprintf("\n- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title)
|
|
||||||
if e.Description != nil {
|
|
||||||
eventText += fmt.Sprintf(": %s", *e.Description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deadlineText := "\n\nUpcoming Deadlines:"
|
|
||||||
if len(deadlines) == 0 {
|
|
||||||
deadlineText += "\nNo active deadlines."
|
|
||||||
}
|
|
||||||
for _, d := range deadlines {
|
|
||||||
deadlineText += fmt.Sprintf("\n- %s: due %s (status: %s)", d.Title, d.DueDate, d.Status)
|
|
||||||
if d.Description != nil {
|
|
||||||
deadlineText += fmt.Sprintf(" — %s", *d.Description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := caseInfo + eventText + deadlineText
|
|
||||||
|
|
||||||
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
|
|
||||||
Model: anthropic.ModelClaudeSonnet4_5,
|
|
||||||
MaxTokens: 512,
|
|
||||||
System: []anthropic.TextBlockParam{
|
|
||||||
{Text: summarizeSystemPrompt},
|
|
||||||
},
|
|
||||||
Messages: []anthropic.MessageParam{
|
|
||||||
anthropic.NewUserMessage(anthropic.NewTextBlock("Summarize the current state of this case:\n\n" + prompt)),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("claude API call: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract text from response
|
|
||||||
var summary string
|
|
||||||
for _, block := range msg.Content {
|
|
||||||
if block.Type == "text" {
|
|
||||||
summary += block.Text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if summary == "" {
|
|
||||||
return "", fmt.Errorf("empty response from Claude")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache summary in database
|
|
||||||
_, err = s.db.ExecContext(ctx,
|
|
||||||
"UPDATE cases SET ai_summary = $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4",
|
|
||||||
summary, time.Now(), caseID, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("caching summary: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary, nil
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeadlineExtractionToolSchema(t *testing.T) {
|
|
||||||
// Verify the tool schema serializes correctly
|
|
||||||
data, err := json.Marshal(deadlineExtractionTool)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to marshal tool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed map[string]any
|
|
||||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
||||||
t.Fatalf("failed to unmarshal tool JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed["name"] != "extract_deadlines" {
|
|
||||||
t.Errorf("expected name 'extract_deadlines', got %v", parsed["name"])
|
|
||||||
}
|
|
||||||
|
|
||||||
schema, ok := parsed["input_schema"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("input_schema is not a map")
|
|
||||||
}
|
|
||||||
|
|
||||||
if schema["type"] != "object" {
|
|
||||||
t.Errorf("expected schema type 'object', got %v", schema["type"])
|
|
||||||
}
|
|
||||||
|
|
||||||
props, ok := schema["properties"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("properties is not a map")
|
|
||||||
}
|
|
||||||
|
|
||||||
deadlines, ok := props["deadlines"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("deadlines property is not a map")
|
|
||||||
}
|
|
||||||
|
|
||||||
if deadlines["type"] != "array" {
|
|
||||||
t.Errorf("expected deadlines type 'array', got %v", deadlines["type"])
|
|
||||||
}
|
|
||||||
|
|
||||||
items, ok := deadlines["items"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("items is not a map")
|
|
||||||
}
|
|
||||||
|
|
||||||
itemProps, ok := items["properties"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("item properties is not a map")
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedFields := []string{"title", "due_date", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"}
|
|
||||||
for _, field := range expectedFields {
|
|
||||||
if _, ok := itemProps[field]; !ok {
|
|
||||||
t.Errorf("missing expected field %q in item properties", field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
required, ok := items["required"].([]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("required is not a list")
|
|
||||||
}
|
|
||||||
if len(required) != 8 {
|
|
||||||
t.Errorf("expected 8 required fields, got %d", len(required))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractedDeadlineJSON(t *testing.T) {
|
|
||||||
dueDate := "2026-04-15"
|
|
||||||
d := ExtractedDeadline{
|
|
||||||
Title: "Statement of Defence",
|
|
||||||
DueDate: &dueDate,
|
|
||||||
DurationValue: 3,
|
|
||||||
DurationUnit: "months",
|
|
||||||
Timing: "after",
|
|
||||||
TriggerEvent: "service of the Statement of Claim",
|
|
||||||
RuleReference: "Rule 23 RoP",
|
|
||||||
Confidence: 0.95,
|
|
||||||
SourceQuote: "The defendant shall file a defence within 3 months",
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(d)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to marshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed ExtractedDeadline
|
|
||||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
||||||
t.Fatalf("failed to unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed.Title != d.Title {
|
|
||||||
t.Errorf("title mismatch: %q != %q", parsed.Title, d.Title)
|
|
||||||
}
|
|
||||||
if *parsed.DueDate != *d.DueDate {
|
|
||||||
t.Errorf("due_date mismatch: %q != %q", *parsed.DueDate, *d.DueDate)
|
|
||||||
}
|
|
||||||
if parsed.DurationValue != d.DurationValue {
|
|
||||||
t.Errorf("duration_value mismatch: %d != %d", parsed.DurationValue, d.DurationValue)
|
|
||||||
}
|
|
||||||
if parsed.Confidence != d.Confidence {
|
|
||||||
t.Errorf("confidence mismatch: %f != %f", parsed.Confidence, d.Confidence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
151
backend/internal/services/dashboard_service.go
Normal file
151
backend/internal/services/dashboard_service.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardService(db *sqlx.DB) *DashboardService {
|
||||||
|
return &DashboardService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardData struct {
|
||||||
|
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
|
||||||
|
CaseSummary CaseSummary `json:"case_summary"`
|
||||||
|
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
||||||
|
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
||||||
|
RecentActivity []RecentActivity `json:"recent_activity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeadlineSummary struct {
|
||||||
|
OverdueCount int `json:"overdue_count" db:"overdue_count"`
|
||||||
|
DueThisWeek int `json:"due_this_week" db:"due_this_week"`
|
||||||
|
DueNextWeek int `json:"due_next_week" db:"due_next_week"`
|
||||||
|
OKCount int `json:"ok_count" db:"ok_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseSummary struct {
|
||||||
|
ActiveCount int `json:"active_count" db:"active_count"`
|
||||||
|
NewThisMonth int `json:"new_this_month" db:"new_this_month"`
|
||||||
|
ClosedCount int `json:"closed_count" db:"closed_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpcomingDeadline struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
DueDate string `json:"due_date" db:"due_date"`
|
||||||
|
CaseNumber string `json:"case_number" db:"case_number"`
|
||||||
|
CaseTitle string `json:"case_title" db:"case_title"`
|
||||||
|
Status string `json:"status" db:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpcomingAppointment struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
StartAt time.Time `json:"start_at" db:"start_at"`
|
||||||
|
CaseNumber *string `json:"case_number" db:"case_number"`
|
||||||
|
Location *string `json:"location" db:"location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentActivity struct {
|
||||||
|
EventType *string `json:"event_type" db:"event_type"`
|
||||||
|
Title string `json:"title" db:"title"`
|
||||||
|
CaseNumber string `json:"case_number" db:"case_number"`
|
||||||
|
EventDate *time.Time `json:"event_date" db:"event_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*DashboardData, error) {
|
||||||
|
now := time.Now()
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||||
|
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||||
|
|
||||||
|
data := &DashboardData{}
|
||||||
|
|
||||||
|
// Single query with CTEs for deadline + case summaries
|
||||||
|
summaryQuery := `
|
||||||
|
WITH deadline_stats AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE due_date < $2 AND status = 'pending') AS overdue_count,
|
||||||
|
COUNT(*) FILTER (WHERE due_date >= $2 AND due_date <= $3 AND status = 'pending') AS due_this_week,
|
||||||
|
COUNT(*) FILTER (WHERE due_date > $3 AND due_date <= $4 AND status = 'pending') AS due_next_week,
|
||||||
|
COUNT(*) FILTER (WHERE due_date > $4 AND status = 'pending') AS ok_count
|
||||||
|
FROM deadlines
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
),
|
||||||
|
case_stats AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
|
||||||
|
COUNT(*) FILTER (WHERE created_at >= $5::date AND status != 'archived') AS new_this_month,
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed_count
|
||||||
|
FROM cases
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ds.overdue_count, ds.due_this_week, ds.due_next_week, ds.ok_count,
|
||||||
|
cs.active_count, cs.new_this_month, cs.closed_count
|
||||||
|
FROM deadline_stats ds, case_stats cs`
|
||||||
|
|
||||||
|
var summaryRow struct {
|
||||||
|
DeadlineSummary
|
||||||
|
CaseSummary
|
||||||
|
}
|
||||||
|
err := s.db.GetContext(ctx, &summaryRow, summaryQuery, tenantID, today, endOfWeek, endOfNextWeek, startOfMonth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard summary: %w", err)
|
||||||
|
}
|
||||||
|
data.DeadlineSummary = summaryRow.DeadlineSummary
|
||||||
|
data.CaseSummary = summaryRow.CaseSummary
|
||||||
|
|
||||||
|
// Upcoming deadlines (next 7 days)
|
||||||
|
deadlineQuery := `
|
||||||
|
SELECT d.id, d.title, d.due_date, c.case_number, c.title AS case_title, d.status
|
||||||
|
FROM deadlines d
|
||||||
|
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
|
||||||
|
WHERE d.tenant_id = $1 AND d.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3
|
||||||
|
ORDER BY d.due_date ASC`
|
||||||
|
|
||||||
|
data.UpcomingDeadlines = []UpcomingDeadline{}
|
||||||
|
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, deadlineQuery, tenantID, today, in7Days); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upcoming appointments (next 7 days)
|
||||||
|
appointmentQuery := `
|
||||||
|
SELECT a.id, a.title, a.start_at, c.case_number, a.location
|
||||||
|
FROM appointments a
|
||||||
|
LEFT JOIN cases c ON c.id = a.case_id AND c.tenant_id = a.tenant_id
|
||||||
|
WHERE a.tenant_id = $1 AND a.start_at >= $2::timestamp AND a.start_at < ($2::date + interval '7 days')
|
||||||
|
ORDER BY a.start_at ASC`
|
||||||
|
|
||||||
|
data.UpcomingAppointments = []UpcomingAppointment{}
|
||||||
|
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, appointmentQuery, tenantID, now); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard upcoming appointments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent activity (last 10 case events)
|
||||||
|
activityQuery := `
|
||||||
|
SELECT ce.event_type, ce.title, c.case_number, ce.event_date
|
||||||
|
FROM case_events ce
|
||||||
|
JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id
|
||||||
|
WHERE ce.tenant_id = $1
|
||||||
|
ORDER BY COALESCE(ce.event_date, ce.created_at) DESC
|
||||||
|
LIMIT 10`
|
||||||
|
|
||||||
|
data.RecentActivity = []RecentActivity{}
|
||||||
|
if err := s.db.SelectContext(ctx, &data.RecentActivity, activityQuery, tenantID); err != nil {
|
||||||
|
return nil, fmt.Errorf("dashboard recent activity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
33
backend/internal/services/dashboard_service_test.go
Normal file
33
backend/internal/services/dashboard_service_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDashboardDateCalculations(t *testing.T) {
|
||||||
|
// Verify the date range logic used in Get()
|
||||||
|
now := time.Date(2026, 3, 25, 14, 0, 0, 0, time.UTC) // Wednesday
|
||||||
|
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
|
||||||
|
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||||
|
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||||
|
|
||||||
|
if today != "2026-03-25" {
|
||||||
|
t.Errorf("today = %s, want 2026-03-25", today)
|
||||||
|
}
|
||||||
|
if endOfWeek != "2026-03-29" { // Sunday
|
||||||
|
t.Errorf("endOfWeek = %s, want 2026-03-29", endOfWeek)
|
||||||
|
}
|
||||||
|
if endOfNextWeek != "2026-04-05" {
|
||||||
|
t.Errorf("endOfNextWeek = %s, want 2026-04-05", endOfNextWeek)
|
||||||
|
}
|
||||||
|
if in7Days != "2026-04-01" {
|
||||||
|
t.Errorf("in7Days = %s, want 2026-04-01", in7Days)
|
||||||
|
}
|
||||||
|
if startOfMonth != "2026-03-01" {
|
||||||
|
t.Errorf("startOfMonth = %s, want 2026-03-01", startOfMonth)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user