Compare commits

...

18 Commits

Author SHA1 Message Date
m
e53e1389f9 feat: add dashboard aggregation endpoint (Phase 2I)
GET /api/dashboard returns aggregated data:
- deadline_summary: overdue, due this/next week, ok counts
- case_summary: active, new this month, closed counts
- upcoming_deadlines: next 7 days with case info
- upcoming_appointments: next 7 days
- recent_activity: last 10 case events

Uses efficient CTE query for summaries. Also fixes duplicate
writeJSON/writeError declarations in appointments handler.
2026-03-25 13:37:06 +01:00
m
2c16f26448 feat: add deadline CRUD, calculator, and holiday services (Phase 1C) 2026-03-25 13:33:57 +01:00
m
f0ee5921cf feat: add appointment CRUD backend (Phase 1D) 2026-03-25 13:32:51 +01:00
m
ba29fc75c7 feat: add case + party CRUD with case events (Phase 1B) 2026-03-25 13:32:15 +01:00
m
8350a7e7fb feat: add tenant + auth backend endpoints (Phase 1A) 2026-03-25 13:31:38 +01:00
m
42a62d45bf feat: add deadline CRUD, calculator, and holiday services (Phase 1C)
- Holiday service with German federal holidays, Easter calculation, DB loading
- Deadline calculator adapted from youpc.org (duration calc + non-working day adjustment)
- Deadline CRUD service (tenant-scoped: list, create, update, complete, delete)
- Deadline rule service (list, filter by proceeding type, hierarchical rule trees)
- HTTP handlers for all endpoints with tenant resolution via X-Tenant-ID header
- Router wired with all new endpoints under /api/
- Tests for holiday and calculator services (8 passing)
2026-03-25 13:31:29 +01:00
m
0b6bab8512 feat: add tenant + auth backend endpoints (Phase 1A)
Tenant management:
- POST /api/tenants — create tenant (creator becomes owner)
- GET /api/tenants — list tenants for authenticated user
- GET /api/tenants/:id — tenant details with access check
- POST /api/tenants/:id/invite — invite user by email (owner/admin)
- DELETE /api/tenants/:id/members/:uid — remove member
- GET /api/tenants/:id/members — list members

New packages:
- internal/services/tenant_service.go — CRUD on tenants + user_tenants
- internal/handlers/tenant_handler.go — HTTP handlers with auth checks
- internal/auth/tenant_resolver.go — X-Tenant-ID header middleware,
  defaults to user's first tenant for scoped routes

Authorization: owners/admins can invite and remove members. Cannot
remove the last owner. Users can remove themselves. TenantResolver
applies to resource routes (cases, deadlines, etc.) but not tenant
management routes.
2026-03-25 13:27:39 +01:00
m
f11c411147 feat: add case + party CRUD with case events (Phase 1B)
- CaseService: list (paginated, filterable), get detail (with parties,
  events, deadline count), create, update, soft-delete (archive)
- PartyService: list by case, create, update, delete
- Auto-create case_events on case creation, status change, party add,
  and case archive
- Auth middleware now resolves tenant_id from user_tenants table
- All operations scoped to tenant_id from auth context
2026-03-25 13:26:50 +01:00
m
bd15b4eb38 feat: add appointment CRUD backend (Phase 1D)
- AppointmentService with tenant-scoped List, GetByID, Create, Update, Delete
- List supports filtering by case_id, appointment_type, and date range (start_from/start_to)
- AppointmentHandler with JSON request/response handling and input validation
- Router wired up: GET/POST /api/appointments, PUT/DELETE /api/appointments/{id}
2026-03-25 13:25:46 +01:00
m
8049ea3c63 feat: add database schema and backend foundation (Phase 0) 2026-03-25 13:23:29 +01:00
m
1fc0874893 feat: add database schema and backend foundation
Part 1 - Database (kanzlai schema in Supabase):
- Tenant-scoped tables: tenants, user_tenants, cases, parties,
  deadlines, appointments, documents, case_events
- Global reference tables: proceeding_types, deadline_rules, holidays
- RLS policies on all tenant-scoped tables
- Seed: UPC proceeding types, 32 deadline rules (INF/CCR/REV/PI/APP),
  ZPO civil rules (Berufung, Revision, Einspruch), 2026 holidays

