Compare commits

..

1 Commits

Author SHA1 Message Date
m
5758e2c37f feat: add AI deadline extraction and case summary endpoints (Phase 2J)
Add two Claude API-powered endpoints:
- POST /api/ai/extract-deadlines: accepts PDF upload or JSON text, extracts
  legal deadlines using Claude tool_use for structured output
- POST /api/ai/summarize-case: generates AI summary from case events/deadlines,
  caches result in cases.ai_summary

New files:
- internal/services/ai_service.go: AIService with Anthropic SDK integration
- internal/handlers/ai.go: HTTP handlers for both endpoints
- internal/services/ai_service_test.go: tool schema and serialization tests

Uses anthropic-sdk-go v1.27.1 with Claude Sonnet 4.5. AI service is optional —
endpoints only registered when ANTHROPIC_API_KEY is set.
2026-03-25 13:40:27 +01:00
30 changed files with 577 additions and 973 deletions

View File

@@ -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) handler := router.New(database, authMW, cfg.AnthropicAPIKey)
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 {

View File

@@ -3,8 +3,14 @@ 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
) )

View File

@@ -1,4 +1,6 @@
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=
@@ -10,3 +12,15 @@ 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=

View File

@@ -0,0 +1,115 @@
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,
})
}

View File

@@ -203,14 +203,3 @@ func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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})
}

View File

@@ -83,3 +83,8 @@ 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)
}

View File

@@ -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) http.Handler { func New(db *sqlx.DB, authMW *auth.Middleware, anthropicAPIKey string) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
// Services // Services
@@ -24,6 +24,13 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
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)
@@ -86,6 +93,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
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
if aiH != nil {
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"))

View File

@@ -0,0 +1,283 @@
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
}

View File

@@ -0,0 +1,109 @@
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)
}
}

View File

@@ -5,15 +5,9 @@
"": { "": {
"name": "frontend", "name": "frontend",
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.100.0",
"@tanstack/react-query": "^5.95.2",
"date-fns": "^4.1.0",
"lucide-react": "^1.6.0",
"next": "15.5.14", "next": "15.5.14",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sonner": "^2.0.7",
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -157,22 +151,6 @@
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="], "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
"@supabase/auth-js": ["@supabase/auth-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pdT3ye3UVRN1Cg0wom6BmyY+XTtp5DiJaYnPi6j8ht5i8Lq8kfqxJMJz9GI9YDKk3w1nhGOPnh6Qz5qpyYm+1w=="],
"@supabase/functions-js": ["@supabase/functions-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-keLg79RPwP+uiwHuxFPTFgDRxPV46LM4j/swjyR2GKJgWniTVSsgiBHfbIBDcrQwehLepy09b/9QSHUywtKRWQ=="],
"@supabase/phoenix": ["@supabase/phoenix@0.4.0", "", {}, "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-xYNvNbBJaXOGcrZ44wxwp5830uo1okMHGS8h8dm3u4f0xcZ39yzbryUsubTJW41MG2gbL/6U57cA4Pi6YMZ9pA=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.100.0", "", { "dependencies": { "@supabase/phoenix": "^0.4.0", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2AZs00zzEF0HuCKY8grz5eCYlwEfVi5HONLZFoNR6aDfxQivl8zdQYNjyFoqN2MZiVhQHD7u6XV/xHwM8mCEHw=="],
"@supabase/ssr": ["@supabase/ssr@0.9.0", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.97.0" } }, "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q=="],
"@supabase/storage-js": ["@supabase/storage-js@2.100.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-d4EeuK6RNIgYNA2MU9kj8lQrLm5AzZ+WwpWjGkii6SADQNIGTC/uiaTRu02XJ5AmFALQfo8fLl9xuCkO6Xw+iQ=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.100.0", "", { "dependencies": { "@supabase/auth-js": "2.100.0", "@supabase/functions-js": "2.100.0", "@supabase/postgrest-js": "2.100.0", "@supabase/realtime-js": "2.100.0", "@supabase/storage-js": "2.100.0" } }, "sha512-r0tlcukejJXJ1m/2eG/Ya5eYs4W8AC7oZfShpG3+SIo/eIU9uIt76ZeYI1SoUwUmcmzlAbgch+HDZDR/toVQPQ=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
@@ -205,10 +183,6 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="], "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -223,8 +197,6 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
@@ -347,8 +319,6 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -361,8 +331,6 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -495,8 +463,6 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -617,8 +583,6 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lucide-react": ["lucide-react@1.6.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -741,8 +705,6 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
@@ -817,8 +779,6 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -9,15 +9,9 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.100.0",
"@tanstack/react-query": "^5.95.2",
"date-fns": "^4.1.0",
"lucide-react": "^1.6.0",
"next": "15.5.14",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sonner": "^2.0.7" "next": "15.5.14"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "typescript": "^5",

View File

@@ -1,20 +0,0 @@
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
export const dynamic = "force-dynamic";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden bg-neutral-50">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -1,10 +0,0 @@
export default function DashboardPage() {
return (
<div>
<h1 className="text-lg font-semibold text-neutral-900">Dashboard</h1>
<p className="mt-1 text-sm text-neutral-500">
Willkommen bei KanzlAI
</p>
</div>
);
}

View File

@@ -1,25 +0,0 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function CallbackPage() {
const router = useRouter();
const supabase = createClient();
useEffect(() => {
supabase.auth.onAuthStateChange((event) => {
if (event === "SIGNED_IN") {
router.push("/");
router.refresh();
}
});
}, [router, supabase.auth]);
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<p className="text-sm text-neutral-500">Authentifizierung...</p>
</div>
);
}

View File

@@ -1,9 +0,0 @@
export const dynamic = "force-dynamic";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -1,189 +0,0 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"password" | "magic">("password");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [magicSent, setMagicSent] = useState(false);
const router = useRouter();
const supabase = createClient();
async function handlePasswordLogin(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
async function handleMagicLink(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/callback`,
},
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setMagicSent(true);
setLoading(false);
}
if (magicSent) {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
Link gesendet
</h1>
<p className="mt-2 text-sm text-neutral-500">
Wir haben einen Login-Link an{" "}
<span className="font-medium text-neutral-700">{email}</span>{" "}
gesendet. Bitte pruefen Sie Ihren Posteingang.
</p>
</div>
<button
onClick={() => setMagicSent(false)}
className="w-full text-center text-sm text-neutral-500 hover:text-neutral-700"
>
Zurueck zum Login
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
KanzlAI
</h1>
<p className="mt-1 text-sm text-neutral-500">
Melden Sie sich an
</p>
</div>
<div className="flex rounded-md border border-neutral-200 bg-neutral-50 p-0.5">
<button
onClick={() => setMode("password")}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "password"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Passwort
</button>
<button
onClick={() => setMode("magic")}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "magic"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Magic Link
</button>
</div>
<form
onSubmit={mode === "password" ? handlePasswordLogin : handleMagicLink}
className="space-y-4"
>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-700"
>
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="anwalt@kanzlei.de"
/>
</div>
{mode === "password" && (
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-700"
>
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
/>
</div>
)}
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 disabled:opacity-50"
>
{loading
? "..."
: mode === "password"
? "Anmelden"
: "Link senden"}
</button>
</form>
<p className="text-center text-sm text-neutral-500">
Noch kein Konto?{" "}
<a
href="/register"
className="font-medium text-neutral-900 hover:underline"
>
Registrieren
</a>
</p>
</div>
</div>
);
}

View File

@@ -1,151 +0,0 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { api } from "@/lib/api";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firmName, setFirmName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
// 1. Create auth user
const { data, error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/callback`,
},
});
if (authError) {
setError(authError.message);
setLoading(false);
return;
}
// 2. Create tenant via backend (the backend adds the user as owner)
if (data.session) {
try {
await api.post("/tenants", { name: firmName });
} catch (err: unknown) {
const apiErr = err as { error?: string };
setError(apiErr.error || "Kanzlei konnte nicht erstellt werden");
setLoading(false);
return;
}
router.push("/");
router.refresh();
} else {
// Email confirmation required
router.push("/login");
}
setLoading(false);
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
KanzlAI
</h1>
<p className="mt-1 text-sm text-neutral-500">
Erstellen Sie Ihr Konto
</p>
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label
htmlFor="firm"
className="block text-sm font-medium text-neutral-700"
>
Kanzleiname
</label>
<input
id="firm"
type="text"
value={firmName}
onChange={(e) => setFirmName(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="Muster & Partner Rechtsanwaelte"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-700"
>
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="anwalt@kanzlei.de"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-700"
>
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
/>
<p className="mt-1 text-xs text-neutral-400">Mindestens 8 Zeichen</p>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 disabled:opacity-50"
>
{loading ? "..." : "Konto erstellen"}
</button>
</form>
<p className="text-center text-sm text-neutral-500">
Bereits registriert?{" "}
<a
href="/login"
className="font-medium text-neutral-900 hover:underline"
>
Anmelden
</a>
</p>
</div>
</div>
);
}

View File

@@ -1,11 +1,26 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
body { @media (prefers-color-scheme: dark) {
-webkit-font-smoothing: antialiased; :root {
-moz-osx-font-smoothing: grayscale; --background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
} }

View File

@@ -1,6 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/Providers";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -14,7 +13,7 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "KanzlAI", title: "KanzlAI-mGMT",
description: "Kanzleimanagement online", description: "Kanzleimanagement online",
}; };
@@ -24,11 +23,11 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="de"> <html lang="en">
<body <body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<Providers>{children}</Providers> {children}
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,7 @@
export default function Home() {
return (
<main className="flex min-h-screen items-center justify-center">
<h1 className="text-4xl font-bold">KanzlAI-mGMT</h1>
</main>
);
}

View File

@@ -1,26 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="bottom-right" richColors />
</QueryClientProvider>
);
}

View File

@@ -1,44 +0,0 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { TenantSwitcher } from "./TenantSwitcher";
import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function Header() {
const [email, setEmail] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
setEmail(user?.email ?? null);
});
}, [supabase.auth]);
async function handleLogout() {
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
return (
<header className="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4">
<div />
<div className="flex items-center gap-3">
<TenantSwitcher />
{email && (
<span className="text-sm text-neutral-500">{email}</span>
)}
<button
onClick={handleLogout}
title="Abmelden"
className="rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
);
}