Part 2 - Backend skeleton:
- config: env var loading (DATABASE_URL, SUPABASE_*, ANTHROPIC_API_KEY)
- db: sqlx connection pool with kanzlai search_path
- auth: JWT verification middleware adapted from youpc.org, context helpers
- models: Go structs for all tables with sqlx/json tags
- router: route registration with auth middleware, /health + placeholder API routes
- Updated main.go to wire everything together
2026-03-25 13:17:33 +01:00
m
193a4cd567 refactor: rename to KanzlAI-mGMT, pivot to Kanzleimanagement
New direction: law firm management (Fristen, Termine, case tracking)
instead of UPC case law search. Updated all references, Go module
path, and deployment info.
2026-03-25 12:40:15 +01:00
m
792d084b4f fix: use node fetch for frontend health check
wget in node:22-alpine can't connect to localhost:3000 — use
node's built-in fetch instead, which works correctly.
2026-03-24 23:47:36 +01:00
m
ff9a6f3866 fix: use expose instead of ports for Dokploy/Traefik compatibility
Port 3000 conflicts with Dokploy. Traefik routes traffic via
Docker network, so expose is sufficient. Also remove env_file
refs since Dokploy injects env vars directly.
2026-03-24 23:43:11 +01:00
m
83a18a0a85 build: add Docker Compose setup for Dokploy deployment 2026-03-24 19:25:48 +01:00
m
b797b349e7 build: add Docker Compose setup for Dokploy deployment
- Multi-stage Dockerfile for Go backend (golang:1.25-alpine -> alpine:3)
- Multi-stage Dockerfile for Next.js frontend (bun:1 -> node:22-alpine)
- docker-compose.yml with backend + frontend services, health checks
- Next.js standalone output + API rewrites to proxy /api/* to backend
- .dockerignore files for both services
- .env.example documenting required environment variables
2026-03-24 19:20:49 +01:00
m
b2139b046e feat: scaffold monorepo with Go backend + Next.js 15 frontend 2026-03-24 18:36:12 +01:00
m
e9bb6a7960 feat: scaffold monorepo with Go backend + Next.js 15 frontend
- Root monorepo: /backend (Go) + /frontend (Next.js 15)
- Go module: mgit.msbls.de/m/KanzlAI with minimal HTTP server
- Next.js 15: TypeScript strict, Tailwind CSS v4, App Router, Bun
- Root Makefile: dev, build, lint, test targets
- Root .gitignore covering Go, Node, IDE, OS files
- CLAUDE.md updated with project structure and tech stack
- .claude/CLAUDE.md with coding conventions (Go stdlib style, TS strict)
2026-03-24 18:32:50 +01:00
87 changed files with 5459 additions and 8 deletions

25
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,25 @@
# KanzlAI Coding Conventions
## Go (backend/)
- Standard library style — prefer stdlib over third-party packages where reasonable
- `go vet` and `go test` must pass before committing
- Package layout: `cmd/` for entry points, `internal/` for private packages
- Error handling: return errors, don't panic. Wrap with context via `fmt.Errorf("operation: %w", err)`
- Naming: follow Go conventions (MixedCaps, not snake_case)
- No global mutable state
## TypeScript / Next.js (frontend/)
- TypeScript strict mode (enabled in tsconfig.json)
- App Router (src/app/) — no Pages Router
- Use server components by default, `"use client"` only when needed
- Tailwind CSS v4 for styling — no CSS modules or styled-components
- ESLint must pass before committing
- Import aliases: `@/` maps to `src/`
- Bun as package manager (not npm/yarn/pnpm)
## General
- No secrets in code — use environment variables
- Commit messages: `feat:`, `fix:`, `build:`, `refactor:`, `docs:`, `test:`, `chore:`

14
.claude/agents/coder.md Normal file
View File

@@ -0,0 +1,14 @@
# Coder Agent
Implementation-focused agent for writing and refactoring code.
## Instructions
- Follow existing patterns in the codebase
- Write minimal, focused code
- Run tests after changes
- Commit incrementally with descriptive messages
## Tools
All tools available.

View File

@@ -0,0 +1,14 @@
# Researcher Agent
Exploration and information gathering agent.
## Instructions
- Search broadly, then narrow down
- Document findings in structured format
- Cite sources and file paths
- Summarize key insights, don't dump raw data
## Tools
Read-only tools preferred. Use Bash only for non-destructive commands.

View File

@@ -0,0 +1,14 @@
# Reviewer Agent
Code review agent for checking quality and correctness.
## Instructions
- Check for bugs, security issues, and style violations
- Verify test coverage for changes
- Suggest improvements concisely
- Focus on correctness over style preferences
## Tools
Read-only tools. No file modifications.

1
.claude/skills/mai-clone Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-clone

1
.claude/skills/mai-coder Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-coder

1
.claude/skills/mai-commit Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-commit

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-consultant

1
.claude/skills/mai-daily Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-daily

1
.claude/skills/mai-debrief Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-debrief

1
.claude/skills/mai-enemy Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-enemy

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-excalidraw

1
.claude/skills/mai-fixer Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-fixer

1
.claude/skills/mai-gitster Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-gitster

1
.claude/skills/mai-head Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-head

1
.claude/skills/mai-init Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-init

1
.claude/skills/mai-inventor Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-inventor

1
.claude/skills/mai-lead Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-lead

1
.claude/skills/mai-maister Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-maister

1
.claude/skills/mai-member Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-member

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-researcher

1
.claude/skills/mai-think Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-think

1
.claude/skills/mai-web Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-web

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# KanzlAI Environment Variables
# Copy to .env and fill in values: cp .env.example .env
# Backend
PORT=8080
# Supabase (required for database access)
SUPABASE_URL=
SUPABASE_ANON_KEY=
# Claude API (required for AI features)
ANTHROPIC_API_KEY=

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Go
backend/bin/
*.exe
*.dll
*.so
*.dylib
# Node / Next.js
frontend/node_modules/
frontend/.next/
frontend/out/
frontend/build/
frontend/.env*
# Dependencies
node_modules/
# Testing
coverage/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Debug logs
*.log
npm-debug.log*
yarn-debug.log*
# Env
.env
.env.*
!.env.example
# Build artifacts
dist/
tmp/
# TypeScript
*.tsbuildinfo
.worktrees/

4
.m/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
workers.json
spawn.lock
session.yaml
config.reference.yaml

168
.m/config.yaml Normal file
View File

@@ -0,0 +1,168 @@
provider: claude
providers:
claude:
api_key: ""
model: claude-sonnet-4-20250514
base_url: https://api.anthropic.com/v1
ollama:
host: http://localhost:11434
model: llama3.2
memory:
enabled: true
backend: ""
path: ""
url: postgres://mai_memory.your-tenant-id:maiMem6034supa@100.99.98.201:6543/postgres?sslmode=disable
group_id: ""
cache_ttl: 5m0s
auto_load: true
embedding_url: ""
embedding_model: ""
gitea:
url: https://mgit.msbls.de
repo: m/KanzlAI
token: ""
sync:
enabled: false
interval: 0s
repos: []
auto_queue: false
api:
api_key: ""
basic_auth:
username: ""
password: ""
public_endpoints:
- /api/health
ui:
theme: default
show_sidebar: true
animation: true
persona: true
avatar_pack: ""
worker:
names: []
name_scheme: role
default_level: standard
auto_discard: false
max_workers: 5
persistent: true
head:
name: ingeborg
max_loops: 50
infinity_mode: false
capacity:
global:
max_workers: 5
max_heads: 3
per_worker:
max_tasks_lifetime: 0
max_concurrent: 1
max_context_tokens: 0
per_head:
max_workers: 10
resources:
max_memory_mb: 0
max_cpu_percent: 0
queue:
max_pending: 100
stale_task_days: 30
workforce:
timeouts:
task_default: 0s
task_max: 0s
idle_before_warn: 10m0s
idle_before_kill: 30m0s
quality_check: 2m0s
context:
max_tokens_per_worker: 0
max_tokens_global: 0
warn_threshold: 0.8
truncate_strategy: oldest
delegation:
strategy: skill_match
preferred_role: coder
auto_delegate: false
max_depth: 3
allowed_roles:
- coder
- researcher
- fixer
peppy:
enabled: false
style: calm
interval: 5m0s
emoji: false
nudges: true
nudge_main: false
custom_prompt: ""
stall_threshold: 0s
restart_enabled: false
max_shifts: 0
quality_gates:
enabled: true
checks: []
preflight:
enabled: false
type: ""
root: ""
checks: []
guardrails:
enabled: false
use_defaults: true
output:
coder_checks: []
researcher_checks: []
fixer_checks: []
custom_checks: {}
global_checks: []
tools:
role_rules: {}
deny_patterns: []
allow_patterns: []
schemas:
report_schemas: {}
deliverable_schemas: {}
modes:
yolo: false
self_improvement: false
autonomous: false
verbose: false
improve_interval: 0s
predict_interval: 0s
layouts:
head: ""
worker: ""
roles: {}
dog:
name: buddy
supabase:
url: ""
role_key: ""
anon_key: ""
schema: mai
storage:
backend: ""
postgres:
url: ""
max_conns: 0
min_conns: 0
max_conn_lifetime: 0s
idle:
behavior: wait
auto_hire: false
prompt: ""
git:
worktrees:
enabled: true
delete_branch: false
dir: .worktrees
phase:
enabled: false
current: ""
allowed_roles: {}
goal: ""
skills: {}
editor: nvim
log_level: info
project_detection: true
tone: professional

22
.mcp.json Normal file
View File

@@ -0,0 +1,22 @@
{
"mcpServers": {
"mai": {
"type": "http",
"url": "http://100.99.98.201:8000/mcp",
"headers": {
"Authorization": "Basic ${SUPABASE_AUTH}"
}
},
"mai-memory": {
"command": "mai",
"args": [
"mcp",
"memory"
],
"env": {
"MAI_MEMORY_EMBEDDING_MODEL": "nomic-embed-text",
"MAI_MEMORY_EMBEDDING_URL": "https://llm.x.msbls.de"
}
}
}
}

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.claude/CLAUDE.md

View File

@@ -1,19 +1,38 @@
# KanzlAI
# KanzlAI-mGMT
AI-powered law firm tools.
Kanzleimanagement online — law firm management for deadlines (Fristen), appointments (Termine), and case tracking.
**Memory group_id:** `kanzlai`
## Purpose
## Project Structure
TBD — to be defined by inventor.
```
backend/ Go API server
cmd/server/ Entry point
internal/ Private packages
frontend/ Next.js 15 (TypeScript, Tailwind CSS, App Router)
src/app/ App Router pages
```
## Tech Stack
- Web: static site (to be decided)
- Hosting: TBD
- **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun
- **Backend:** Go (standard library HTTP server)
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema in flexsiebels instance
- **Deploy:** Dokploy on mLake, domain: kanzlai.msbls.de
## Development
```bash
make dev-backend # Go server on :8080
make dev-frontend # Next.js dev server
make build # Build both
make lint # Lint both
make test # Test both
```
## Git Strategy
- **main** = production
- Feature branches: `feat/*`, `fix/*`
- Worker branches: `mai/<worker>/<task>`

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
.PHONY: dev dev-backend dev-frontend build build-backend build-frontend lint lint-backend lint-frontend test test-backend test-frontend clean
# Development
dev:
@echo "Start backend and frontend separately:"
@echo " make dev-backend"
@echo " make dev-frontend"
dev-backend:
cd backend && go run ./cmd/server
dev-frontend:
cd frontend && bun run dev
# Build
build: build-backend build-frontend
build-backend:
cd backend && go build -o bin/server ./cmd/server
build-frontend:
cd frontend && bun run build
# Lint
lint: lint-backend lint-frontend
lint-backend:
cd backend && go vet ./...
lint-frontend:
cd frontend && bun run lint
# Test
test: test-backend test-frontend
test-backend:
cd backend && go test ./...
test-frontend:
@echo "No frontend tests configured yet"
# Clean
clean:
rm -rf backend/bin
rm -rf frontend/.next frontend/out

View File

@@ -1,3 +1,27 @@
# KanzlAI
# KanzlAI-mGMT
AI-powered law firm tools
Kanzleimanagement online — law firm management for deadlines, appointments, and case tracking.
## Structure
```
backend/ Go API server
frontend/ Next.js 15 (TypeScript, Tailwind CSS)
```
## Development
```bash
make dev-backend # Go server on :8080
make dev-frontend # Next.js dev server
make build # Build both
make lint # Lint both
make test # Test both
```
## Tech Stack
- **Frontend:** Next.js 15, TypeScript, Tailwind CSS
- **Backend:** Go
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema
- **Deploy:** Dokploy on mLake (kanzlai.msbls.de)

6
backend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
bin/
*.exe
.git
.gitignore
Dockerfile
.dockerignore

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# Build
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
# Run
FROM alpine:3
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

View File

@@ -0,0 +1,32 @@
package main
import (
"log"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/router"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
database, err := db.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
handler := router.New(database, authMW)
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
log.Fatal(err)
}
}

10
backend/go.mod Normal file
View File

@@ -0,0 +1,10 @@
module mgit.msbls.de/m/KanzlAI-mGMT
go 1.25.5
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/lib/pq v1.12.0 // indirect
)

12
backend/go.sum Normal file
View File

@@ -0,0 +1,12 @@
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

View File

@@ -0,0 +1,32 @@
package auth
import (
"context"
"github.com/google/uuid"
)
type contextKey string
const (
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
func ContextWithTenantID(ctx context.Context, tenantID uuid.UUID) context.Context {
return context.WithValue(ctx, tenantIDKey, tenantID)
}
func UserFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDKey).(uuid.UUID)
return id, ok
}
func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}

View File

@@ -0,0 +1,102 @@
package auth
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Middleware struct {
jwtSecret []byte
db *sqlx.DB
}
func NewMiddleware(jwtSecret string, db *sqlx.DB) *Middleware {
return &Middleware{jwtSecret: []byte(jwtSecret), db: db}
}
func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
http.Error(w, "missing authorization token", http.StatusUnauthorized)
return
}
userID, err := m.verifyJWT(token)
if err != nil {
http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized)
return
}
ctx := ContextWithUserID(r.Context(), userID)
// Resolve tenant from user_tenants
var tenantID uuid.UUID
err = m.db.GetContext(r.Context(), &tenantID,
"SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
ctx = ContextWithTenantID(ctx, tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *Middleware) verifyJWT(tokenStr string) (uuid.UUID, error) {
parsedToken, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.jwtSecret, nil
})
if err != nil {
return uuid.Nil, fmt.Errorf("parsing JWT: %w", err)
}
if !parsedToken.Valid {
return uuid.Nil, fmt.Errorf("invalid JWT token")
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return uuid.Nil, fmt.Errorf("extracting JWT claims")
}
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
return uuid.Nil, fmt.Errorf("JWT token has expired")
}
}
sub, ok := claims["sub"].(string)
if !ok {
return uuid.Nil, fmt.Errorf("missing sub claim in JWT")
}
userID, err := uuid.Parse(sub)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid user ID format: %w", err)
}
return userID, nil
}
func extractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return parts[1]
}

View File

@@ -0,0 +1,61 @@
package auth
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
)
// TenantLookup resolves the default tenant for a user.
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
// or defaults to the user's first tenant.
type TenantResolver struct {
lookup TenantLookup
}
func NewTenantResolver(lookup TenantLookup) *TenantResolver {
return &TenantResolver{lookup: lookup}
}
func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var tenantID uuid.UUID
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
if err != nil {
http.Error(w, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest)
return
}
tenantID = parsed
} else {
// Default to user's first tenant
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil {
http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError)
return
}
if first == nil {
http.Error(w, "no tenant found for user", http.StatusBadRequest)
return
}
tenantID = *first
}
ctx := ContextWithTenantID(r.Context(), tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,124 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
type mockTenantLookup struct {
tenantID *uuid.UUID
err error
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := TenantFromContext(r.Context())
if !ok {
t.Fatal("tenant ID not in context")
}
gotTenantID = id
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", tenantID.String())
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
}
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, _ := TenantFromContext(r.Context())
gotTenantID = id
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
}
func TestTenantResolver_NoUser(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestTenantResolver_InvalidHeader(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", "not-a-uuid")
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestTenantResolver_NoTenantForUser(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{tenantID: nil})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}

View File

@@ -0,0 +1,42 @@
package config
import (
"fmt"
"os"
)
type Config struct {
Port string
DatabaseURL string
SupabaseURL string
SupabaseAnonKey string
SupabaseJWTSecret string
AnthropicAPIKey string
}
func Load() (*Config, error) {
cfg := &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
SupabaseURL: os.Getenv("SUPABASE_URL"),
SupabaseAnonKey: os.Getenv("SUPABASE_ANON_KEY"),
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
if cfg.SupabaseJWTSecret == "" {
return nil, fmt.Errorf("SUPABASE_JWT_SECRET is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,26 @@
package db
import (
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func Connect(databaseURL string) (*sqlx.DB, error) {
db, err := sqlx.Connect("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("connecting to database: %w", err)
}
// Set search_path so queries use kanzlai schema by default
if _, err := db.Exec("SET search_path TO kanzlai, public"); err != nil {
db.Close()
return nil, fmt.Errorf("setting search_path: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
return db, nil
}

View File

@@ -0,0 +1,205 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AppointmentHandler struct {
svc *services.AppointmentService
}
func NewAppointmentHandler(svc *services.AppointmentService) *AppointmentHandler {
return &AppointmentHandler{svc: svc}
}
func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
filter := services.AppointmentFilter{}
if v := r.URL.Query().Get("case_id"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
filter.CaseID = &id
}
if v := r.URL.Query().Get("type"); v != "" {
filter.Type = &v
}
if v := r.URL.Query().Get("start_from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_from (use RFC3339)")
return
}
filter.StartFrom = &t
}
if v := r.URL.Query().Get("start_to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_to (use RFC3339)")
return
}
filter.StartTo = &t
}
appointments, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list appointments")
return
}
writeJSON(w, http.StatusOK, appointments)
}
type createAppointmentRequest struct {
CaseID *uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
Location *string `json:"location"`
AppointmentType *string `json:"appointment_type"`
}
func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
var req createAppointmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
return
}
appt := &models.Appointment{
TenantID: tenantID,
CaseID: req.CaseID,
Title: req.Title,
Description: req.Description,
StartAt: req.StartAt,
EndAt: req.EndAt,
Location: req.Location,
AppointmentType: req.AppointmentType,
}
if err := h.svc.Create(r.Context(), appt); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create appointment")
return
}
writeJSON(w, http.StatusCreated, appt)
}
type updateAppointmentRequest struct {
CaseID *uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
Location *string `json:"location"`
AppointmentType *string `json:"appointment_type"`
}
func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid appointment id")
return
}
// Fetch existing to verify ownership
existing, err := h.svc.GetByID(r.Context(), tenantID, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "appointment not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch appointment")
return
}
var req updateAppointmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
return
}
existing.CaseID = req.CaseID
existing.Title = req.Title
existing.Description = req.Description
existing.StartAt = req.StartAt
existing.EndAt = req.EndAt
existing.Location = req.Location
existing.AppointmentType = req.AppointmentType
if err := h.svc.Update(r.Context(), existing); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update appointment")
return
}
writeJSON(w, http.StatusOK, existing)
}
func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid appointment id")
return
}
if err := h.svc.Delete(r.Context(), tenantID, id); err != nil {
writeError(w, http.StatusNotFound, "appointment not found")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,89 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// CalculateHandlers holds handlers for deadline calculation endpoints
type CalculateHandlers struct {
calculator *services.DeadlineCalculator
rules *services.DeadlineRuleService
}
// NewCalculateHandlers creates calculate handlers
func NewCalculateHandlers(calc *services.DeadlineCalculator, rules *services.DeadlineRuleService) *CalculateHandlers {
return &CalculateHandlers{calculator: calc, rules: rules}
}
// CalculateRequest is the input for POST /api/deadlines/calculate
type CalculateRequest struct {
ProceedingType string `json:"proceeding_type"`
TriggerEventDate string `json:"trigger_event_date"`
SelectedRuleIDs []string `json:"selected_rule_ids,omitempty"`
}
// Calculate handles POST /api/deadlines/calculate
func (h *CalculateHandlers) Calculate(w http.ResponseWriter, r *http.Request) {
var req CalculateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ProceedingType == "" || req.TriggerEventDate == "" {
writeError(w, http.StatusBadRequest, "proceeding_type and trigger_event_date are required")
return
}
eventDate, err := time.Parse("2006-01-02", req.TriggerEventDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid trigger_event_date format, expected YYYY-MM-DD")
return
}
var results []services.CalculatedDeadline
if len(req.SelectedRuleIDs) > 0 {
ruleModels, err := h.rules.GetByIDs(req.SelectedRuleIDs)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch selected rules")
return
}
results = h.calculator.CalculateFromRules(eventDate, ruleModels)
} else {
tree, err := h.rules.GetRuleTree(req.ProceedingType)
if err != nil {
writeError(w, http.StatusBadRequest, "unknown proceeding type")
return
}
// Flatten tree to get all rule models
var flatNodes []services.RuleTreeNode
flattenTree(tree, &flatNodes)
ruleModels := make([]models.DeadlineRule, 0, len(flatNodes))
for _, node := range flatNodes {
ruleModels = append(ruleModels, node.DeadlineRule)
}
results = h.calculator.CalculateFromRules(eventDate, ruleModels)
}
writeJSON(w, http.StatusOK, map[string]any{
"proceeding_type": req.ProceedingType,
"trigger_event_date": req.TriggerEventDate,
"deadlines": results,
})
}
func flattenTree(nodes []services.RuleTreeNode, result *[]services.RuleTreeNode) {
for _, n := range nodes {
*result = append(*result, n)
if len(n.Children) > 0 {
flattenTree(n.Children, result)
}
}
}

View File

@@ -0,0 +1,158 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
type CaseHandler struct {
svc *services.CaseService
}
func NewCaseHandler(svc *services.CaseService) *CaseHandler {
return &CaseHandler{svc: svc}
}
func (h *CaseHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
filter := services.CaseFilter{
Status: r.URL.Query().Get("status"),
Type: r.URL.Query().Get("type"),
Search: r.URL.Query().Get("search"),
Limit: limit,
Offset: offset,
}
cases, total, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"cases": cases,
"total": total,
})
}
func (h *CaseHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
var input services.CreateCaseInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.CaseNumber == "" || input.Title == "" {
writeError(w, http.StatusBadRequest, "case_number and title are required")
return
}
c, err := h.svc.Create(r.Context(), tenantID, userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, c)
}
func (h *CaseHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
detail, err := h.svc.GetByID(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if detail == nil {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeJSON(w, http.StatusOK, detail)
}
func (h *CaseHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.UpdateCaseInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
updated, err := h.svc.Update(r.Context(), tenantID, caseID, userID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *CaseHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, caseID, userID); err != nil {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "archived"})
}

View 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)
}

View File

@@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DeadlineRuleHandlers holds handlers for deadline rule endpoints
type DeadlineRuleHandlers struct {
rules *services.DeadlineRuleService
}
// NewDeadlineRuleHandlers creates deadline rule handlers
func NewDeadlineRuleHandlers(rs *services.DeadlineRuleService) *DeadlineRuleHandlers {
return &DeadlineRuleHandlers{rules: rs}
}
// List handles GET /api/deadline-rules
// Query params: proceeding_type_id (optional int filter)
func (h *DeadlineRuleHandlers) List(w http.ResponseWriter, r *http.Request) {
var proceedingTypeID *int
if v := r.URL.Query().Get("proceeding_type_id"); v != "" {
id, err := strconv.Atoi(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid proceeding_type_id")
return
}
proceedingTypeID = &id
}
rules, err := h.rules.List(proceedingTypeID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadline rules")
return
}
writeJSON(w, http.StatusOK, rules)
}
// GetRuleTree handles GET /api/deadline-rules/{type}
// {type} is the proceeding type code (e.g., "INF", "REV")
func (h *DeadlineRuleHandlers) GetRuleTree(w http.ResponseWriter, r *http.Request) {
typeCode := r.PathValue("type")
if typeCode == "" {
writeError(w, http.StatusBadRequest, "proceeding type code required")
return
}
tree, err := h.rules.GetRuleTree(typeCode)
if err != nil {
writeError(w, http.StatusNotFound, "proceeding type not found")
return
}
writeJSON(w, http.StatusOK, tree)
}

View File

@@ -0,0 +1,162 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DeadlineHandlers holds handlers for deadline CRUD endpoints
type DeadlineHandlers struct {
deadlines *services.DeadlineService
db *sqlx.DB
}
// NewDeadlineHandlers creates deadline handlers
func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHandlers {
return &DeadlineHandlers{deadlines: ds, db: db}
}
// ListForCase handles GET /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadlines")
return
}
writeJSON(w, http.StatusOK, deadlines)
}
// Create handles POST /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.CreateDeadlineInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
input.CaseID = caseID
if input.Title == "" || input.DueDate == "" {
writeError(w, http.StatusBadRequest, "title and due_date are required")
return
}
deadline, err := h.deadlines.Create(tenantID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create deadline")
return
}
writeJSON(w, http.StatusCreated, deadline)
}
// Update handles PUT /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
var input services.UpdateDeadlineInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update deadline")
return
}
if deadline == nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, deadline)
}
// Complete handles PATCH /api/deadlines/{deadlineID}/complete
func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete deadline")
return
}
if deadline == nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, deadline)
}
// Delete handles DELETE /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
err = h.deadlines.Delete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,85 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
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) {
writeJSON(w, status, map[string]string{"error": msg})
}
// resolveTenant gets the tenant ID for the authenticated user.
// Checks X-Tenant-ID header first, then falls back to user's first tenant.
func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
return uuid.Nil, errUnauthorized
}
// Check header first
if headerVal := r.Header.Get("X-Tenant-ID"); headerVal != "" {
tenantID, err := uuid.Parse(headerVal)
if err != nil {
return uuid.Nil, errInvalidTenant
}
// Verify user has access to this tenant
var count int
err = db.Get(&count,
`SELECT COUNT(*) FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil || count == 0 {
return uuid.Nil, errTenantAccess
}
return tenantID, nil
}
// Fall back to user's first tenant
var tenantID uuid.UUID
err := db.Get(&tenantID,
`SELECT tenant_id FROM user_tenants WHERE user_id = $1 ORDER BY created_at LIMIT 1`,
userID)
if err != nil {
return uuid.Nil, errNoTenant
}
return tenantID, nil
}
type apiError struct {
msg string
status int
}
func (e *apiError) Error() string { return e.msg }
var (
errUnauthorized = &apiError{msg: "unauthorized", status: http.StatusUnauthorized}
errInvalidTenant = &apiError{msg: "invalid tenant ID", status: http.StatusBadRequest}
errTenantAccess = &apiError{msg: "no access to tenant", status: http.StatusForbidden}
errNoTenant = &apiError{msg: "no tenant found for user", status: http.StatusBadRequest}
)
// handleTenantError writes the appropriate error response for tenant resolution errors
func handleTenantError(w http.ResponseWriter, err error) {
if ae, ok := err.(*apiError); ok {
writeError(w, ae.status, ae.msg)
return
}
writeError(w, http.StatusInternalServerError, "internal error")
}
// parsePathUUID extracts a UUID from the URL path using PathValue
func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
return uuid.Parse(r.PathValue(key))
}

View File

@@ -0,0 +1,134 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
type PartyHandler struct {
svc *services.PartyService
}
func NewPartyHandler(svc *services.PartyService) *PartyHandler {
return &PartyHandler{svc: svc}
}
func (h *PartyHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
parties, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"parties": parties,
})
}
func (h *PartyHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.CreatePartyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
party, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
if err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, party)
}
func (h *PartyHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
partyID, err := uuid.Parse(r.PathValue("partyId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid party ID")
return
}
var input services.UpdatePartyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
updated, err := h.svc.Update(r.Context(), tenantID, partyID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "party not found")
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *PartyHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
partyID, err := uuid.Parse(r.PathValue("partyId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid party ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, partyID); err != nil {
writeError(w, http.StatusNotFound, "party not found")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,243 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type TenantHandler struct {
svc *services.TenantService
}
func NewTenantHandler(svc *services.TenantService) *TenantHandler {
return &TenantHandler{svc: svc}
}
// CreateTenant handles POST /api/tenants
func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Name == "" || req.Slug == "" {
jsonError(w, "name and slug are required", http.StatusBadRequest)
return
}
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, tenant, http.StatusCreated)
}
// ListTenants handles GET /api/tenants
func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenants, err := h.svc.ListForUser(r.Context(), userID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, tenants, http.StatusOK)
}
// GetTenant handles GET /api/tenants/{id}
func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Verify user has access to this tenant
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role == "" {
jsonError(w, "not found", http.StatusNotFound)
return
}
tenant, err := h.svc.GetByID(r.Context(), tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if tenant == nil {
jsonError(w, "not found", http.StatusNotFound)
return
}
jsonResponse(w, tenant, http.StatusOK)
}
// InviteUser handles POST /api/tenants/{id}/invite
func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Only owners and admins can invite
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" {
jsonError(w, "only owners and admins can invite users", http.StatusForbidden)
return
}
var req struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
jsonError(w, "email is required", http.StatusBadRequest)
return
}
if req.Role == "" {
req.Role = "member"
}
if req.Role != "member" && req.Role != "admin" {
jsonError(w, "role must be member or admin", http.StatusBadRequest)
return
}
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, ut, http.StatusCreated)
}
// RemoveMember handles DELETE /api/tenants/{id}/members/{uid}
func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
memberID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
jsonError(w, "invalid member ID", http.StatusBadRequest)
return
}
// Only owners and admins can remove members (or user removing themselves)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" && userID != memberID {
jsonError(w, "insufficient permissions", http.StatusForbidden)
return
}
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, map[string]string{"status": "removed"}, http.StatusOK)
}
// ListMembers handles GET /api/tenants/{id}/members
func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Verify user has access
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role == "" {
jsonError(w, "not found", http.StatusNotFound)
return
}
members, err := h.svc.ListMembers(r.Context(), tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, members, http.StatusOK)
}
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,132 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
func TestCreateTenant_MissingFields(t *testing.T) {
h := &TenantHandler{} // no service needed for validation
// Build request with auth context
body := `{"name":"","slug":""}`
r := httptest.NewRequest("POST", "/api/tenants", bytes.NewBufferString(body))
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.CreateTenant(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "name and slug are required" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestCreateTenant_NoAuth(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("POST", "/api/tenants", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
h.CreateTenant(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestGetTenant_InvalidID(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("GET", "/api/tenants/not-a-uuid", nil)
r.SetPathValue("id", "not-a-uuid")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.GetTenant(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestInviteUser_InvalidTenantID(t *testing.T) {
h := &TenantHandler{}
body := `{"email":"test@example.com","role":"member"}`
r := httptest.NewRequest("POST", "/api/tenants/bad/invite", bytes.NewBufferString(body))
r.SetPathValue("id", "bad")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.InviteUser(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestInviteUser_NoAuth(t *testing.T) {
h := &TenantHandler{}
body := `{"email":"test@example.com"}`
r := httptest.NewRequest("POST", "/api/tenants/"+uuid.New().String()+"/invite", bytes.NewBufferString(body))
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.InviteUser(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestRemoveMember_InvalidIDs(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("DELETE", "/api/tenants/bad/members/bad", nil)
r.SetPathValue("id", "bad")
r.SetPathValue("uid", "bad")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.RemoveMember(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestJsonResponse(t *testing.T) {
w := httptest.NewRecorder()
jsonResponse(w, map[string]string{"key": "value"}, http.StatusOK)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("expected application/json, got %s", ct)
}
}
func TestJsonError(t *testing.T) {
w := httptest.NewRecorder()
jsonError(w, "something went wrong", http.StatusBadRequest)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "something went wrong" {
t.Errorf("unexpected error: %s", resp["error"])
}
}

View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Appointment struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
StartAt time.Time `db:"start_at" json:"start_at"`
EndAt *time.Time `db:"end_at" json:"end_at,omitempty"`
Location *string `db:"location" json:"location,omitempty"`
AppointmentType *string `db:"appointment_type" json:"appointment_type,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,23 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Case struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseNumber string `db:"case_number" json:"case_number"`
Title string `db:"title" json:"title"`
CaseType *string `db:"case_type" json:"case_type,omitempty"`
Court *string `db:"court" json:"court,omitempty"`
CourtRef *string `db:"court_ref" json:"court_ref,omitempty"`
Status string `db:"status" json:"status"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,22 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type CaseEvent struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
EventDate *time.Time `db:"event_date" json:"event_date,omitempty"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,27 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Deadline struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
Title string `db:"title" json:"title"`
Description *string `db:"description" json:"description,omitempty"`
DueDate string `db:"due_date" json:"due_date"`
OriginalDueDate *string `db:"original_due_date" json:"original_due_date,omitempty"`
WarningDate *string `db:"warning_date" json:"warning_date,omitempty"`
Source string `db:"source" json:"source"`
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,43 @@
package models
import (
"time"
"github.com/google/uuid"
)
type DeadlineRule struct {
ID uuid.UUID `db:"id" json:"id"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"`
Code *string `db:"code" json:"code,omitempty"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
EventType *string `db:"event_type" json:"event_type,omitempty"`
IsMandatory bool `db:"is_mandatory" json:"is_mandatory"`
DurationValue int `db:"duration_value" json:"duration_value"`
DurationUnit string `db:"duration_unit" json:"duration_unit"`
Timing *string `db:"timing" json:"timing,omitempty"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type ProceedingType struct {
ID int `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"`
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
}

View File

@@ -0,0 +1,23 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Document struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
Title string `db:"title" json:"title"`
DocType *string `db:"doc_type" json:"doc_type,omitempty"`
FilePath *string `db:"file_path" json:"file_path,omitempty"`
FileSize *int `db:"file_size" json:"file_size,omitempty"`
MimeType *string `db:"mime_type" json:"mime_type,omitempty"`
AIExtracted *json.RawMessage `db:"ai_extracted" json:"ai_extracted,omitempty"`
UploadedBy *uuid.UUID `db:"uploaded_by" json:"uploaded_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -0,0 +1,17 @@
package models
import (
"encoding/json"
"github.com/google/uuid"
)
type Party struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID uuid.UUID `db:"case_id" json:"case_id"`
Name string `db:"name" json:"name"`
Role *string `db:"role" json:"role,omitempty"`
Representative *string `db:"representative" json:"representative,omitempty"`
ContactInfo json.RawMessage `db:"contact_info" json:"contact_info"`
}

View File

@@ -0,0 +1,30 @@
package models
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Tenant struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Slug string `db:"slug" json:"slug"`
Settings json.RawMessage `db:"settings" json:"settings"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type UserTenant struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
Role string `db:"role" json:"role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// TenantWithRole is a Tenant joined with the user's role in that tenant.
type TenantWithRole struct {
Tenant
Role string `db:"role" json:"role"`
}

View File

@@ -0,0 +1,126 @@
package router
import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
mux := http.NewServeMux()
// Services
tenantSvc := services.NewTenantService(db)
caseSvc := services.NewCaseService(db)
partySvc := services.NewPartyService(db)
appointmentSvc := services.NewAppointmentService(db)
holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
dashboardSvc := services.NewDashboardService(db)
// Handlers
tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc)
apptH := handlers.NewAppointmentHandler(appointmentSvc)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
// Authenticated API routes
api := http.NewServeMux()
// Tenant management (no tenant resolver — these operate across tenants)
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
// Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux()
// Cases
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", caseH.Create)
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
// Parties
scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Deadlines
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
// Deadline rules (reference data)
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
// Deadline calculator
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", apptH.Create)
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
// Dashboard
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Placeholder routes for future phases
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
api.Handle("/api/", tenantResolver.Resolve(scoped))
mux.Handle("/api/", authMW.RequireAuth(api))
return mux
}
func handleHealth(db *sqlx.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "error", "error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
}
func placeholder(resource string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "not_implemented",
"resource": resource,
})
}
}

View File

@@ -0,0 +1,135 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type AppointmentService struct {
db *sqlx.DB
}
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
return &AppointmentService{db: db}
}
type AppointmentFilter struct {
CaseID *uuid.UUID
Type *string
StartFrom *time.Time
StartTo *time.Time
}
func (s *AppointmentService) List(ctx context.Context, tenantID uuid.UUID, filter AppointmentFilter) ([]models.Appointment, error) {
query := "SELECT * FROM appointments WHERE tenant_id = $1"
args := []any{tenantID}
argN := 2
if filter.CaseID != nil {
query += fmt.Sprintf(" AND case_id = $%d", argN)
args = append(args, *filter.CaseID)
argN++
}
if filter.Type != nil {
query += fmt.Sprintf(" AND appointment_type = $%d", argN)
args = append(args, *filter.Type)
argN++
}
if filter.StartFrom != nil {
query += fmt.Sprintf(" AND start_at >= $%d", argN)
args = append(args, *filter.StartFrom)
argN++
}
if filter.StartTo != nil {
query += fmt.Sprintf(" AND start_at <= $%d", argN)
args = append(args, *filter.StartTo)
argN++
}
query += " ORDER BY start_at ASC"
var appointments []models.Appointment
if err := s.db.SelectContext(ctx, &appointments, query, args...); err != nil {
return nil, fmt.Errorf("listing appointments: %w", err)
}
if appointments == nil {
appointments = []models.Appointment{}
}
return appointments, nil
}
func (s *AppointmentService) GetByID(ctx context.Context, tenantID, id uuid.UUID) (*models.Appointment, error) {
var a models.Appointment
err := s.db.GetContext(ctx, &a, "SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return nil, fmt.Errorf("getting appointment: %w", err)
}
return &a, nil
}
func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment) error {
a.ID = uuid.New()
now := time.Now().UTC()
a.CreatedAt = now
a.UpdatedAt = now
_, err := s.db.NamedExecContext(ctx, `
INSERT INTO appointments (id, tenant_id, case_id, title, description, start_at, end_at, location, appointment_type, caldav_uid, caldav_etag, created_at, updated_at)
VALUES (:id, :tenant_id, :case_id, :title, :description, :start_at, :end_at, :location, :appointment_type, :caldav_uid, :caldav_etag, :created_at, :updated_at)
`, a)
if err != nil {
return fmt.Errorf("creating appointment: %w", err)
}
return nil
}
func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment) error {
a.UpdatedAt = time.Now().UTC()
result, err := s.db.NamedExecContext(ctx, `
UPDATE appointments SET
case_id = :case_id,
title = :title,
description = :description,
start_at = :start_at,
end_at = :end_at,
location = :location,
appointment_type = :appointment_type,
caldav_uid = :caldav_uid,
caldav_etag = :caldav_etag,
updated_at = :updated_at
WHERE id = :id AND tenant_id = :tenant_id
`, a)
if err != nil {
return fmt.Errorf("updating appointment: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("appointment not found")
}
return nil
}
func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID) error {
result, err := s.db.ExecContext(ctx, "DELETE FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return fmt.Errorf("deleting appointment: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("appointment not found")
}
return nil
}

View File

@@ -0,0 +1,277 @@
package services
import (
"context"
"database/sql"
"fmt"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type CaseService struct {
db *sqlx.DB
}
func NewCaseService(db *sqlx.DB) *CaseService {
return &CaseService{db: db}
}
type CaseFilter struct {
Status string
Type string
Search string
Limit int
Offset int
}
type CaseDetail struct {
models.Case
Parties []models.Party `json:"parties"`
RecentEvents []models.CaseEvent `json:"recent_events"`
DeadlinesCount int `json:"deadlines_count"`
}
type CreateCaseInput struct {
CaseNumber string `json:"case_number"`
Title string `json:"title"`
CaseType *string `json:"case_type,omitempty"`
Court *string `json:"court,omitempty"`
CourtRef *string `json:"court_ref,omitempty"`
Status string `json:"status"`
}
type UpdateCaseInput struct {
CaseNumber *string `json:"case_number,omitempty"`
Title *string `json:"title,omitempty"`
CaseType *string `json:"case_type,omitempty"`
Court *string `json:"court,omitempty"`
CourtRef *string `json:"court_ref,omitempty"`
Status *string `json:"status,omitempty"`
}
func (s *CaseService) List(ctx context.Context, tenantID uuid.UUID, filter CaseFilter) ([]models.Case, int, error) {
if filter.Limit <= 0 {
filter.Limit = 20
}
if filter.Limit > 100 {
filter.Limit = 100
}
// Build WHERE clause
where := "WHERE tenant_id = $1"
args := []interface{}{tenantID}
argIdx := 2
if filter.Status != "" {
where += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, filter.Status)
argIdx++
}
if filter.Type != "" {
where += fmt.Sprintf(" AND case_type = $%d", argIdx)
args = append(args, filter.Type)
argIdx++
}
if filter.Search != "" {
where += fmt.Sprintf(" AND (title ILIKE $%d OR case_number ILIKE $%d)", argIdx, argIdx)
args = append(args, "%"+filter.Search+"%")
argIdx++
}
// Count total
var total int
countQuery := "SELECT COUNT(*) FROM cases " + where
if err := s.db.GetContext(ctx, &total, countQuery, args...); err != nil {
return nil, 0, fmt.Errorf("counting cases: %w", err)
}
// Fetch page
query := fmt.Sprintf("SELECT * FROM cases %s ORDER BY updated_at DESC LIMIT $%d OFFSET $%d",
where, argIdx, argIdx+1)
args = append(args, filter.Limit, filter.Offset)
var cases []models.Case
if err := s.db.SelectContext(ctx, &cases, query, args...); err != nil {
return nil, 0, fmt.Errorf("listing cases: %w", err)
}
return cases, total, nil
}
func (s *CaseService) GetByID(ctx context.Context, tenantID, caseID uuid.UUID) (*CaseDetail, error) {
var c models.Case
err := s.db.GetContext(ctx, &c,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting case: %w", err)
}
detail := &CaseDetail{Case: c}
// Parties
if err := s.db.SelectContext(ctx, &detail.Parties,
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("getting parties: %w", err)
}
// Recent events (last 20)
if err := s.db.SelectContext(ctx, &detail.RecentEvents,
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 20",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("getting events: %w", err)
}
// Deadlines count
if err := s.db.GetContext(ctx, &detail.DeadlinesCount,
"SELECT COUNT(*) FROM deadlines WHERE case_id = $1 AND tenant_id = $2",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("counting deadlines: %w", err)
}
return detail, nil
}
func (s *CaseService) Create(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, input CreateCaseInput) (*models.Case, error) {
if input.Status == "" {
input.Status = "active"
}
id := uuid.New()
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $9, $9)`,
id, tenantID, input.CaseNumber, input.Title, input.CaseType, input.Court, input.CourtRef, input.Status, now)
if err != nil {
return nil, fmt.Errorf("creating case: %w", err)
}
// Create case_created event
createEvent(ctx, s.db, tenantID, id, userID, "case_created", "Case created", nil)
var c models.Case
if err := s.db.GetContext(ctx, &c, "SELECT * FROM cases WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created case: %w", err)
}
return &c, nil
}
func (s *CaseService) Update(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input UpdateCaseInput) (*models.Case, error) {
// Fetch current to detect status change
var current models.Case
err := s.db.GetContext(ctx, &current,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("fetching case for update: %w", err)
}
// Build SET clause dynamically
sets := []string{}
args := []interface{}{}
argIdx := 1
if input.CaseNumber != nil {
sets = append(sets, fmt.Sprintf("case_number = $%d", argIdx))
args = append(args, *input.CaseNumber)
argIdx++
}
if input.Title != nil {
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
args = append(args, *input.Title)
argIdx++
}
if input.CaseType != nil {
sets = append(sets, fmt.Sprintf("case_type = $%d", argIdx))
args = append(args, *input.CaseType)
argIdx++
}
if input.Court != nil {
sets = append(sets, fmt.Sprintf("court = $%d", argIdx))
args = append(args, *input.Court)
argIdx++
}
if input.CourtRef != nil {
sets = append(sets, fmt.Sprintf("court_ref = $%d", argIdx))
args = append(args, *input.CourtRef)
argIdx++
}
if input.Status != nil {
sets = append(sets, fmt.Sprintf("status = $%d", argIdx))
args = append(args, *input.Status)
argIdx++
}
if len(sets) == 0 {
return &current, nil
}
sets = append(sets, fmt.Sprintf("updated_at = $%d", argIdx))
args = append(args, time.Now())
argIdx++
query := fmt.Sprintf("UPDATE cases SET %s WHERE id = $%d AND tenant_id = $%d",
joinStrings(sets, ", "), argIdx, argIdx+1)
args = append(args, caseID, tenantID)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("updating case: %w", err)
}
// Log status change event
if input.Status != nil && *input.Status != current.Status {
desc := fmt.Sprintf("Status changed from %s to %s", current.Status, *input.Status)
createEvent(ctx, s.db, tenantID, caseID, userID, "status_changed", desc, nil)
}
var updated models.Case
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM cases WHERE id = $1", caseID); err != nil {
return nil, fmt.Errorf("fetching updated case: %w", err)
}
return &updated, nil
}
func (s *CaseService) Delete(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
"UPDATE cases SET status = 'archived', updated_at = $1 WHERE id = $2 AND tenant_id = $3 AND status != 'archived'",
time.Now(), caseID, tenantID)
if err != nil {
return fmt.Errorf("archiving case: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
createEvent(ctx, s.db, tenantID, caseID, userID, "case_archived", "Case archived", nil)
return nil
}
func createEvent(ctx context.Context, db *sqlx.DB, tenantID, caseID uuid.UUID, userID uuid.UUID, eventType, title string, description *string) {
now := time.Now()
db.ExecContext(ctx,
`INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '{}', $7, $7)`,
uuid.New(), tenantID, caseID, eventType, title, description, now, userID)
}
func joinStrings(strs []string, sep string) string {
result := ""
for i, s := range strs {
if i > 0 {
result += sep
}
result += s
}
return result
}

View 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
}

View 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)
}
}

View File

@@ -0,0 +1,99 @@
package services
import (
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// CalculatedDeadline holds a calculated deadline with adjustment info
type CalculatedDeadline struct {
RuleCode string `json:"rule_code"`
RuleID string `json:"rule_id"`
Title string `json:"title"`
DueDate string `json:"due_date"`
OriginalDueDate string `json:"original_due_date"`
WasAdjusted bool `json:"was_adjusted"`
}
// DeadlineCalculator calculates deadlines from rules and event dates
type DeadlineCalculator struct {
holidays *HolidayService
}
// NewDeadlineCalculator creates a new calculator
func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
return &DeadlineCalculator{holidays: holidays}
}
// CalculateEndDate calculates the end date for a single deadline rule based on an event date.
// Adapted from youpc.org CalculateDeadlineEndDate.
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule) (adjusted time.Time, original time.Time, wasAdjusted bool) {
endDate := eventDate
timing := "after"
if rule.Timing != nil {
timing = *rule.Timing
}
durationValue := rule.DurationValue
durationUnit := rule.DurationUnit
if timing == "before" {
switch durationUnit {
case "days":
endDate = endDate.AddDate(0, 0, -durationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, -durationValue*7)
case "months":
endDate = endDate.AddDate(0, -durationValue, 0)
}
} else {
switch durationUnit {
case "days":
endDate = endDate.AddDate(0, 0, durationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, durationValue*7)
case "months":
endDate = endDate.AddDate(0, durationValue, 0)
}
}
original = endDate
adjusted, _, wasAdjusted = c.holidays.AdjustForNonWorkingDays(endDate)
return adjusted, original, wasAdjusted
}
// CalculateFromRules calculates deadlines for a set of rules given an event date.
// Returns a list of calculated deadlines with due dates.
func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []models.DeadlineRule) []CalculatedDeadline {
results := make([]CalculatedDeadline, 0, len(rules))
for _, rule := range rules {
var adjusted, original time.Time
var wasAdjusted bool
if rule.DurationValue > 0 {
adjusted, original, wasAdjusted = c.CalculateEndDate(eventDate, rule)
} else {
adjusted = eventDate
original = eventDate
}
code := ""
if rule.Code != nil {
code = *rule.Code
}
results = append(results, CalculatedDeadline{
RuleCode: code,
RuleID: rule.ID.String(),
Title: rule.Name,
DueDate: adjusted.Format("2006-01-02"),
OriginalDueDate: original.Format("2006-01-02"),
WasAdjusted: wasAdjusted,
})
}
return results
}

View File

@@ -0,0 +1,141 @@
package services
import (
"testing"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
func TestCalculateEndDateAfterDays(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday
timing := "after"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 10 days",
DurationValue: 10,
DurationUnit: "days",
Timing: &timing,
}
adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule)
// 25 March + 10 days = 4 April 2026 (Saturday)
// Apr 5 = Easter Sunday (holiday), Apr 6 = Easter Monday (holiday) -> adjusted to 7 April (Tuesday)
expectedOriginal := time.Date(2026, 4, 4, 0, 0, 0, 0, time.UTC)
expectedAdjusted := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
if original != expectedOriginal {
t.Errorf("original should be %s, got %s", expectedOriginal, original)
}
if adjusted != expectedAdjusted {
t.Errorf("adjusted should be %s, got %s", expectedAdjusted, adjusted)
}
if !wasAdjusted {
t.Error("should have been adjusted (Saturday)")
}
}
func TestCalculateEndDateBeforeMonths(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) // Monday
timing := "before"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 2 months before",
DurationValue: 2,
DurationUnit: "months",
Timing: &timing,
}
adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule)
// 15 June - 2 months = 15 April 2026 (Wednesday)
expected := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
if original != expected {
t.Errorf("original should be %s, got %s", expected, original)
}
if adjusted != expected {
t.Errorf("adjusted should be %s (not a holiday/weekend), got %s", expected, adjusted)
}
if wasAdjusted {
t.Error("should not have been adjusted (Wednesday)")
}
}
func TestCalculateEndDateWeeks(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday
timing := "after"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 2 weeks",
DurationValue: 2,
DurationUnit: "weeks",
Timing: &timing,
}
adjusted, original, _ := calc.CalculateEndDate(eventDate, rule)
// 25 March + 14 days = 8 April 2026 (Wednesday)
expected := time.Date(2026, 4, 8, 0, 0, 0, 0, time.UTC)
if original != expected {
t.Errorf("original should be %s, got %s", expected, original)
}
if adjusted != expected {
t.Errorf("adjusted should be %s, got %s", expected, adjusted)
}
}
func TestCalculateFromRules(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
timing := "after"
code := "TEST-1"
rules := []models.DeadlineRule{
{
ID: uuid.New(),
Code: &code,
Name: "Rule A",
DurationValue: 7,
DurationUnit: "days",
Timing: &timing,
},
{
ID: uuid.New(),
Name: "Rule B (zero duration)",
DurationValue: 0,
DurationUnit: "days",
},
}
results := calc.CalculateFromRules(eventDate, rules)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
// Rule A: 25 March + 7 = 1 April (Wednesday)
if results[0].DueDate != "2026-04-01" {
t.Errorf("Rule A due date should be 2026-04-01, got %s", results[0].DueDate)
}
if results[0].RuleCode != "TEST-1" {
t.Errorf("Rule A code should be TEST-1, got %s", results[0].RuleCode)
}
// Rule B: zero duration -> event date
if results[1].DueDate != "2026-03-25" {
t.Errorf("Rule B due date should be 2026-03-25, got %s", results[1].DueDate)
}
}

View File

@@ -0,0 +1,175 @@
package services
import (
"fmt"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DeadlineRuleService handles deadline rule queries
type DeadlineRuleService struct {
db *sqlx.DB
}
// NewDeadlineRuleService creates a new deadline rule service
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
return &DeadlineRuleService{db: db}
}
// List returns deadline rules, optionally filtered by proceeding type
func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
var err error
if proceedingTypeID != nil {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
}
if err != nil {
return nil, fmt.Errorf("listing deadline rules: %w", err)
}
return rules, nil
}
// RuleTreeNode represents a deadline rule with its children
type RuleTreeNode struct {
models.DeadlineRule
Children []RuleTreeNode `json:"children,omitempty"`
}
// GetRuleTree returns a hierarchical tree of rules for a proceeding type
func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTreeNode, error) {
// First resolve proceeding type code to ID
var pt models.ProceedingType
err := s.db.Get(&pt,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE code = $1 AND is_active = true`, proceedingTypeCode)
if err != nil {
return nil, fmt.Errorf("resolving proceeding type %q: %w", proceedingTypeCode, err)
}
// Get all rules for this proceeding type
var rules []models.DeadlineRule
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID)
if err != nil {
return nil, fmt.Errorf("listing rules for type %q: %w", proceedingTypeCode, err)
}
return buildTree(rules), nil
}
// GetByIDs returns deadline rules by their IDs
func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
if err != nil {
return nil, fmt.Errorf("building IN query: %w", err)
}
query = s.db.Rebind(query)
var rules []models.DeadlineRule
err = s.db.Select(&rules, query, args...)
if err != nil {
return nil, fmt.Errorf("fetching rules by IDs: %w", err)
}
return rules, nil
}
// GetRulesForProceedingType returns all active rules for a proceeding type ID
func (s *DeadlineRuleService) GetRulesForProceedingType(proceedingTypeID int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
err := s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, proceedingTypeID)
if err != nil {
return nil, fmt.Errorf("listing rules for proceeding type %d: %w", proceedingTypeID, err)
}
return rules, nil
}
// ListProceedingTypes returns all active proceeding types
func (s *DeadlineRuleService) ListProceedingTypes() ([]models.ProceedingType, error) {
var types []models.ProceedingType
err := s.db.Select(&types,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE is_active = true
ORDER BY sort_order`)
if err != nil {
return nil, fmt.Errorf("listing proceeding types: %w", err)
}
return types, nil
}
// buildTree converts a flat list of rules into a hierarchical tree
func buildTree(rules []models.DeadlineRule) []RuleTreeNode {
nodeMap := make(map[string]*RuleTreeNode, len(rules))
var roots []RuleTreeNode
// Create nodes
for _, r := range rules {
node := RuleTreeNode{DeadlineRule: r}
nodeMap[r.ID.String()] = &node
}
// Build tree
for _, r := range rules {
node := nodeMap[r.ID.String()]
if r.ParentID != nil {
parentKey := r.ParentID.String()
if parent, ok := nodeMap[parentKey]; ok {
parent.Children = append(parent.Children, *node)
continue
}
}
roots = append(roots, *node)
}
return roots
}

View File

@@ -0,0 +1,180 @@
package services
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DeadlineService handles CRUD operations for case deadlines
type DeadlineService struct {
db *sqlx.DB
}
// NewDeadlineService creates a new deadline service
func NewDeadlineService(db *sqlx.DB) *DeadlineService {
return &DeadlineService{db: db}
}
// ListForCase returns all deadlines for a case, scoped to tenant
func (s *DeadlineService) ListForCase(tenantID, caseID uuid.UUID) ([]models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1 AND case_id = $2
ORDER BY due_date ASC`
var deadlines []models.Deadline
err := s.db.Select(&deadlines, query, tenantID, caseID)
if err != nil {
return nil, fmt.Errorf("listing deadlines for case: %w", err)
}
return deadlines, nil
}
// GetByID returns a single deadline by ID, scoped to tenant
func (s *DeadlineService) GetByID(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1 AND id = $2`
var d models.Deadline
err := s.db.Get(&d, query, tenantID, deadlineID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting deadline: %w", err)
}
return &d, nil
}
// CreateDeadlineInput holds the fields for creating a deadline
type CreateDeadlineInput struct {
CaseID uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DueDate string `json:"due_date"`
WarningDate *string `json:"warning_date,omitempty"`
Source string `json:"source"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// Create inserts a new deadline
func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
id := uuid.New()
source := input.Source
if source == "" {
source = "manual"
}
query := `INSERT INTO deadlines (id, tenant_id, case_id, title, description, due_date,
warning_date, source, rule_id, status, notes,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, NOW(), NOW())
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err := s.db.Get(&d, query, id, tenantID, input.CaseID, input.Title, input.Description,
input.DueDate, input.WarningDate, source, input.RuleID, input.Notes)
if err != nil {
return nil, fmt.Errorf("creating deadline: %w", err)
}
return &d, nil
}
// UpdateDeadlineInput holds the fields for updating a deadline
type UpdateDeadlineInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
DueDate *string `json:"due_date,omitempty"`
WarningDate *string `json:"warning_date,omitempty"`
Notes *string `json:"notes,omitempty"`
Status *string `json:"status,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
}
// Update modifies an existing deadline
func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
// First check it exists and belongs to tenant
existing, err := s.GetByID(tenantID, deadlineID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, nil
}
query := `UPDATE deadlines SET
title = COALESCE($1, title),
description = COALESCE($2, description),
due_date = COALESCE($3, due_date),
warning_date = COALESCE($4, warning_date),
notes = COALESCE($5, notes),
status = COALESCE($6, status),
rule_id = COALESCE($7, rule_id),
updated_at = NOW()
WHERE id = $8 AND tenant_id = $9
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err = s.db.Get(&d, query, input.Title, input.Description, input.DueDate,
input.WarningDate, input.Notes, input.Status, input.RuleID,
deadlineID, tenantID)
if err != nil {
return nil, fmt.Errorf("updating deadline: %w", err)
}
return &d, nil
}
// Complete marks a deadline as completed
func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `UPDATE deadlines SET
status = 'completed',
completed_at = $1,
updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err := s.db.Get(&d, query, time.Now(), deadlineID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("completing deadline: %w", err)
}
return &d, nil
}
// Delete removes a deadline
func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2`
result, err := s.db.Exec(query, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("deleting deadline: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking delete result: %w", err)
}
if rows == 0 {
return fmt.Errorf("deadline not found")
}
return nil
}

View File

@@ -0,0 +1,193 @@
package services
import (
"fmt"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
// Holiday represents a non-working day
type Holiday struct {
Date time.Time
Name string
IsVacation bool // Part of court vacation period
IsClosure bool // Single-day closure (public holiday)
}
// HolidayService manages holiday data and non-working day checks
type HolidayService struct {
db *sqlx.DB
// Cached holidays by year
cache map[int][]Holiday
}
// NewHolidayService creates a holiday service
func NewHolidayService(db *sqlx.DB) *HolidayService {
return &HolidayService{
db: db,
cache: make(map[int][]Holiday),
}
}
// dbHoliday matches the holidays table schema
type dbHoliday struct {
ID int `db:"id"`
Date time.Time `db:"date"`
Name string `db:"name"`
Country string `db:"country"`
State *string `db:"state"`
HolidayType string `db:"holiday_type"`
}
// LoadHolidaysForYear loads holidays from DB for a given year, merges with
// German federal holidays, and caches the result.
func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) {
if cached, ok := s.cache[year]; ok {
return cached, nil
}
holidays := make([]Holiday, 0, 30)
// Load from DB if available
if s.db != nil {
var dbHolidays []dbHoliday
err := s.db.Select(&dbHolidays,
`SELECT id, date, name, country, state, holiday_type
FROM holidays
WHERE EXTRACT(YEAR FROM date) = $1
ORDER BY date`, year)
if err == nil {
for _, h := range dbHolidays {
holidays = append(holidays, Holiday{
Date: h.Date,
Name: h.Name,
IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure",
IsVacation: h.HolidayType == "vacation",
})
}
}
// If DB query fails, fall through to hardcoded holidays
}
// Always add German federal holidays (if not already present from DB)
federal := germanFederalHolidays(year)
existing := make(map[string]bool, len(holidays))
for _, h := range holidays {
existing[h.Date.Format("2006-01-02")] = true
}
for _, h := range federal {
key := h.Date.Format("2006-01-02")
if !existing[key] {
holidays = append(holidays, h)
}
}
s.cache[year] = holidays
return holidays, nil
}
// IsHoliday checks if a date is a holiday
func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
year := date.Year()
holidays, err := s.LoadHolidaysForYear(year)
if err != nil {
return nil
}
dateStr := date.Format("2006-01-02")
for i := range holidays {
if holidays[i].Date.Format("2006-01-02") == dateStr {
return &holidays[i]
}
}
return nil
}
// IsNonWorkingDay returns true if the date is a weekend or holiday
func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
wd := date.Weekday()
if wd == time.Saturday || wd == time.Sunday {
return true
}
return s.IsHoliday(date) != nil
}
// AdjustForNonWorkingDays moves the date to the next working day
// if it falls on a weekend or holiday.
// Returns adjusted date, original date, and whether adjustment was made.
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
// Safety limit: max 30 days forward
for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ {
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// ClearCache clears the holiday cache (useful after DB updates)
func (s *HolidayService) ClearCache() {
s.cache = make(map[int][]Holiday)
}
// germanFederalHolidays returns all German federal public holidays for a year.
// These are holidays observed in all 16 German states.
func germanFederalHolidays(year int) []Holiday {
easterMonth, easterDay := CalculateEasterSunday(year)
easter := time.Date(year, time.Month(easterMonth), easterDay, 0, 0, 0, 0, time.UTC)
holidays := []Holiday{
{Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", IsClosure: true},
{Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", IsClosure: true},
{Date: easter, Name: "Ostersonntag", IsClosure: true},
{Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", IsClosure: true},
{Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", IsClosure: true},
{Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", IsClosure: true},
{Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", IsClosure: true},
{Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", IsClosure: true},
{Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", IsClosure: true},
{Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", IsClosure: true},
{Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", IsClosure: true},
}
return holidays
}
// CalculateEasterSunday computes Easter Sunday using the Anonymous Gregorian algorithm.
// Returns month (1-12) and day.
func CalculateEasterSunday(year int) (int, int) {
a := year % 19
b := year / 100
c := year % 100
d := b / 4
e := b % 4
f := (b + 8) / 25
g := (b - f + 1) / 3
h := (19*a + b - d - g + 15) % 30
i := c / 4
k := c % 4
l := (32 + 2*e + 2*i - h - k) % 7
m := (a + 11*h + 22*l) / 451
month := (h + l - 7*m + 114) / 31
day := ((h + l - 7*m + 114) % 31) + 1
return month, day
}
// GetHolidaysForYear returns all holidays for a year (for API exposure)
func (s *HolidayService) GetHolidaysForYear(year int) ([]Holiday, error) {
return s.LoadHolidaysForYear(year)
}
// FormatHolidayList returns a simple string representation of holidays for debugging
func FormatHolidayList(holidays []Holiday) string {
var b strings.Builder
for _, h := range holidays {
fmt.Fprintf(&b, "%s: %s\n", h.Date.Format("2006-01-02"), h.Name)
}
return b.String()
}

View File

@@ -0,0 +1,121 @@
package services
import (
"testing"
"time"
)
func TestCalculateEasterSunday(t *testing.T) {
tests := []struct {
year int
wantMonth int
wantDay int
}{
{2024, 3, 31},
{2025, 4, 20},
{2026, 4, 5},
{2027, 3, 28},
}
for _, tt := range tests {
m, d := CalculateEasterSunday(tt.year)
if m != tt.wantMonth || d != tt.wantDay {
t.Errorf("CalculateEasterSunday(%d) = %d-%02d, want %d-%02d",
tt.year, m, d, tt.wantMonth, tt.wantDay)
}
}
}
func TestGermanFederalHolidays(t *testing.T) {
holidays := germanFederalHolidays(2026)
// Should have 11 federal holidays
if len(holidays) != 11 {
t.Fatalf("expected 11 federal holidays, got %d", len(holidays))
}
// Check Neujahr
if holidays[0].Name != "Neujahr" {
t.Errorf("first holiday should be Neujahr, got %s", holidays[0].Name)
}
if holidays[0].Date != time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) {
t.Errorf("Neujahr should be Jan 1, got %s", holidays[0].Date)
}
// Check Karfreitag 2026 (Easter = Apr 5, so Good Friday = Apr 3)
found := false
for _, h := range holidays {
if h.Name == "Karfreitag" {
found = true
expected := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
if h.Date != expected {
t.Errorf("Karfreitag 2026 should be %s, got %s", expected, h.Date)
}
}
}
if !found {
t.Error("Karfreitag not found in holidays")
}
}
func TestHolidayServiceIsNonWorkingDay(t *testing.T) {
svc := NewHolidayService(nil) // no DB, uses hardcoded holidays
// Saturday
sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(sat) {
t.Error("Saturday should be non-working day")
}
// Sunday
sun := time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(sun) {
t.Error("Sunday should be non-working day")
}
// Regular Monday
mon := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC)
if svc.IsNonWorkingDay(mon) {
t.Error("regular Monday should be a working day")
}
// Christmas (Friday Dec 25, 2026)
xmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(xmas) {
t.Error("Christmas should be non-working day")
}
// New Year
newyear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(newyear) {
t.Error("New Year should be non-working day")
}
}
func TestAdjustForNonWorkingDays(t *testing.T) {
svc := NewHolidayService(nil)
// Saturday -> Monday
sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC)
adj, orig, adjusted := svc.AdjustForNonWorkingDays(sat)
if !adjusted {
t.Error("Saturday should be adjusted")
}
if orig != sat {
t.Error("original should be unchanged")
}
expected := time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC)
if adj != expected {
t.Errorf("Saturday should adjust to Monday %s, got %s", expected, adj)
}
// Regular Wednesday -> no adjustment
wed := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
adj, _, adjusted = svc.AdjustForNonWorkingDays(wed)
if adjusted {
t.Error("Wednesday should not be adjusted")
}
if adj != wed {
t.Error("non-adjusted date should be unchanged")
}
}

View File

@@ -0,0 +1,152 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type PartyService struct {
db *sqlx.DB
}
func NewPartyService(db *sqlx.DB) *PartyService {
return &PartyService{db: db}
}
type CreatePartyInput struct {
Name string `json:"name"`
Role *string `json:"role,omitempty"`
Representative *string `json:"representative,omitempty"`
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
type UpdatePartyInput struct {
Name *string `json:"name,omitempty"`
Role *string `json:"role,omitempty"`
Representative *string `json:"representative,omitempty"`
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
func (s *PartyService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.Party, error) {
var parties []models.Party
err := s.db.SelectContext(ctx, &parties,
"SELECT * FROM parties WHERE case_id = $1 AND tenant_id = $2 ORDER BY name",
caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("listing parties: %w", err)
}
return parties, nil
}
func (s *PartyService) Create(ctx context.Context, tenantID, caseID uuid.UUID, userID uuid.UUID, input CreatePartyInput) (*models.Party, error) {
// Verify case exists and belongs to tenant
var exists bool
err := s.db.GetContext(ctx, &exists,
"SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)", caseID, tenantID)
if err != nil {
return nil, fmt.Errorf("checking case: %w", err)
}
if !exists {
return nil, sql.ErrNoRows
}
id := uuid.New()
contactInfo := input.ContactInfo
if contactInfo == nil {
contactInfo = json.RawMessage("{}")
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO parties (id, tenant_id, case_id, name, role, representative, contact_info)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, tenantID, caseID, input.Name, input.Role, input.Representative, contactInfo)
if err != nil {
return nil, fmt.Errorf("creating party: %w", err)
}
// Log event
desc := fmt.Sprintf("Party added: %s", input.Name)
createEvent(ctx, s.db, tenantID, caseID, userID, "party_added", desc, nil)
var party models.Party
if err := s.db.GetContext(ctx, &party, "SELECT * FROM parties WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created party: %w", err)
}
return &party, nil
}
func (s *PartyService) Update(ctx context.Context, tenantID, partyID uuid.UUID, input UpdatePartyInput) (*models.Party, error) {
// Verify party exists and belongs to tenant
var current models.Party
err := s.db.GetContext(ctx, &current,
"SELECT * FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("fetching party: %w", err)
}
sets := []string{}
args := []interface{}{}
argIdx := 1
if input.Name != nil {
sets = append(sets, fmt.Sprintf("name = $%d", argIdx))
args = append(args, *input.Name)
argIdx++
}
if input.Role != nil {
sets = append(sets, fmt.Sprintf("role = $%d", argIdx))
args = append(args, *input.Role)
argIdx++
}
if input.Representative != nil {
sets = append(sets, fmt.Sprintf("representative = $%d", argIdx))
args = append(args, *input.Representative)
argIdx++
}
if input.ContactInfo != nil {
sets = append(sets, fmt.Sprintf("contact_info = $%d", argIdx))
args = append(args, input.ContactInfo)
argIdx++
}
if len(sets) == 0 {
return &current, nil
}
query := fmt.Sprintf("UPDATE parties SET %s WHERE id = $%d AND tenant_id = $%d",
joinStrings(sets, ", "), argIdx, argIdx+1)
args = append(args, partyID, tenantID)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("updating party: %w", err)
}
var updated models.Party
if err := s.db.GetContext(ctx, &updated, "SELECT * FROM parties WHERE id = $1", partyID); err != nil {
return nil, fmt.Errorf("fetching updated party: %w", err)
}
return &updated, nil
}
func (s *PartyService) Delete(ctx context.Context, tenantID, partyID uuid.UUID) error {
result, err := s.db.ExecContext(ctx,
"DELETE FROM parties WHERE id = $1 AND tenant_id = $2", partyID, tenantID)
if err != nil {
return fmt.Errorf("deleting party: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -0,0 +1,211 @@
package services
import (
"context"
"database/sql"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type TenantService struct {
db *sqlx.DB
}
func NewTenantService(db *sqlx.DB) *TenantService {
return &TenantService{db: db}
}
// Create creates a new tenant and assigns the creator as owner.
func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug string) (*models.Tenant, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
var tenant models.Tenant
err = tx.QueryRowxContext(ctx,
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id, name, slug, settings, created_at, updated_at`,
name, slug,
).StructScan(&tenant)
if err != nil {
return nil, fmt.Errorf("insert tenant: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, 'owner')`,
userID, tenant.ID,
)
if err != nil {
return nil, fmt.Errorf("assign owner: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &tenant, nil
}
// ListForUser returns all tenants the user belongs to.
func (s *TenantService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.TenantWithRole, error) {
var tenants []models.TenantWithRole
err := s.db.SelectContext(ctx, &tenants,
`SELECT t.id, t.name, t.slug, t.settings, t.created_at, t.updated_at, ut.role
FROM tenants t
JOIN user_tenants ut ON ut.tenant_id = t.id
WHERE ut.user_id = $1
ORDER BY t.name`,
userID,
)
if err != nil {
return nil, fmt.Errorf("list tenants: %w", err)
}
return tenants, nil
}
// GetByID returns a single tenant. The caller must verify the user has access.
func (s *TenantService) GetByID(ctx context.Context, tenantID uuid.UUID) (*models.Tenant, error) {
var tenant models.Tenant
err := s.db.GetContext(ctx, &tenant,
`SELECT id, name, slug, settings, created_at, updated_at FROM tenants WHERE id = $1`,
tenantID,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get tenant: %w", err)
}
return &tenant, nil
}
// GetUserRole returns the user's role in a tenant, or empty string if not a member.
func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
var role string
err := s.db.GetContext(ctx, &role,
`SELECT role FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID,
)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get user role: %w", err)
}
return role, nil
}
// FirstTenantForUser returns the user's first tenant (by name), used as default.
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
var tenantID uuid.UUID
err := s.db.GetContext(ctx, &tenantID,
`SELECT t.id FROM tenants t
JOIN user_tenants ut ON ut.tenant_id = t.id
WHERE ut.user_id = $1
ORDER BY t.name LIMIT 1`,
userID,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("first tenant: %w", err)
}
return &tenantID, nil
}
// ListMembers returns all members of a tenant.
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
var members []models.UserTenant
err := s.db.SelectContext(ctx, &members,
`SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`,
tenantID,
)
if err != nil {
return nil, fmt.Errorf("list members: %w", err)
}
return members, nil
}
// InviteByEmail looks up a user by email in auth.users and adds them to the tenant.
func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, email, role string) (*models.UserTenant, error) {
// Look up user in Supabase auth.users
var userID uuid.UUID
err := s.db.GetContext(ctx, &userID,
`SELECT id FROM auth.users WHERE email = $1`,
email,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("no user found with email %s", email)
}
if err != nil {
return nil, fmt.Errorf("lookup user: %w", err)
}
// Check if already a member
var exists bool
err = s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenantID,
)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if exists {
return nil, fmt.Errorf("user is already a member of this tenant")
}
var ut models.UserTenant
err = s.db.QueryRowxContext(ctx,
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, $3)
RETURNING user_id, tenant_id, role, created_at`,
userID, tenantID, role,
).StructScan(&ut)
if err != nil {
return nil, fmt.Errorf("invite user: %w", err)
}
return &ut, nil
}
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner
role, err := s.GetUserRole(ctx, userID, tenantID)
if err != nil {
return fmt.Errorf("check role: %w", err)
}
if role == "" {
return fmt.Errorf("user is not a member of this tenant")
}
if role == "owner" {
// Count owners — prevent removing the last one
var ownerCount int
err := s.db.GetContext(ctx, &ownerCount,
`SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`,
tenantID,
)
if err != nil {
return fmt.Errorf("count owners: %w", err)
}
if ownerCount <= 1 {
return fmt.Errorf("cannot remove the last owner of a tenant")
}
}
_, err = s.db.ExecContext(ctx,
`DELETE FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID,
)
if err != nil {
return fmt.Errorf("remove member: %w", err)
}
return nil
}

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
backend:
build:
context: ./backend
expose:
- "8080"
environment:
- PORT=8080
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
frontend:
build:
context: ./frontend
expose:
- "3000"
depends_on:
backend:
condition: service_healthy
environment:
- API_URL=http://backend:8080
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{if(!r.ok)throw r.status;process.exit(0)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

9
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
.next/
out/
build/
.git
.gitignore
Dockerfile
.dockerignore
.env*

28
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Build
FROM oven/bun:1 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV API_URL=http://backend:8080
RUN mkdir -p public
RUN bun run build
# Run
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

828
frontend/bun.lock Normal file
View File

@@ -0,0 +1,828 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "frontend",
"dependencies": {
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.14",
"tailwindcss": "^4",
"typescript": "^5",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@15.5.14", "", {}, "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.14", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-ogBjgsFrPPz19abP3VwcYSahbkUOMMvJjxCOYWYndw+PydeMuLuB4XrvNkNutFrTjC9St2KFULRdKID8Sd/CMQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
"@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/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
"@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=="],
"@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/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@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/project-service": ["@typescript-eslint/project-service@8.57.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.2", "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2" } }, "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.2", "@typescript-eslint/tsconfig-utils": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
"@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="],
"@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="],
"@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="],
"@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="],
"@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="],
"@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="],
"@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="],
"@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="],
"@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="],
"@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="],
"@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="],
"@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="],
"@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="],
"@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="],
"@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="],
"@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="],
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
"array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
"array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="],
"array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
"array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
"array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
"array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="],
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"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=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
"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=="],
"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=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
"eslint-config-next": ["eslint-config-next@15.5.14", "", { "dependencies": { "@next/eslint-plugin-next": "15.5.14", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-lmJ5F8ZgOYogq0qtH4L5SpxuASY2SPdOzqUprN2/56+P3GPsIpXaUWIJC66kYIH+yZdsM4nkHE5MIBP6s1NiBw=="],
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
"eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
"eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"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=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
"is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"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=="],
"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=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@15.5.14", "", { "dependencies": { "@next/env": "15.5.14", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.14", "@next/swc-darwin-x64": "15.5.14", "@next/swc-linux-arm64-gnu": "15.5.14", "@next/swc-linux-arm64-musl": "15.5.14", "@next/swc-linux-x64-gnu": "15.5.14", "@next/swc-linux-x64-musl": "15.5.14", "@next/swc-win32-arm64-msvc": "15.5.14", "@next/swc-win32-x64-msvc": "15.5.14", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ=="],
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
"object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="],
"object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
"object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="],
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"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=="],
"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=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
"string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
"string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
"string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
"typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
"which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"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=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}

View File

@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

13
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
rewrites: async () => [
{
source: "/api/:path*",
destination: `${process.env.API_URL || "http://localhost:8080"}/:path*`,
},
],
};
export default nextConfig;

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "kanzlai-mgmt",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.14"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.5.14",
"@eslint/eslintrc": "^3"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "KanzlAI-mGMT",
description: "Kanzleimanagement online",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</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>
);
}

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}