View File

@@ -1,55 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
FolderOpen,
Clock,
Calendar,
Brain,
Settings,
} from "lucide-react";
const navigation = [
{ name: "Dashboard", href: "/", icon: LayoutDashboard },
{ name: "Akten", href: "/akten", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "AI Analyse", href: "/ai", icon: Brain },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="flex h-full w-56 flex-col border-r border-neutral-200 bg-white">
<div className="flex h-14 items-center border-b border-neutral-200 px-4">
<span className="text-sm font-semibold text-neutral-900">KanzlAI</span>
</div>
<nav className="flex-1 space-y-0.5 p-2">
{navigation.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
isActive
? "bg-neutral-100 font-medium text-neutral-900"
: "text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
}`}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.name}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -1,79 +0,0 @@
"use client";
import { api } from "@/lib/api";
import type { TenantWithRole } from "@/lib/types";
import { ChevronsUpDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
export function TenantSwitcher() {
const [tenants, setTenants] = useState<TenantWithRole[]>([]);
const [current, setCurrent] = useState<TenantWithRole | null>(null);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
api.get<TenantWithRole[]>("/tenants").then((data) => {
setTenants(data);
const savedId = localStorage.getItem("kanzlai_tenant_id");
const match = data.find((t) => t.id === savedId) || data[0];
if (match) {
setCurrent(match);
localStorage.setItem("kanzlai_tenant_id", match.id);
}
}).catch(() => {
// Not authenticated or no tenants
});
}, []);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function switchTenant(tenant: TenantWithRole) {
setCurrent(tenant);
localStorage.setItem("kanzlai_tenant_id", tenant.id);
setOpen(false);
window.location.reload();
}
if (!current) return null;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50"
>
<span className="max-w-[160px] truncate">{current.name}</span>
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
</button>
{open && tenants.length > 1 && (
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
{tenants.map((tenant) => (
<button
key={tenant.id}
onClick={() => switchTenant(tenant)}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
tenant.id === current.id
? "bg-neutral-50 font-medium text-neutral-900"
: "text-neutral-600 hover:bg-neutral-50"
}`}
>
<span className="truncate">{tenant.name}</span>
<span className="ml-auto text-xs text-neutral-400">
{tenant.role}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,77 +0,0 @@
import { createClient } from "@/lib/supabase/client";
import type { ApiError } from "@/lib/types";
class ApiClient {
private baseUrl = "/api";
private async getHeaders(): Promise<HeadersInit> {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (session?.access_token) {
headers["Authorization"] = `Bearer ${session.access_token}`;
}
const tenantId = typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
return headers;
}
private async request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = await this.getHeaders();
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: { ...headers, ...options.headers },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err: ApiError = {
error: body.error || res.statusText,
status: res.status,
};
throw err;
}
if (res.status === 204) return undefined as T;
return res.json();
}
get<T>(path: string) {
return this.request<T>(path, { method: "GET" });
}
post<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
}
put<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: "PUT",
body: body ? JSON.stringify(body) : undefined,
});
}
delete<T>(path: string) {
return this.request<T>(path, { method: "DELETE" });
}
}
export const api = new ApiClient();

View File

@@ -1,8 +0,0 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}

View File

@@ -1,29 +0,0 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// setAll is called from Server Components where cookies
// cannot be set. This is safe to ignore when middleware
// handles the session refresh.
}
},
},
},
);
}

View File

@@ -1,117 +0,0 @@
export interface Tenant {
id: string;
name: string;
slug: string;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface TenantWithRole extends Tenant {
role: string;
}
export interface UserTenant {
user_id: string;
tenant_id: string;
role: string;
created_at: string;
}
export interface Case {
id: string;
tenant_id: string;
case_number: string;
title: string;
case_type?: string;
court?: string;
court_ref?: string;
status: string;
ai_summary?: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface Party {
id: string;
tenant_id: string;
case_id: string;
name: string;
role?: string;
representative?: string;
contact_info: Record<string, unknown>;
}
export interface Deadline {
id: string;
tenant_id: string;
case_id: string;
title: string;
description?: string;
due_date: string;
original_due_date?: string;
warning_date?: string;
source: string;
rule_id?: string;
status: string;
completed_at?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface Appointment {
id: string;
tenant_id: string;
case_id?: string;
title: string;
description?: string;
start_at: string;
end_at?: string;
location?: string;
appointment_type?: string;
created_at: string;
updated_at: string;
}
export interface CaseEvent {
id: string;
tenant_id: string;
case_id: string;
event_type?: string;
title: string;
description?: string;
event_date?: string;
created_by?: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface Document {
id: string;
tenant_id: string;
case_id: string;
title: string;
doc_type?: string;
file_path?: string;
file_size?: number;
mime_type?: string;
ai_extracted?: Record<string, unknown>;
uploaded_by?: string;
created_at: string;
updated_at: string;
}
export interface ApiError {
error: string;
status: number;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
}

View File

@@ -1,60 +0,0 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
);
},
},
},
);
const {
data: { user },
} = await supabase.auth.getUser();
const { pathname } = request.nextUrl;
// Auth pages — redirect to app if already logged in
if (user && (pathname === "/login" || pathname === "/register")) {
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
// Protected routes — redirect to login if not authenticated
if (
!user &&
!pathname.startsWith("/login") &&
!pathname.startsWith("/register") &&
!pathname.startsWith("/callback")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};