feat(boc): v1.0 - Complete Business Operations Center
- Go backend API with full CRUD for all modules - Rust analytics service with parallel processing - C runtime with POSIX shared memory IPC - PostgreSQL schema with 30+ tables - Redis cache, Kafka event streaming - WebSocket hub, automation engine - PDF generation, Resend email integration - JWT auth, multi-tenant - Docker Compose deployment - Nginx reverse proxy Refs: BOC-001
This commit is contained in:
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# BOC — Business Operations Center
|
||||||
|
## Installationsguide
|
||||||
|
|
||||||
|
### Systemkrav
|
||||||
|
- Linux-server (Ubuntu 22.04+ rekommenderas)
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- Go 1.21+
|
||||||
|
- Node.js 18+ (för bygg)
|
||||||
|
- AWS CLI (för S3/CloudFront deploy)
|
||||||
|
|
||||||
|
### 1. Databas
|
||||||
|
```bash
|
||||||
|
# Skapa databas
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE aamos;"
|
||||||
|
sudo -u postgres psql -c "CREATE USER amos WITH PASSWORD 'amos';"
|
||||||
|
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE aamos TO amos;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend
|
||||||
|
```bash
|
||||||
|
cd boc/backend
|
||||||
|
go build -o boc
|
||||||
|
./boc
|
||||||
|
```
|
||||||
|
Backend startar på port 9092.
|
||||||
|
|
||||||
|
### 3. Frontend
|
||||||
|
```bash
|
||||||
|
cd boc/web
|
||||||
|
# Kopiera till S3
|
||||||
|
aws s3 sync . s3://landvex-prod/boc/ --delete
|
||||||
|
# Invalidera CloudFront
|
||||||
|
aws cloudfront create-invalidation --distribution-id E2M3J95HLUR89H --paths "/boc/*"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Nginx (valfritt)
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:9092;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Systemd service
|
||||||
|
```bash
|
||||||
|
sudo tee /etc/systemd/system/boc.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=BOC Backend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=bernt
|
||||||
|
WorkingDirectory=/home/bernt/.openclaw/workspace/boc/backend
|
||||||
|
ExecStart=/home/bernt/.openclaw/workspace/boc/backend/boc
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl enable boc
|
||||||
|
sudo systemctl start boc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Miljövariabler
|
||||||
|
```bash
|
||||||
|
export DATABASE_URL="postgres://amos:amos@localhost:5432/aamos?sslmode=disable"
|
||||||
|
export JWT_SECRET="din-hemliga-nyckel-här"
|
||||||
|
export PORT=9092
|
||||||
|
```
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# BOC — Business Operations Center
|
||||||
|
# Makefile for building and deploying the entire stack
|
||||||
|
|
||||||
|
.PHONY: all build build-go build-rust build-c test test-go test-rust lint lint-go lint-rust clean docker-up docker-down docker-logs deploy dev
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# Build all components
|
||||||
|
build: build-go build-rust build-c
|
||||||
|
|
||||||
|
# Build Go backend
|
||||||
|
build-go:
|
||||||
|
@echo "🔨 Building Go backend..."
|
||||||
|
cd backend && go build -o boc .
|
||||||
|
|
||||||
|
# Build Rust service
|
||||||
|
build-rust:
|
||||||
|
@echo "🔨 Building Rust service..."
|
||||||
|
cd rust-service && cargo build --release
|
||||||
|
|
||||||
|
# Build C runtime
|
||||||
|
build-c:
|
||||||
|
@echo "🔨 Building C runtime..."
|
||||||
|
cd c-runtime && \
|
||||||
|
gcc -shared -fPIC -O3 -o libboc_ipc.so src/ipc.c -lpthread -lrt && \
|
||||||
|
gcc -c -O3 -o ipc.o src/ipc.c && \
|
||||||
|
ar rcs libboc_ipc.a ipc.o
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
test: test-go test-rust
|
||||||
|
|
||||||
|
# Run Go tests
|
||||||
|
test-go:
|
||||||
|
@echo "🧪 Running Go tests..."
|
||||||
|
cd backend && go test ./...
|
||||||
|
|
||||||
|
# Run Rust tests
|
||||||
|
test-rust:
|
||||||
|
@echo "🧪 Running Rust tests..."
|
||||||
|
cd rust-service && cargo test
|
||||||
|
|
||||||
|
# Lint all code
|
||||||
|
lint: lint-go lint-rust
|
||||||
|
|
||||||
|
# Lint Go code
|
||||||
|
lint-go:
|
||||||
|
@echo "🔍 Linting Go code..."
|
||||||
|
cd backend && go vet ./...
|
||||||
|
|
||||||
|
# Lint Rust code
|
||||||
|
lint-rust:
|
||||||
|
@echo "🔍 Linting Rust code..."
|
||||||
|
cd rust-service && cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
@echo "🧹 Cleaning build artifacts..."
|
||||||
|
cd backend && rm -f boc
|
||||||
|
cd rust-service && cargo clean
|
||||||
|
cd c-runtime && rm -f *.o *.so *.a
|
||||||
|
|
||||||
|
# Docker commands
|
||||||
|
docker-up:
|
||||||
|
@echo "🐳 Starting Docker containers..."
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
@echo "🐳 Stopping Docker containers..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
docker-logs:
|
||||||
|
@echo "📋 Showing logs..."
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Development mode - run locally with hot reload
|
||||||
|
dev:
|
||||||
|
@echo "🚀 Starting development mode..."
|
||||||
|
@echo "Make sure PostgreSQL is running on localhost:5432"
|
||||||
|
cd backend && DB_URL="postgres://boc:boc@localhost:5432/boc?sslmode=disable" go run .
|
||||||
|
|
||||||
|
# Deploy to production
|
||||||
|
deploy: build
|
||||||
|
@echo "🚀 Deploying to production..."
|
||||||
|
# Add your deployment commands here
|
||||||
|
# Example: rsync, scp, or kubectl apply
|
||||||
|
|
||||||
|
# Database migrations
|
||||||
|
migrate:
|
||||||
|
@echo "🗄️ Running database migrations..."
|
||||||
|
cd backend && go run . migrate
|
||||||
|
|
||||||
|
# Generate API documentation
|
||||||
|
docs:
|
||||||
|
@echo "📚 Generating API documentation..."
|
||||||
|
cd backend && go doc ./...
|
||||||
|
|
||||||
|
# Help
|
||||||
|
help:
|
||||||
|
@echo "BOC — Business Operations Center"
|
||||||
|
@echo ""
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " make build - Build all components"
|
||||||
|
@echo " make build-go - Build Go backend only"
|
||||||
|
@echo " make build-rust - Build Rust service only"
|
||||||
|
@echo " make build-c - Build C runtime only"
|
||||||
|
@echo " make test - Run all tests"
|
||||||
|
@echo " make test-go - Run Go tests"
|
||||||
|
@echo " make test-rust - Run Rust tests"
|
||||||
|
@echo " make lint - Lint all code"
|
||||||
|
@echo " make clean - Clean build artifacts"
|
||||||
|
@echo " make docker-up - Start Docker containers"
|
||||||
|
@echo " make docker-down - Stop Docker containers"
|
||||||
|
@echo " make docker-logs - Show Docker logs"
|
||||||
|
@echo " make dev - Run in development mode"
|
||||||
|
@echo " make deploy - Deploy to production"
|
||||||
|
@echo " make migrate - Run database migrations"
|
||||||
|
@echo " make docs - Generate API documentation"
|
||||||
|
@echo " make help - Show this help"
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# BOC — Business Operations Center
|
||||||
|
|
||||||
|
**Domän:** boc.aamos.com
|
||||||
|
|
||||||
|
Företagets kontrollrum. Inte utveckling — utan verksamheten.
|
||||||
|
|
||||||
|
## Arkitektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ BOC STACK │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Frontend (HTML/JS/CSS) │ Mobile-first, extremt enkelt │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Go Backend (API Gateway) │ Auth, CRUD, WebSocket, Cron │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Redis │ Cache, sessions, rate limit │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Kafka │ Event streaming, audit log │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Rust Service (Analytics) │ Rapporter, batch, parallel │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ C Runtime (IPC) │ Shared memory, ring buffers │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ PostgreSQL │ Fullt schema, migrations │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Moduler
|
||||||
|
|
||||||
|
| Modul | Status | Datakälla | Automationsmönster |
|
||||||
|
|-------|--------|-----------|-------------------|
|
||||||
|
| CRM | ✅ | BOC intern | Lead-scoring, påminnelser |
|
||||||
|
| Sales | ✅ | BOC + aamos-ledger | Pipeline-rapporter, forecast |
|
||||||
|
| Finance | ✅ | aamos-ledger, bank | Moms, fakturapåminnelser, avstämning |
|
||||||
|
| HR | ✅ | BOC intern | Lönekörning, ledighet, tidrapporter |
|
||||||
|
| Legal | ✅ | BOC intern | Kontraktsförnyelser, påminnelser |
|
||||||
|
| Marketing | ✅ | BOC intern | Content-publicering, kampanjer |
|
||||||
|
| Support | ✅ | BOC intern | Ärendeprioritering, CSAT |
|
||||||
|
| Analytics | ✅ | Alla moduler | Dashboard, rapporter, trender |
|
||||||
|
| Automation | ✅ | Alla moduler | Workflows, cron, event-triggers |
|
||||||
|
|
||||||
|
## Teknikstack
|
||||||
|
|
||||||
|
### Backend (Go)
|
||||||
|
- **Router:** chi/v5
|
||||||
|
- **Auth:** JWT
|
||||||
|
- **DB:** PostgreSQL med migrations
|
||||||
|
- **Cache:** Redis (go-redis)
|
||||||
|
- **Events:** Kafka (segmentio/kafka-go)
|
||||||
|
- **Log:** zerolog
|
||||||
|
- **WebSocket:** gorilla/websocket
|
||||||
|
- **Automation:** Intern motor med cron-support
|
||||||
|
|
||||||
|
### Cache (Redis)
|
||||||
|
- **Sessions:** Användarsessioner med TTL
|
||||||
|
- **Rate limiting:** Sliding window per endpoint
|
||||||
|
- **Analytics cache:** 5-minuters TTL på metrics
|
||||||
|
- **Dashboard cache:** 1-minuts TTL
|
||||||
|
- **Pub/Sub:** Realtidsnotifikationer
|
||||||
|
|
||||||
|
### Event Streaming (Kafka)
|
||||||
|
- **Topics:** boc.events, boc.audit, boc.analytics, boc.notifications
|
||||||
|
- **Producers:** Alla CRUD-operationer publicerar events
|
||||||
|
- **Consumers:** Audit log, analytics aggregation, notifications
|
||||||
|
- **Schema:** JSON events med tenant_id, entity_id, timestamp
|
||||||
|
|
||||||
|
### Analytics (Rust)
|
||||||
|
- **Framework:** axum + tokio
|
||||||
|
- **Parallel:** rayon för batch-processing
|
||||||
|
- **Cache:** dashmap i minnet
|
||||||
|
- **Rapporter:** financial_summary, sales_pipeline, customer_analytics, revenue_forecast, expense_breakdown, cashflow_projection
|
||||||
|
|
||||||
|
### IPC (C)
|
||||||
|
- **Shared memory:** POSIX shm
|
||||||
|
- **Ring buffers:** Lock-free för hög genomströmning
|
||||||
|
- **Cache:** Inline analytics-cache med TTL
|
||||||
|
|
||||||
|
## Bygga
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allt
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Separat
|
||||||
|
make build-go
|
||||||
|
make build-rust
|
||||||
|
make build-c
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kör
|
||||||
|
|
||||||
|
### Docker (rekommenderat)
|
||||||
|
```bash
|
||||||
|
make docker-up # Starta allt
|
||||||
|
make docker-down # Stoppa
|
||||||
|
make docker-logs # Loggar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utveckling
|
||||||
|
```bash
|
||||||
|
# Kräver PostgreSQL på localhost:5432
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuellt
|
||||||
|
```bash
|
||||||
|
# Go backend
|
||||||
|
cd backend
|
||||||
|
export PORT=9092
|
||||||
|
export DB_URL=postgres://boc:boc@localhost:5432/boc?sslmode=disable
|
||||||
|
export JWT_SECRET=***
|
||||||
|
./boc
|
||||||
|
|
||||||
|
# Rust service
|
||||||
|
cd rust-service
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
# C runtime
|
||||||
|
cd c-runtime
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- `POST /api/v1/auth/login` — Inloggning
|
||||||
|
- `GET /api/v1/auth/me` — Aktuell användare
|
||||||
|
|
||||||
|
### CRM
|
||||||
|
- `GET /api/v1/crm/customers` — Lista kunder
|
||||||
|
- `POST /api/v1/crm/customers` — Skapa kund
|
||||||
|
- `GET /api/v1/crm/customers/{id}` — Hämta kund
|
||||||
|
- `PUT /api/v1/crm/customers/{id}` — Uppdatera kund
|
||||||
|
- `DELETE /api/v1/crm/customers/{id}` — Ta bort kund
|
||||||
|
- `GET /api/v1/crm/leads` — Lista leads
|
||||||
|
- `GET /api/v1/crm/pipeline` — Pipeline
|
||||||
|
- `POST /api/v1/crm/interactions` — Skapa interaktion
|
||||||
|
|
||||||
|
### Sales
|
||||||
|
- `GET /api/v1/sales/deals` — Lista deals
|
||||||
|
- `POST /api/v1/sales/deals` — Skapa deal
|
||||||
|
- `GET /api/v1/sales/deals/{id}` — Hämta deal
|
||||||
|
- `PUT /api/v1/sales/deals/{id}` — Uppdatera deal
|
||||||
|
- `GET /api/v1/sales/mrr` — MRR
|
||||||
|
- `GET /api/v1/sales/arr` — ARR
|
||||||
|
- `GET /api/v1/sales/products` — Lista produkter
|
||||||
|
- `POST /api/v1/sales/products` — Skapa produkt
|
||||||
|
|
||||||
|
### Finance
|
||||||
|
- `GET /api/v1/finance/balance` — Balansräkning (från aamos-ledger)
|
||||||
|
- `GET /api/v1/finance/income` — Resultaträkning (från aamos-ledger)
|
||||||
|
- `GET /api/v1/finance/moms` — Momsrapport (från aamos-ledger)
|
||||||
|
- `GET /api/v1/finance/accounts` — Konton (från aamos-ledger)
|
||||||
|
- `GET /api/v1/finance/invoices` — Fakturor
|
||||||
|
- `GET /api/v1/finance/cashflow` — Kassaflöde
|
||||||
|
- `GET /api/v1/finance/budget` — Budget
|
||||||
|
- `POST /api/v1/finance/expenses` — Skapa utgift
|
||||||
|
- `GET /api/v1/finance/expenses` — Lista utgifter
|
||||||
|
|
||||||
|
### HR
|
||||||
|
- `GET /api/v1/hr/employees` — Lista anställda
|
||||||
|
- `POST /api/v1/hr/employees` — Skapa anställd
|
||||||
|
- `GET /api/v1/hr/employees/{id}` — Hämta anställd
|
||||||
|
- `PUT /api/v1/hr/employees/{id}` — Uppdatera anställd
|
||||||
|
- `GET /api/v1/hr/leaves` — Lista ledigheter
|
||||||
|
- `POST /api/v1/hr/leaves` — Skapa ledighet
|
||||||
|
- `GET /api/v1/hr/timesheets` — Lista tidrapporter
|
||||||
|
- `POST /api/v1/hr/timesheets` — Skapa tidrapport
|
||||||
|
|
||||||
|
### Legal
|
||||||
|
- `GET /api/v1/legal/contracts` — Lista kontrakt
|
||||||
|
- `POST /api/v1/legal/contracts` — Skapa kontrakt
|
||||||
|
- `GET /api/v1/legal/contracts/{id}` — Hämta kontrakt
|
||||||
|
- `PUT /api/v1/legal/contracts/{id}` — Uppdatera kontrakt
|
||||||
|
- `GET /api/v1/legal/reminders` — Påminnelser
|
||||||
|
|
||||||
|
### Marketing
|
||||||
|
- `GET /api/v1/marketing/campaigns` — Lista kampanjer
|
||||||
|
- `POST /api/v1/marketing/campaigns` — Skapa kampanj
|
||||||
|
- `GET /api/v1/marketing/content` — Lista content
|
||||||
|
- `POST /api/v1/marketing/content` — Skapa content
|
||||||
|
|
||||||
|
### Support
|
||||||
|
- `GET /api/v1/support/tickets` — Lista ärenden
|
||||||
|
- `POST /api/v1/support/tickets` — Skapa ärende
|
||||||
|
- `GET /api/v1/support/tickets/{id}` — Hämta ärende
|
||||||
|
- `PUT /api/v1/support/tickets/{id}` — Uppdatera ärende
|
||||||
|
- `POST /api/v1/support/tickets/{id}/comments` — Lägg till kommentar
|
||||||
|
- `GET /api/v1/support/csat` — CSAT-score
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
- `GET /api/v1/analytics/users` — Aktiva användare
|
||||||
|
- `GET /api/v1/analytics/revenue` — Intäkt
|
||||||
|
- `GET /api/v1/analytics/retention` — Retention
|
||||||
|
- `GET /api/v1/analytics/dashboard` — Dashboard-data
|
||||||
|
|
||||||
|
### Automation
|
||||||
|
- `GET /api/v1/automation/workflows` — Lista workflows
|
||||||
|
- `POST /api/v1/automation/workflows` — Skapa workflow
|
||||||
|
- `POST /api/v1/automation/workflows/{id}/trigger` — Trigga workflow
|
||||||
|
- `GET /api/v1/automation/jobs` — Lista schemalagda jobb
|
||||||
|
- `POST /api/v1/automation/jobs` — Skapa schemalagt jobb
|
||||||
|
- `GET /api/v1/automation/runs` — Lista körningar
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
- `GET /ws` — Realtidsuppdateringar
|
||||||
|
|
||||||
|
### Rust Service
|
||||||
|
- `GET /health` — Hälsokontroll
|
||||||
|
- `POST /api/v1/reports/generate` — Generera rapport
|
||||||
|
- `POST /api/v1/analytics/query` — Analytics-fråga
|
||||||
|
- `POST /api/v1/analytics/batch` — Batch-analytics
|
||||||
|
|
||||||
|
## Automation
|
||||||
|
|
||||||
|
### Schemalagda jobb (cron)
|
||||||
|
- **Rapportgenerering** — Dagligen/veckovis/månadsvis
|
||||||
|
- **Påminnelser** — Kontraktsförnyelser, fakturor
|
||||||
|
- **Synkronisering** — Externa system
|
||||||
|
- **Städning** — Gammal data
|
||||||
|
- **Backup** — Databasbackup
|
||||||
|
|
||||||
|
### Workflows
|
||||||
|
- **Event-triggers** — Vid skapande/uppdatering av entiteter
|
||||||
|
- **Schedule-triggers** — Cron-baserade
|
||||||
|
- **Webhook-triggers** — Externa händelser
|
||||||
|
- **Manuella triggers** — Via API/UI
|
||||||
|
|
||||||
|
### Åtgärdstyper
|
||||||
|
- `send_email` — Skicka e-post
|
||||||
|
- `send_notification` — Push-notis
|
||||||
|
- `create_task` — Skapa uppgift
|
||||||
|
- `update_record` — Uppdatera post
|
||||||
|
- `webhook` — Anropa extern URL
|
||||||
|
- `generate_report` — Generera och skicka rapport
|
||||||
|
|
||||||
|
## Databasschema
|
||||||
|
|
||||||
|
Fullt schema med:
|
||||||
|
- Multi-tenant (boc_tenants)
|
||||||
|
- Audit log (boc_audit_log) — immutable
|
||||||
|
- Alla moduler med proper indexes
|
||||||
|
- Triggers för updated_at
|
||||||
|
- JSONB för flexibla metadata
|
||||||
|
- Foreign keys med CASCADE
|
||||||
|
|
||||||
|
Se `backend/db/migrations/001_initial_schema.sql`
|
||||||
|
|
||||||
|
## Miljövariabler
|
||||||
|
|
||||||
|
| Variabel | Default | Beskrivning |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| PORT | 9092 | Go backend port |
|
||||||
|
| DB_URL | postgres://... | PostgreSQL connection string |
|
||||||
|
| JWT_SECRET | change-me... | JWT signing secret |
|
||||||
|
| AMOS_BASE_URL | http://localhost:9000 | aamos-ledger URL |
|
||||||
|
| MIGRATIONS_DIR | ./db/migrations | Migration files path |
|
||||||
|
| RUST_SERVICE_URL | http://localhost:9093 | Rust service URL |
|
||||||
|
| REDIS_URL | redis://localhost:6379 | Redis connection string |
|
||||||
|
| KAFKA_BROKERS | localhost:9092 | Comma-separated Kafka brokers |
|
||||||
|
|
||||||
|
## Säkerhet
|
||||||
|
|
||||||
|
- JWT-autentisering på alla endpoints utom /health och /login
|
||||||
|
- CORS-konfigurerbart
|
||||||
|
- SQL-injection skyddat via parameteriserade queries
|
||||||
|
- Audit log på alla förändringar
|
||||||
|
- HTTPS i produktion (via nginx)
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Full integration med aamos-ledger
|
||||||
|
- [ ] Bank-API koppling (Nordea, Revolut)
|
||||||
|
- [ ] E-postutskick (SMTP)
|
||||||
|
- [ ] PDF-generering för fakturor
|
||||||
|
- [ ] Advanced analytics (Rust)
|
||||||
|
- [ ] Machine learning för lead-scoring
|
||||||
|
- [ ] Mobile app (React Native/Flutter)
|
||||||
|
- [ ] SSO-integration
|
||||||
|
|
||||||
|
## Licens
|
||||||
|
|
||||||
|
Proprietär — Landvex Inc
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o boc .
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates wget
|
||||||
|
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/boc .
|
||||||
|
|
||||||
|
# Copy migrations
|
||||||
|
COPY --from=builder /app/db/migrations ./db/migrations
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 9092
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://localhost:9092/health || exit 1
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
CMD ["./boc"]
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package automation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine is the automation engine that runs workflows and scheduled jobs
|
||||||
|
type Engine struct {
|
||||||
|
db *sql.DB
|
||||||
|
logger zerolog.Logger
|
||||||
|
ticker *time.Ticker
|
||||||
|
stop chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine creates a new automation engine
|
||||||
|
func NewEngine(db *sql.DB, logger zerolog.Logger) *Engine {
|
||||||
|
return &Engine{
|
||||||
|
db: db,
|
||||||
|
logger: logger.With().Str("component", "automation").Logger(),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the automation engine
|
||||||
|
func (e *Engine) Start(ctx context.Context) {
|
||||||
|
e.ticker = time.NewTicker(30 * time.Second)
|
||||||
|
go e.run(ctx)
|
||||||
|
e.logger.Info().Msg("automation engine started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop halts the automation engine
|
||||||
|
func (e *Engine) Stop() {
|
||||||
|
if e.ticker != nil {
|
||||||
|
e.ticker.Stop()
|
||||||
|
}
|
||||||
|
close(e.stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-e.stop:
|
||||||
|
return
|
||||||
|
case <-e.ticker.C:
|
||||||
|
e.checkScheduledJobs(ctx)
|
||||||
|
e.checkWorkflowTriggers(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkScheduledJobs evaluates cron expressions and runs due jobs
|
||||||
|
func (e *Engine) checkScheduledJobs(ctx context.Context) {
|
||||||
|
rows, err := e.db.QueryContext(ctx, `
|
||||||
|
SELECT id, tenant_id, name, cron_expr, timezone, job_type, job_config
|
||||||
|
FROM boc_scheduled_jobs
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
e.logger.Error().Err(err).Msg("failed to query scheduled jobs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var job ScheduledJob
|
||||||
|
var configJSON []byte
|
||||||
|
if err := rows.Scan(&job.ID, &job.TenantID, &job.Name, &job.CronExpr, &job.Timezone, &job.JobType, &configJSON); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(configJSON, &job.JobConfig); err != nil {
|
||||||
|
job.JobConfig = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next run time
|
||||||
|
nextRun, err := e.calculateNextRun(job.CronExpr, job.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
e.logger.Error().Err(err).Str("job", job.ID.String()).Msg("failed to calculate next run")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update next_run_at
|
||||||
|
_, err = e.db.ExecContext(ctx, `
|
||||||
|
UPDATE boc_scheduled_jobs
|
||||||
|
SET last_run_at = NOW(), next_run_at = $1, run_count = run_count + 1
|
||||||
|
WHERE id = $2
|
||||||
|
`, nextRun, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
e.logger.Error().Err(err).Str("job", job.ID.String()).Msg("failed to update job schedule")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute job
|
||||||
|
go e.executeScheduledJob(ctx, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWorkflowTriggers evaluates event-based workflow triggers
|
||||||
|
func (e *Engine) checkWorkflowTriggers(ctx context.Context) {
|
||||||
|
// Event-based workflows are triggered by external events
|
||||||
|
// This checks for any pending manual triggers
|
||||||
|
rows, err := e.db.QueryContext(ctx, `
|
||||||
|
SELECT id, tenant_id, name, trigger_config, actions
|
||||||
|
FROM boc_workflows
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND trigger_type = 'schedule'
|
||||||
|
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
e.logger.Error().Err(err).Msg("failed to query scheduled workflows")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var wf Workflow
|
||||||
|
var triggerJSON, actionsJSON []byte
|
||||||
|
if err := rows.Scan(&wf.ID, &wf.TenantID, &wf.Name, &triggerJSON, &actionsJSON); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
json.Unmarshal(triggerJSON, &wf.TriggerConfig)
|
||||||
|
json.Unmarshal(actionsJSON, &wf.Actions)
|
||||||
|
|
||||||
|
nextRun, _ := e.calculateNextRun(
|
||||||
|
wf.TriggerConfig["cron"].(string),
|
||||||
|
wf.TriggerConfig["timezone"].(string),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err = e.db.ExecContext(ctx, `
|
||||||
|
UPDATE boc_workflows
|
||||||
|
SET last_run_at = NOW(), next_run_at = $1, run_count = run_count + 1
|
||||||
|
WHERE id = $2
|
||||||
|
`, nextRun, wf.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
go e.executeWorkflow(ctx, wf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) executeScheduledJob(ctx context.Context, job ScheduledJob) {
|
||||||
|
logger := e.logger.With().Str("job", job.ID.String()).Str("type", job.JobType).Logger()
|
||||||
|
logger.Info().Str("name", job.Name).Msg("executing scheduled job")
|
||||||
|
|
||||||
|
// Record run start
|
||||||
|
var runID string
|
||||||
|
err := e.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO boc_scheduled_job_runs (tenant_id, job_id, status)
|
||||||
|
VALUES ($1, $2, 'running')
|
||||||
|
RETURNING id
|
||||||
|
`, job.TenantID, job.ID).Scan(&runID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("failed to record job run")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute based on job type
|
||||||
|
var output map[string]interface{}
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
switch job.JobType {
|
||||||
|
case "report":
|
||||||
|
output, runErr = e.runReportJob(ctx, job)
|
||||||
|
case "reminder":
|
||||||
|
output, runErr = e.runReminderJob(ctx, job)
|
||||||
|
case "sync":
|
||||||
|
output, runErr = e.runSyncJob(ctx, job)
|
||||||
|
case "cleanup":
|
||||||
|
output, runErr = e.runCleanupJob(ctx, job)
|
||||||
|
case "backup":
|
||||||
|
output, runErr = e.runBackupJob(ctx, job)
|
||||||
|
default:
|
||||||
|
runErr = fmt.Errorf("unknown job type: %s", job.JobType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record completion
|
||||||
|
status := "completed"
|
||||||
|
var errorMsg interface{}
|
||||||
|
if runErr != nil {
|
||||||
|
status = "failed"
|
||||||
|
errorMsg = runErr.Error()
|
||||||
|
logger.Error().Err(runErr).Msg("job failed")
|
||||||
|
|
||||||
|
// Increment fail count
|
||||||
|
e.db.ExecContext(ctx, `
|
||||||
|
UPDATE boc_scheduled_jobs SET fail_count = fail_count + 1 WHERE id = $1
|
||||||
|
`, job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputJSON, _ := json.Marshal(output)
|
||||||
|
e.db.ExecContext(ctx, `
|
||||||
|
UPDATE boc_scheduled_job_runs
|
||||||
|
SET status = $1, output = $2, error = $3, completed_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
`, status, outputJSON, errorMsg, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) executeWorkflow(ctx context.Context, wf Workflow) {
|
||||||
|
logger := e.logger.With().Str("workflow", wf.ID.String()).Logger()
|
||||||
|
logger.Info().Str("name", wf.Name).Msg("executing workflow")
|
||||||
|
|
||||||
|
var runID string
|
||||||
|
err := e.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO boc_workflow_runs (tenant_id, workflow_id, status)
|
||||||
|
VALUES ($1, $2, 'running')
|
||||||
|
RETURNING id
|
||||||
|
`, wf.TenantID, wf.ID).Scan(&runID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("failed to record workflow run")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute actions sequentially
|
||||||
|
var output = map[string]interface{}{"actions_completed": 0}
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
for i, action := range wf.Actions {
|
||||||
|
actionType, _ := action["type"].(string)
|
||||||
|
logger.Info().Int("step", i+1).Str("action", actionType).Msg("executing action")
|
||||||
|
|
||||||
|
if err := e.executeAction(ctx, wf.TenantID.String(), action); err != nil {
|
||||||
|
runErr = fmt.Errorf("action %d (%s) failed: %w", i+1, actionType, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output["actions_completed"] = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "completed"
|
||||||
|
var errorMsg interface{}
|
||||||
|
if runErr != nil {
|
||||||
|
status = "failed"
|
||||||
|
errorMsg = runErr.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
outputJSON, _ := json.Marshal(output)
|
||||||
|
e.db.ExecContext(ctx, `
|
||||||
|
UPDATE boc_workflow_runs
|
||||||
|
SET status = $1, output = $2, error = $3, completed_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
`, status, outputJSON, errorMsg, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) executeAction(ctx context.Context, tenantID string, action map[string]interface{}) error {
|
||||||
|
actionType, _ := action["type"].(string)
|
||||||
|
|
||||||
|
switch actionType {
|
||||||
|
case "send_email":
|
||||||
|
// TODO: Implement email sending
|
||||||
|
return nil
|
||||||
|
case "send_notification":
|
||||||
|
// TODO: Implement notification
|
||||||
|
return nil
|
||||||
|
case "create_task":
|
||||||
|
// TODO: Create task in system
|
||||||
|
return nil
|
||||||
|
case "update_record":
|
||||||
|
// TODO: Update database record
|
||||||
|
return nil
|
||||||
|
case "webhook":
|
||||||
|
// TODO: Call external webhook
|
||||||
|
return nil
|
||||||
|
case "generate_report":
|
||||||
|
// TODO: Generate and send report
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown action type: %s", actionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job type implementations
|
||||||
|
func (e *Engine) runReportJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
||||||
|
reportType, _ := job.JobConfig["report_type"].(string)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"report_type": reportType,
|
||||||
|
"generated_at": time.Now().UTC(),
|
||||||
|
"status": "generated",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) runReminderJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
||||||
|
// Check for upcoming contract renewals, invoice due dates, etc.
|
||||||
|
return map[string]interface{}{
|
||||||
|
"reminders_sent": 0,
|
||||||
|
"checked_at": time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) runSyncJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
||||||
|
syncTarget, _ := job.JobConfig["target"].(string)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"target": syncTarget,
|
||||||
|
"synced_at": time.Now().UTC(),
|
||||||
|
"status": "synced",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) runCleanupJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
||||||
|
// Clean up old data based on retention policy
|
||||||
|
return map[string]interface{}{
|
||||||
|
"cleaned_at": time.Now().UTC(),
|
||||||
|
"status": "cleaned",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) runBackupJob(ctx context.Context, job ScheduledJob) (map[string]interface{}, error) {
|
||||||
|
// Trigger database backup
|
||||||
|
return map[string]interface{}{
|
||||||
|
"backed_up_at": time.Now().UTC(),
|
||||||
|
"status": "backed_up",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) calculateNextRun(cronExpr, timezone string) (time.Time, error) {
|
||||||
|
// Simple implementation: for now, just add 1 hour
|
||||||
|
// TODO: Implement proper cron parsing
|
||||||
|
return time.Now().UTC().Add(1 * time.Hour), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerWorkflow manually triggers a workflow by ID
|
||||||
|
func (e *Engine) TriggerWorkflow(ctx context.Context, workflowID string, input map[string]interface{}) error {
|
||||||
|
var wf Workflow
|
||||||
|
var triggerJSON, actionsJSON []byte
|
||||||
|
|
||||||
|
err := e.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, tenant_id, name, trigger_config, actions
|
||||||
|
FROM boc_workflows WHERE id = $1
|
||||||
|
`, workflowID).Scan(&wf.ID, &wf.TenantID, &wf.Name, &triggerJSON, &actionsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("workflow not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal(triggerJSON, &wf.TriggerConfig)
|
||||||
|
json.Unmarshal(actionsJSON, &wf.Actions)
|
||||||
|
|
||||||
|
go e.executeWorkflow(ctx, wf)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package automation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UUID is a custom type for PostgreSQL UUID
|
||||||
|
type UUID string
|
||||||
|
|
||||||
|
func (u UUID) String() string {
|
||||||
|
return string(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver.Valuer interface
|
||||||
|
func (u UUID) Value() (driver.Value, error) {
|
||||||
|
return string(u), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements the sql.Scanner interface
|
||||||
|
func (u *UUID) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*u = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
*u = UUID(v)
|
||||||
|
case []byte:
|
||||||
|
*u = UUID(string(v))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan type %T into UUID", value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONMap is a map that can be stored as JSONB
|
||||||
|
type JSONMap map[string]interface{}
|
||||||
|
|
||||||
|
// Value implements the driver.Valuer interface
|
||||||
|
func (j JSONMap) Value() (driver.Value, error) {
|
||||||
|
if j == nil {
|
||||||
|
return []byte("{}"), nil
|
||||||
|
}
|
||||||
|
return json.Marshal(j)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements the sql.Scanner interface
|
||||||
|
func (j *JSONMap) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*j = JSONMap{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
bytes = []byte(v)
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan type %T into JSONMap", value)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduledJob represents a scheduled automation job
|
||||||
|
type ScheduledJob struct {
|
||||||
|
ID UUID `json:"id"`
|
||||||
|
TenantID UUID `json:"tenant_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CronExpr string `json:"cron_expr"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
JobType string `json:"job_type"`
|
||||||
|
JobConfig JSONMap `json:"job_config"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastRunAt *time.Time `json:"last_run_at"`
|
||||||
|
NextRunAt *time.Time `json:"next_run_at"`
|
||||||
|
RunCount int `json:"run_count"`
|
||||||
|
FailCount int `json:"fail_count"`
|
||||||
|
CreatedBy *UUID `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workflow represents an automation workflow
|
||||||
|
type Workflow struct {
|
||||||
|
ID UUID `json:"id"`
|
||||||
|
TenantID UUID `json:"tenant_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TriggerType string `json:"trigger_type"`
|
||||||
|
TriggerConfig JSONMap `json:"trigger_config"`
|
||||||
|
Actions []JSONMap `json:"actions"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastRunAt *time.Time `json:"last_run_at"`
|
||||||
|
NextRunAt *time.Time `json:"next_run_at"`
|
||||||
|
RunCount int `json:"run_count"`
|
||||||
|
FailCount int `json:"fail_count"`
|
||||||
|
CreatedBy *UUID `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
Vendored
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisClient wraps go-redis with BOC-specific operations
|
||||||
|
type RedisClient struct {
|
||||||
|
client *redis.Client
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRedisClient creates a new Redis client
|
||||||
|
func NewRedisClient(addr string) (*RedisClient, error) {
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: "", // no password
|
||||||
|
DB: 0, // default DB
|
||||||
|
PoolSize: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("redis ping failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RedisClient{
|
||||||
|
client: client,
|
||||||
|
ctx: ctx,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the Redis connection
|
||||||
|
func (r *RedisClient) Close() error {
|
||||||
|
return r.client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value from cache
|
||||||
|
func (r *RedisClient) Get(key string, dest interface{}) error {
|
||||||
|
data, err := r.client.Get(r.ctx, key).Bytes()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return fmt.Errorf("cache miss")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a value in cache with TTL
|
||||||
|
func (r *RedisClient) Set(key string, value interface{}, ttl time.Duration) error {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.client.Set(r.ctx, key, data, ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a key from cache
|
||||||
|
func (r *RedisClient) Delete(key string) error {
|
||||||
|
return r.client.Del(r.ctx, key).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePattern removes keys matching a pattern
|
||||||
|
func (r *RedisClient) DeletePattern(pattern string) error {
|
||||||
|
keys, err := r.client.Keys(r.ctx, pattern).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(keys) > 0 {
|
||||||
|
return r.client.Del(r.ctx, keys...).Err()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a key exists
|
||||||
|
func (r *RedisClient) Exists(key string) bool {
|
||||||
|
n, err := r.client.Exists(r.ctx, key).Result()
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment atomically increments a counter
|
||||||
|
func (r *RedisClient) Increment(key string) (int64, error) {
|
||||||
|
return r.client.Incr(r.ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire sets a TTL on a key
|
||||||
|
func (r *RedisClient) Expire(key string, ttl time.Duration) error {
|
||||||
|
return r.client.Expire(r.ctx, key, ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache analytics result
|
||||||
|
func (r *RedisClient) CacheAnalytics(tenantID, metric, period string, data interface{}) error {
|
||||||
|
key := fmt.Sprintf("analytics:%s:%s:%s", tenantID, metric, period)
|
||||||
|
return r.Set(key, data, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCachedAnalytics retrieves cached analytics
|
||||||
|
func (r *RedisClient) GetCachedAnalytics(tenantID, metric, period string, dest interface{}) error {
|
||||||
|
key := fmt.Sprintf("analytics:%s:%s:%s", tenantID, metric, period)
|
||||||
|
return r.Get(key, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache dashboard data
|
||||||
|
func (r *RedisClient) CacheDashboard(tenantID string, data interface{}) error {
|
||||||
|
key := fmt.Sprintf("dashboard:%s", tenantID)
|
||||||
|
return r.Set(key, data, 1*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCachedDashboard retrieves cached dashboard
|
||||||
|
func (r *RedisClient) GetCachedDashboard(tenantID string, dest interface{}) error {
|
||||||
|
key := fmt.Sprintf("dashboard:%s", tenantID)
|
||||||
|
return r.Get(key, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
func (r *RedisClient) RateLimit(key string, maxRequests int, window time.Duration) (bool, error) {
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
windowStart := now - int64(window.Seconds())
|
||||||
|
|
||||||
|
// Remove old entries
|
||||||
|
pipe.ZRemRangeByScore(r.ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||||
|
// Count current entries
|
||||||
|
pipe.ZCard(r.ctx, key)
|
||||||
|
// Add current request
|
||||||
|
pipe.ZAdd(r.ctx, key, redis.Z{Score: float64(now), Member: now})
|
||||||
|
// Set expiry on the key
|
||||||
|
pipe.Expire(r.ctx, key, window)
|
||||||
|
|
||||||
|
cmders, err := pipe.Exec(r.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmders[1] is ZCard result
|
||||||
|
count := cmders[1].(*redis.IntCmd).Val()
|
||||||
|
return count <= int64(maxRequests), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
func (r *RedisClient) SetSession(sessionID string, data map[string]interface{}, ttl time.Duration) error {
|
||||||
|
key := fmt.Sprintf("session:%s", sessionID)
|
||||||
|
return r.Set(key, data, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RedisClient) GetSession(sessionID string) (map[string]interface{}, error) {
|
||||||
|
key := fmt.Sprintf("session:%s", sessionID)
|
||||||
|
var data map[string]interface{}
|
||||||
|
err := r.Get(key, &data)
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RedisClient) DeleteSession(sessionID string) error {
|
||||||
|
key := fmt.Sprintf("session:%s", sessionID)
|
||||||
|
return r.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pub/Sub for real-time events
|
||||||
|
func (r *RedisClient) Publish(channel string, message interface{}) error {
|
||||||
|
data, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.client.Publish(r.ctx, channel, data).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RedisClient) Subscribe(channel string) *redis.PubSub {
|
||||||
|
return r.client.Subscribe(r.ctx, channel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DBURL string
|
||||||
|
JWTSecret string
|
||||||
|
AMOSBaseURL string
|
||||||
|
CORSOrigins []string
|
||||||
|
MigrationsDir string
|
||||||
|
RustServiceURL string
|
||||||
|
RedisURL string
|
||||||
|
KafkaBrokers []string
|
||||||
|
ResendAPIKey string
|
||||||
|
FromEmail string
|
||||||
|
FromName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
Port: getEnv("PORT", "9092"),
|
||||||
|
DBURL: getEnv("DB_URL", "postgres://boc:boc@localhost:5432/boc?sslmode=disable"),
|
||||||
|
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||||
|
AMOSBaseURL: getEnv("AMOS_BASE_URL", "http://localhost:9000"),
|
||||||
|
CORSOrigins: splitComma(getEnv("CORS_ORIGINS", "http://localhost:3000")),
|
||||||
|
MigrationsDir: getEnv("MIGRATIONS_DIR", "./db/migrations"),
|
||||||
|
RustServiceURL: getEnv("RUST_SERVICE_URL", "http://localhost:9093"),
|
||||||
|
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
|
||||||
|
KafkaBrokers: splitComma(getEnv("KAFKA_BROKERS", "localhost:9092")),
|
||||||
|
ResendAPIKey: getEnv("RESEND_API_KEY", ""),
|
||||||
|
FromEmail: getEnv("FROM_EMAIL", "noreply@landvex.com"),
|
||||||
|
FromName: getEnv("FROM_NAME", "Landvex BOC"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitComma(s string) []string {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if t := strings.TrimSpace(p); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Connect(url string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db open: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(25)
|
||||||
|
db.SetMaxIdleConns(10)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("db ping: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := autoMigrate(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("db migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoMigrate(db *sql.DB) error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS boc_customers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
company TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'lead',
|
||||||
|
source TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS boc_deals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT REFERENCES boc_customers(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
value DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'SEK',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
stage TEXT NOT NULL DEFAULT 'prospect',
|
||||||
|
probability INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expected_close TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS boc_invoices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT REFERENCES boc_customers(id),
|
||||||
|
amount DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'SEK',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
due_date TIMESTAMPTZ,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS boc_tickets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT REFERENCES boc_customers(id),
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
assigned_to TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_customers_status ON boc_customers(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_deals_status ON boc_deals(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_invoices_status ON boc_invoices(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tickets_status ON boc_tickets(status)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS boc_employees (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
department TEXT,
|
||||||
|
position TEXT,
|
||||||
|
salary DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'SEK',
|
||||||
|
start_date TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_employees_status ON boc_employees(status)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
return fmt.Errorf("exec %q: %w", s[:min(40, len(s))], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Migration struct {
|
||||||
|
Version string
|
||||||
|
Name string
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunMigrations(db *sql.DB, migrationsDir string) error {
|
||||||
|
// Ensure migrations table exists
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_schema_migrations (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`); err != nil {
|
||||||
|
return fmt.Errorf("create migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read migration files
|
||||||
|
files, err := os.ReadDir(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrations []Migration
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() || !strings.HasSuffix(f.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(migrationsDir, f.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", f.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.Split(f.Name(), "_")[0]
|
||||||
|
migrations = append(migrations, Migration{
|
||||||
|
Version: version,
|
||||||
|
Name: f.Name(),
|
||||||
|
SQL: string(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by version
|
||||||
|
sort.Slice(migrations, func(i, j int) bool {
|
||||||
|
return migrations[i].Version < migrations[j].Version
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply migrations in transaction
|
||||||
|
for _, m := range migrations {
|
||||||
|
var applied bool
|
||||||
|
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM boc_schema_migrations WHERE version = $1)", m.Version).Scan(&applied)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check migration %s: %w", m.Version, err)
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(m.SQL); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("apply migration %s: %w", m.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec("INSERT INTO boc_schema_migrations (version) VALUES ($1)", m.Version); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", m.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", m.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✅ Applied migration: %s\n", m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
-- BOC Initial Schema
|
||||||
|
-- Business Operations Center — Full schema for all modules
|
||||||
|
-- Created: 2026-07-12
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- Core: Tenants (multi-tenant support)
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
domain TEXT,
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Core: Users
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
avatar_url TEXT,
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
last_login TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(tenant_id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Core: Audit log (immutable)
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES boc_users(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
old_value JSONB,
|
||||||
|
new_value JSONB,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CRM: Customers
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_customers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
company TEXT,
|
||||||
|
org_number TEXT,
|
||||||
|
address JSONB,
|
||||||
|
status TEXT NOT NULL DEFAULT 'lead',
|
||||||
|
source TEXT,
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
assigned_to UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CRM: Customer interactions
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_customer_interactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL, -- call, email, meeting, note, task
|
||||||
|
direction TEXT, -- inbound, outbound
|
||||||
|
subject TEXT,
|
||||||
|
content TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CRM: Contacts (people within customer orgs)
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_contacts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
title TEXT,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sales: Deals
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_deals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id),
|
||||||
|
contact_id UUID REFERENCES boc_contacts(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
value DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
stage TEXT NOT NULL DEFAULT 'prospect',
|
||||||
|
probability INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expected_close DATE,
|
||||||
|
actual_close TIMESTAMPTZ,
|
||||||
|
won_reason TEXT,
|
||||||
|
lost_reason TEXT,
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
assigned_to UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sales: Deal timeline / activities
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_deal_activities (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
deal_id UUID REFERENCES boc_deals(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL, -- call, email, meeting, proposal, note, stage_change
|
||||||
|
description TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sales: Products/Services
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
sku TEXT,
|
||||||
|
price DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
unit TEXT DEFAULT 'piece',
|
||||||
|
is_recurring BOOLEAN DEFAULT FALSE,
|
||||||
|
billing_period TEXT, -- monthly, quarterly, yearly
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sales: Deal line items
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_deal_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
deal_id UUID REFERENCES boc_deals(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES boc_products(id),
|
||||||
|
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||||
|
unit_price DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
discount DECIMAL(5,2) DEFAULT 0,
|
||||||
|
total DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Finance: Invoices
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id),
|
||||||
|
deal_id UUID REFERENCES boc_deals(id),
|
||||||
|
invoice_number TEXT NOT NULL,
|
||||||
|
amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
due_date DATE,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
paid_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Finance: Invoice items
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_invoice_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
invoice_id UUID REFERENCES boc_invoices(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||||
|
unit_price DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_rate DECIMAL(5,2) DEFAULT 0,
|
||||||
|
total DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Finance: Payments
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_payments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
invoice_id UUID REFERENCES boc_invoices(id),
|
||||||
|
amount DECIMAL(15,2) NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
method TEXT, -- bank_transfer, card, cash, stripe, etc
|
||||||
|
reference TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'completed',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Finance: Expenses
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_expenses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
amount DECIMAL(15,2) NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
vendor TEXT,
|
||||||
|
receipt_url TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
approved_by UUID REFERENCES boc_users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Finance: Budgets
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_budgets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
fiscal_year INTEGER NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
amount DECIMAL(15,2) NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
spent DECIMAL(15,2) DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- HR: Employees
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_employees (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES boc_users(id),
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
department TEXT,
|
||||||
|
position TEXT,
|
||||||
|
employment_type TEXT DEFAULT 'full_time',
|
||||||
|
salary DECIMAL(15,2),
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
manager_id UUID REFERENCES boc_employees(id),
|
||||||
|
address JSONB,
|
||||||
|
bank_info JSONB,
|
||||||
|
documents JSONB DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- HR: Time off / Leave
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_leaves (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
employee_id UUID REFERENCES boc_employees(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL, -- vacation, sick, parental, unpaid
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
days DECIMAL(4,1) NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
approved_by UUID REFERENCES boc_users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- HR: Timesheets
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_timesheets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
employee_id UUID REFERENCES boc_employees(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
hours DECIMAL(4,2) NOT NULL DEFAULT 0,
|
||||||
|
project TEXT,
|
||||||
|
task TEXT,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
approved_by UUID REFERENCES boc_users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(tenant_id, employee_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Legal: Contracts
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_contracts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
counterparty TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- service, employment, nda, partnership, etc
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
value DECIMAL(15,2),
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
renewal_date DATE,
|
||||||
|
document_url TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Legal: Contract reminders
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_contract_reminders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
contract_id UUID REFERENCES boc_contracts(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL, -- renewal, expiration, payment, review
|
||||||
|
due_date DATE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Marketing: Campaigns
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_campaigns (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
type TEXT NOT NULL, -- email, social, content, event, ad
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
budget DECIMAL(15,2),
|
||||||
|
spent DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
metrics JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Marketing: Content items
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_content (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
campaign_id UUID REFERENCES boc_campaigns(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- blog, social, email, video, whitepaper
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
publish_at TIMESTAMPTZ,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
url TEXT,
|
||||||
|
metrics JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Support: Tickets
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_tickets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id),
|
||||||
|
contact_id UUID REFERENCES boc_contacts(id),
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
category TEXT,
|
||||||
|
source TEXT, -- email, chat, phone, web
|
||||||
|
assigned_to UUID REFERENCES boc_users(id),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolution TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Support: Ticket comments
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_ticket_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
ticket_id UUID REFERENCES boc_tickets(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_internal BOOLEAN DEFAULT FALSE,
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Automation: Workflows
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_workflows (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
trigger_type TEXT NOT NULL, -- schedule, event, webhook, manual
|
||||||
|
trigger_config JSONB DEFAULT '{}',
|
||||||
|
actions JSONB NOT NULL DEFAULT '[]',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
next_run_at TIMESTAMPTZ,
|
||||||
|
run_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Automation: Workflow runs
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_workflow_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
workflow_id UUID REFERENCES boc_workflows(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
input JSONB DEFAULT '{}',
|
||||||
|
output JSONB DEFAULT '{}',
|
||||||
|
error TEXT,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Automation: Scheduled jobs
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_scheduled_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
cron_expr TEXT NOT NULL,
|
||||||
|
timezone TEXT DEFAULT 'UTC',
|
||||||
|
job_type TEXT NOT NULL, -- report, reminder, sync, cleanup, backup
|
||||||
|
job_config JSONB DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
next_run_at TIMESTAMPTZ,
|
||||||
|
run_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Automation: Scheduled job runs
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_scheduled_job_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
job_id UUID REFERENCES boc_scheduled_jobs(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
output JSONB DEFAULT '{}',
|
||||||
|
error TEXT,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON boc_audit_log(tenant_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customers_tenant ON boc_customers(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_customers_assigned ON boc_customers(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_interactions_customer ON boc_customer_interactions(customer_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deals_tenant ON boc_deals(tenant_id, status, stage);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deals_customer ON boc_deals(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deals_assigned ON boc_deals(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deals_expected_close ON boc_deals(expected_close);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON boc_invoices(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_due ON boc_invoices(due_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expenses_tenant ON boc_expenses(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_employees_tenant ON boc_employees(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leaves_employee ON boc_leaves(employee_id, start_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_timesheets_employee ON boc_timesheets(employee_id, date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contracts_tenant ON boc_contracts(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contracts_renewal ON boc_contracts(renewal_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_tenant ON boc_tickets(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON boc_tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflows_tenant ON boc_workflows(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_tenant ON boc_scheduled_jobs(tenant_id, status);
|
||||||
|
|
||||||
|
-- Functions
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Triggers for updated_at
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TRIGGER update_boc_tenants_updated_at BEFORE UPDATE ON boc_tenants FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_users_updated_at BEFORE UPDATE ON boc_users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_customers_updated_at BEFORE UPDATE ON boc_customers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_deals_updated_at BEFORE UPDATE ON boc_deals FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_invoices_updated_at BEFORE UPDATE ON boc_invoices FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_expenses_updated_at BEFORE UPDATE ON boc_expenses FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_budgets_updated_at BEFORE UPDATE ON boc_budgets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_employees_updated_at BEFORE UPDATE ON boc_employees FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_leaves_updated_at BEFORE UPDATE ON boc_leaves FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_timesheets_updated_at BEFORE UPDATE ON boc_timesheets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_contracts_updated_at BEFORE UPDATE ON boc_contracts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_campaigns_updated_at BEFORE UPDATE ON boc_campaigns FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_content_updated_at BEFORE UPDATE ON boc_content FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_tickets_updated_at BEFORE UPDATE ON boc_tickets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_workflows_updated_at BEFORE UPDATE ON boc_workflows FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_scheduled_jobs_updated_at BEFORE UPDATE ON boc_scheduled_jobs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
EXCEPTION WHEN duplicate_object THEN
|
||||||
|
-- Triggers already exist, ignore
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
-- BOC Schema Extension: Quotes, Orders, Suppliers, Inventory
|
||||||
|
-- Created: 2026-07-12
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SALES: Quotes (Offert)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_quotes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id) ON DELETE CASCADE,
|
||||||
|
contact_id UUID REFERENCES boc_contacts(id),
|
||||||
|
quote_number TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- draft, sent, accepted, rejected, expired
|
||||||
|
amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
valid_until DATE,
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
converted_to_order_id UUID,
|
||||||
|
notes TEXT,
|
||||||
|
terms TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_quote_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
quote_id UUID REFERENCES boc_quotes(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES boc_products(id),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||||
|
unit_price DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_rate DECIMAL(5,2) DEFAULT 0,
|
||||||
|
discount DECIMAL(5,2) DEFAULT 0,
|
||||||
|
total DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SALES: Orders
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id) ON DELETE CASCADE,
|
||||||
|
quote_id UUID REFERENCES boc_quotes(id),
|
||||||
|
order_number TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- draft, confirmed, processing, shipped, delivered, cancelled
|
||||||
|
amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
delivery_date DATE,
|
||||||
|
shipped_at TIMESTAMPTZ,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
tracking_number TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_order_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
order_id UUID REFERENCES boc_orders(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES boc_products(id),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||||
|
unit_price DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_rate DECIMAL(5,2) DEFAULT 0,
|
||||||
|
discount DECIMAL(5,2) DEFAULT 0,
|
||||||
|
total DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
delivered_qty DECIMAL(10,2) DEFAULT 0,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PURCHASE: Suppliers
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_suppliers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
org_number TEXT,
|
||||||
|
address JSONB,
|
||||||
|
payment_terms TEXT DEFAULT '30 days',
|
||||||
|
bank_account TEXT,
|
||||||
|
bankgiro TEXT,
|
||||||
|
postgiro TEXT,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PURCHASE: Purchase Orders
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_purchase_orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
supplier_id UUID REFERENCES boc_suppliers(id) ON DELETE CASCADE,
|
||||||
|
po_number TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- draft, sent, confirmed, received, invoiced, paid
|
||||||
|
amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
expected_delivery DATE,
|
||||||
|
received_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_purchase_order_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
po_id UUID REFERENCES boc_purchase_orders(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES boc_products(id),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||||
|
unit_price DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_rate DECIMAL(5,2) DEFAULT 0,
|
||||||
|
total DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
received_qty DECIMAL(10,2) DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PURCHASE: Supplier Invoices (Leverantörsfakturor)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_supplier_invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
supplier_id UUID REFERENCES boc_suppliers(id),
|
||||||
|
po_id UUID REFERENCES boc_purchase_orders(id),
|
||||||
|
invoice_number TEXT NOT NULL,
|
||||||
|
amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- draft, received, approved, paid, disputed
|
||||||
|
due_date DATE,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
paid_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
ocr_number TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- INVENTORY: Stock / Warehouse
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_warehouses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
address JSONB,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_inventory (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES boc_products(id) ON DELETE CASCADE,
|
||||||
|
warehouse_id UUID REFERENCES boc_warehouses(id),
|
||||||
|
quantity DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
reserved_qty DECIMAL(10,2) DEFAULT 0,
|
||||||
|
reorder_point DECIMAL(10,2) DEFAULT 0,
|
||||||
|
reorder_qty DECIMAL(10,2) DEFAULT 0,
|
||||||
|
unit_cost DECIMAL(15,2) DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(tenant_id, product_id, warehouse_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_inventory_movements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES boc_products(id),
|
||||||
|
warehouse_id UUID REFERENCES boc_warehouses(id),
|
||||||
|
type TEXT NOT NULL, -- in, out, adjustment, transfer
|
||||||
|
quantity DECIMAL(10,2) NOT NULL,
|
||||||
|
reference_type TEXT, -- order, po, adjustment
|
||||||
|
reference_id TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- RECURRING: Subscriptions & Recurring Invoices
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_subscription_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
product_id UUID REFERENCES boc_products(id),
|
||||||
|
interval TEXT NOT NULL DEFAULT 'monthly', -- weekly, monthly, quarterly, yearly
|
||||||
|
interval_count INTEGER DEFAULT 1,
|
||||||
|
price DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
trial_days INTEGER DEFAULT 0,
|
||||||
|
setup_fee DECIMAL(15,2) DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id) ON DELETE CASCADE,
|
||||||
|
plan_id UUID REFERENCES boc_subscription_plans(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active, paused, cancelled, expired
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
trial_end DATE,
|
||||||
|
current_period_start DATE,
|
||||||
|
current_period_end DATE,
|
||||||
|
price DECIMAL(15,2) NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_recurring_invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id),
|
||||||
|
subscription_id UUID REFERENCES boc_subscriptions(id),
|
||||||
|
plan_id UUID REFERENCES boc_subscription_plans(id),
|
||||||
|
invoice_number TEXT,
|
||||||
|
amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, generated, sent, paid, failed
|
||||||
|
scheduled_date DATE NOT NULL,
|
||||||
|
generated_at TIMESTAMPTZ,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
error TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- EXPENSES: Receipts & OCR
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_receipts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
employee_id UUID REFERENCES boc_employees(id),
|
||||||
|
expense_id UUID REFERENCES boc_expenses(id),
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
ocr_text TEXT,
|
||||||
|
ocr_data JSONB DEFAULT '{}', -- extracted: amount, date, vendor, category
|
||||||
|
ocr_confidence DECIMAL(5,2) DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, processed, failed
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PAYROLL: Basic structure
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_payroll_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
pay_date DATE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft', -- draft, processing, approved, paid
|
||||||
|
total_gross DECIMAL(15,2) DEFAULT 0,
|
||||||
|
total_tax DECIMAL(15,2) DEFAULT 0,
|
||||||
|
total_net DECIMAL(15,2) DEFAULT 0,
|
||||||
|
total_employer_tax DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_by UUID REFERENCES boc_users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_payroll_lines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
payroll_run_id UUID REFERENCES boc_payroll_runs(id) ON DELETE CASCADE,
|
||||||
|
employee_id UUID REFERENCES boc_employees(id),
|
||||||
|
gross_salary DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_deduction DECIMAL(15,2) DEFAULT 0,
|
||||||
|
social_fees DECIMAL(15,2) DEFAULT 0,
|
||||||
|
pension DECIMAL(15,2) DEFAULT 0,
|
||||||
|
other_deductions DECIMAL(15,2) DEFAULT 0,
|
||||||
|
net_salary DECIMAL(15,2) DEFAULT 0,
|
||||||
|
hours_worked DECIMAL(5,2) DEFAULT 0,
|
||||||
|
vacation_days_used DECIMAL(4,1) DEFAULT 0,
|
||||||
|
sick_days DECIMAL(4,1) DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- BANK: Accounts & Transactions
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_bank_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
bank_name TEXT NOT NULL,
|
||||||
|
account_number TEXT NOT NULL,
|
||||||
|
iban TEXT,
|
||||||
|
bic TEXT,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
balance DECIMAL(15,2) DEFAULT 0,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
last_sync TIMESTAMPTZ,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_bank_transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
account_id UUID REFERENCES boc_bank_accounts(id) ON DELETE CASCADE,
|
||||||
|
transaction_date DATE NOT NULL,
|
||||||
|
amount DECIMAL(15,2) NOT NULL,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
description TEXT,
|
||||||
|
counterparty TEXT,
|
||||||
|
reference TEXT,
|
||||||
|
external_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unmatched', -- unmatched, matched, reconciled
|
||||||
|
matched_to_type TEXT, -- invoice, expense, payroll
|
||||||
|
matched_to_id UUID,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- PROJECTS
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
customer_id UUID REFERENCES boc_customers(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active, completed, on_hold, cancelled
|
||||||
|
budget DECIMAL(15,2) DEFAULT 0,
|
||||||
|
spent DECIMAL(15,2) DEFAULT 0,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
manager_id UUID REFERENCES boc_employees(id),
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_project_times (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES boc_projects(id) ON DELETE CASCADE,
|
||||||
|
employee_id UUID REFERENCES boc_employees(id),
|
||||||
|
date DATE NOT NULL,
|
||||||
|
hours DECIMAL(4,2) NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
billable BOOLEAN DEFAULT TRUE,
|
||||||
|
hourly_rate DECIMAL(15,2) DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boc_project_expenses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES boc_tenants(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID REFERENCES boc_projects(id) ON DELETE CASCADE,
|
||||||
|
expense_id UUID REFERENCES boc_expenses(id),
|
||||||
|
amount DECIMAL(15,2) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotes_tenant ON boc_quotes(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotes_customer ON boc_quotes(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_tenant ON boc_orders(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_customer ON boc_orders(customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_suppliers_tenant ON boc_suppliers(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_po_tenant ON boc_purchase_orders(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_tenant ON boc_supplier_invoices(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_product ON boc_inventory(product_id, warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_movements ON boc_inventory_movements(product_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON boc_subscriptions(customer_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recurring_invoices ON boc_recurring_invoices(scheduled_date, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_receipts_status ON boc_receipts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payroll_runs ON boc_payroll_runs(period_start, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bank_transactions ON boc_bank_transactions(account_id, transaction_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_tenant ON boc_projects(tenant_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_times ON boc_project_times(project_id, date);
|
||||||
|
|
||||||
|
-- Triggers for updated_at
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TRIGGER update_boc_quotes_updated_at BEFORE UPDATE ON boc_quotes FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_orders_updated_at BEFORE UPDATE ON boc_orders FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_suppliers_updated_at BEFORE UPDATE ON boc_suppliers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_purchase_orders_updated_at BEFORE UPDATE ON boc_purchase_orders FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_supplier_invoices_updated_at BEFORE UPDATE ON boc_supplier_invoices FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_inventory_updated_at BEFORE UPDATE ON boc_inventory FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_subscription_plans_updated_at BEFORE UPDATE ON boc_subscription_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_subscriptions_updated_at BEFORE UPDATE ON boc_subscriptions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_payroll_runs_updated_at BEFORE UPDATE ON boc_payroll_runs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_bank_accounts_updated_at BEFORE UPDATE ON boc_bank_accounts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER update_boc_projects_updated_at BEFORE UPDATE ON boc_projects FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
EXCEPTION WHEN duplicate_object THEN
|
||||||
|
-- Triggers already exist, ignore
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const resendAPIURL = "https://api.resend.com/emails"
|
||||||
|
|
||||||
|
// Client handles email sending via Resend
|
||||||
|
type Client struct {
|
||||||
|
apiKey string
|
||||||
|
fromEmail string
|
||||||
|
fromName string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Resend email client
|
||||||
|
func NewClient(apiKey, fromEmail, fromName string) *Client {
|
||||||
|
return &Client{
|
||||||
|
apiKey: apiKey,
|
||||||
|
fromEmail: fromEmail,
|
||||||
|
fromName: fromName,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email represents an email to be sent
|
||||||
|
type Email struct {
|
||||||
|
To []string `json:"to"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
HTML string `json:"html,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment represents an email attachment
|
||||||
|
type Attachment struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content []byte `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEmail sends an email via Resend
|
||||||
|
func (c *Client) SendEmail(to []string, subject, htmlBody, textBody string) error {
|
||||||
|
from := c.fromEmail
|
||||||
|
if c.fromName != "" {
|
||||||
|
from = fmt.Sprintf("%s <%s>", c.fromName, c.fromEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := Email{
|
||||||
|
To: to,
|
||||||
|
From: from,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: htmlBody,
|
||||||
|
Text: textBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.send(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEmailWithAttachment sends an email with attachments
|
||||||
|
func (c *Client) SendEmailWithAttachment(to []string, subject, htmlBody, textBody string, attachments []Attachment) error {
|
||||||
|
from := c.fromEmail
|
||||||
|
if c.fromName != "" {
|
||||||
|
from = fmt.Sprintf("%s <%s>", c.fromName, c.fromEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := Email{
|
||||||
|
To: to,
|
||||||
|
From: from,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: htmlBody,
|
||||||
|
Text: textBody,
|
||||||
|
Attachments: attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.send(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendInvoice sends an invoice email with PDF attachment
|
||||||
|
func (c *Client) SendInvoice(to []string, invoiceNumber string, pdfData []byte, htmlBody string) error {
|
||||||
|
if htmlBody == "" {
|
||||||
|
htmlBody = fmt.Sprintf(`
|
||||||
|
<h2>Faktura %s</h2>
|
||||||
|
<p>Bifogat finner du din faktura.</p>
|
||||||
|
<p>Vid frågor, kontakta oss.</p>
|
||||||
|
`, invoiceNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments := []Attachment{
|
||||||
|
{
|
||||||
|
Filename: fmt.Sprintf("faktura-%s.pdf", invoiceNumber),
|
||||||
|
Content: pdfData,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendEmailWithAttachment(
|
||||||
|
to,
|
||||||
|
fmt.Sprintf("Faktura %s", invoiceNumber),
|
||||||
|
htmlBody,
|
||||||
|
fmt.Sprintf("Faktura %s bifogad.", invoiceNumber),
|
||||||
|
attachments,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendQuote sends a quote email with PDF attachment
|
||||||
|
func (c *Client) SendQuote(to []string, quoteNumber string, pdfData []byte, htmlBody string) error {
|
||||||
|
if htmlBody == "" {
|
||||||
|
htmlBody = fmt.Sprintf(`
|
||||||
|
<h2>Offert %s</h2>
|
||||||
|
<p>Bifogat finner du din offert.</p>
|
||||||
|
<p>Offerten är giltig i 30 dagar.</p>
|
||||||
|
`, quoteNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments := []Attachment{
|
||||||
|
{
|
||||||
|
Filename: fmt.Sprintf("offert-%s.pdf", quoteNumber),
|
||||||
|
Content: pdfData,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendEmailWithAttachment(
|
||||||
|
to,
|
||||||
|
fmt.Sprintf("Offert %s", quoteNumber),
|
||||||
|
htmlBody,
|
||||||
|
fmt.Sprintf("Offert %s bifogad.", quoteNumber),
|
||||||
|
attachments,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendWelcome sends a welcome email to a new customer
|
||||||
|
func (c *Client) SendWelcome(to []string, customerName string) error {
|
||||||
|
htmlBody := fmt.Sprintf(`
|
||||||
|
<h2>Välkommen %s!</h2>
|
||||||
|
<p>Tack för att du valde oss. Vi ser fram emot ett gott samarbete.</p>
|
||||||
|
<p>Logga in på din dashboard för att se dina uppgifter och hantera dina ärenden.</p>
|
||||||
|
`, customerName)
|
||||||
|
|
||||||
|
return c.SendEmail(
|
||||||
|
to,
|
||||||
|
"Välkommen!",
|
||||||
|
htmlBody,
|
||||||
|
fmt.Sprintf("Välkommen %s! Tack för att du valde oss.", customerName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPaymentReminder sends a payment reminder
|
||||||
|
func (c *Client) SendPaymentReminder(to []string, invoiceNumber string, amount float64, currency string, dueDate time.Time) error {
|
||||||
|
htmlBody := fmt.Sprintf(`
|
||||||
|
<h2>Påminnelse: Faktura %s</h2>
|
||||||
|
<p>Detta är en påminnelse om att faktura %s på <strong>%.2f %s</strong> förfaller %s.</p>
|
||||||
|
<p>Vänligen betala i tid för att undvika påminnelseavgifter.</p>
|
||||||
|
`, invoiceNumber, invoiceNumber, amount, currency, dueDate.Format("2006-01-02"))
|
||||||
|
|
||||||
|
return c.SendEmail(
|
||||||
|
to,
|
||||||
|
fmt.Sprintf("Påminnelse: Faktura %s", invoiceNumber),
|
||||||
|
htmlBody,
|
||||||
|
fmt.Sprintf("Påminnelse: Faktura %s på %.2f %s förfaller %s.", invoiceNumber, amount, currency, dueDate.Format("2006-01-02")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPasswordReset sends a password reset email
|
||||||
|
func (c *Client) SendPasswordReset(to []string, resetToken string, resetURL string) error {
|
||||||
|
htmlBody := fmt.Sprintf(`
|
||||||
|
<h2>Återställ ditt lösenord</h2>
|
||||||
|
<p>Du har begärt att återställa ditt lösenord.</p>
|
||||||
|
<p><a href="%s" style="background:#C96A3A;color:white;padding:12px 24px;text-decoration:none;border-radius:6px;">Återställ lösenord</a></p>
|
||||||
|
<p>Om du inte begärt detta, ignorera detta meddelande.</p>
|
||||||
|
`, resetURL+"?token="+resetToken)
|
||||||
|
|
||||||
|
return c.SendEmail(
|
||||||
|
to,
|
||||||
|
"Återställ ditt lösenord",
|
||||||
|
htmlBody,
|
||||||
|
"Klicka på länken för att återställa ditt lösenord: "+resetURL+"?token="+resetToken,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(email Email) error {
|
||||||
|
payload, err := json.Marshal(email)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", resendAPIURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
var errResp struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
||||||
|
return fmt.Errorf("resend API error (status %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("resend API error: %s (status %d)", errResp.Error, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/segmentio/kafka-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KafkaClient wraps kafka-go for BOC event streaming
|
||||||
|
type KafkaClient struct {
|
||||||
|
writer *kafka.Writer
|
||||||
|
reader *kafka.Reader
|
||||||
|
brokers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event represents a domain event
|
||||||
|
type Event struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event types
|
||||||
|
const (
|
||||||
|
EventCustomerCreated = "customer.created"
|
||||||
|
EventCustomerUpdated = "customer.updated"
|
||||||
|
EventCustomerDeleted = "customer.deleted"
|
||||||
|
EventDealCreated = "deal.created"
|
||||||
|
EventDealUpdated = "deal.updated"
|
||||||
|
EventDealClosed = "deal.closed"
|
||||||
|
EventInvoiceCreated = "invoice.created"
|
||||||
|
EventInvoicePaid = "invoice.paid"
|
||||||
|
EventTicketCreated = "ticket.created"
|
||||||
|
EventTicketResolved = "ticket.resolved"
|
||||||
|
EventEmployeeCreated = "employee.created"
|
||||||
|
EventContractRenewal = "contract.renewal_due"
|
||||||
|
EventWorkflowTriggered = "workflow.triggered"
|
||||||
|
EventReportGenerated = "report.generated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Topic names
|
||||||
|
const (
|
||||||
|
TopicBOCEvents = "boc.events"
|
||||||
|
TopicAuditLog = "boc.audit"
|
||||||
|
TopicAnalytics = "boc.analytics"
|
||||||
|
TopicNotifications = "boc.notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewKafkaClient creates a new Kafka client
|
||||||
|
func NewKafkaClient(brokers []string) (*KafkaClient, error) {
|
||||||
|
writer := &kafka.Writer{
|
||||||
|
Addr: kafka.TCP(brokers...),
|
||||||
|
Balancer: &kafka.LeastBytes{},
|
||||||
|
RequiredAcks: kafka.RequireAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
conn, err := kafka.Dial("tcp", brokers[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kafka connection failed: %w", err)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
return &KafkaClient{
|
||||||
|
writer: writer,
|
||||||
|
brokers: brokers,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the Kafka client
|
||||||
|
func (k *KafkaClient) Close() error {
|
||||||
|
return k.writer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish sends an event to Kafka
|
||||||
|
func (k *KafkaClient) Publish(ctx context.Context, topic string, event Event) error {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return k.writer.WriteMessages(ctx, kafka.Message{
|
||||||
|
Topic: topic,
|
||||||
|
Key: []byte(event.EntityID),
|
||||||
|
Value: data,
|
||||||
|
Time: event.Timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishAsync sends an event asynchronously
|
||||||
|
func (k *KafkaClient) PublishAsync(topic string, event Event) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := k.Publish(ctx, topic, event); err != nil {
|
||||||
|
fmt.Printf("Failed to publish event: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReader creates a new Kafka reader for a topic
|
||||||
|
func (k *KafkaClient) CreateReader(topic, groupID string) *kafka.Reader {
|
||||||
|
return kafka.NewReader(kafka.ReaderConfig{
|
||||||
|
Brokers: k.brokers,
|
||||||
|
Topic: topic,
|
||||||
|
GroupID: groupID,
|
||||||
|
MinBytes: 10e3, // 10KB
|
||||||
|
MaxBytes: 10e6, // 10MB
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEvent creates a new event with defaults
|
||||||
|
func CreateEvent(eventType, tenantID, entityType, entityID, action string, data map[string]interface{}) Event {
|
||||||
|
return Event{
|
||||||
|
ID: fmt.Sprintf("%d-%s", time.Now().UnixNano(), entityID),
|
||||||
|
Type: eventType,
|
||||||
|
TenantID: tenantID,
|
||||||
|
EntityID: entityID,
|
||||||
|
EntityType: entityType,
|
||||||
|
Action: action,
|
||||||
|
Data: data,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureTopics creates topics if they don't exist
|
||||||
|
func (k *KafkaClient) EnsureTopics() error {
|
||||||
|
conn, err := kafka.Dial("tcp", k.brokers[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
topics := []string{TopicBOCEvents, TopicAuditLog, TopicAnalytics, TopicNotifications}
|
||||||
|
|
||||||
|
for _, topic := range topics {
|
||||||
|
topicConfigs := []kafka.TopicConfig{
|
||||||
|
{
|
||||||
|
Topic: topic,
|
||||||
|
NumPartitions: 3,
|
||||||
|
ReplicationFactor: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.CreateTopics(topicConfigs...); err != nil {
|
||||||
|
// Topic might already exist, continue
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventConsumer handles consuming events from Kafka
|
||||||
|
type EventConsumer struct {
|
||||||
|
reader *kafka.Reader
|
||||||
|
handlers map[string]func(Event) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEventConsumer creates a new event consumer
|
||||||
|
func NewEventConsumer(brokers []string, topic, groupID string) *EventConsumer {
|
||||||
|
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||||
|
Brokers: brokers,
|
||||||
|
Topic: topic,
|
||||||
|
GroupID: groupID,
|
||||||
|
MinBytes: 10e3,
|
||||||
|
MaxBytes: 10e6,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &EventConsumer{
|
||||||
|
reader: reader,
|
||||||
|
handlers: make(map[string]func(Event) error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterHandler registers a handler for an event type
|
||||||
|
func (c *EventConsumer) RegisterHandler(eventType string, handler func(Event) error) {
|
||||||
|
c.handlers[eventType] = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins consuming events
|
||||||
|
func (c *EventConsumer) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
msg, err := c.reader.ReadMessage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return // Context cancelled
|
||||||
|
}
|
||||||
|
fmt.Printf("Error reading message: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var event Event
|
||||||
|
if err := json.Unmarshal(msg.Value, &event); err != nil {
|
||||||
|
fmt.Printf("Error unmarshaling event: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if handler, ok := c.handlers[event.Type]; ok {
|
||||||
|
if err := handler(event); err != nil {
|
||||||
|
fmt.Printf("Error handling event %s: %v\n", event.Type, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the consumer
|
||||||
|
func (c *EventConsumer) Close() error {
|
||||||
|
return c.reader.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
module boc
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
|
github.com/lib/pq v1.12.3
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3
|
||||||
|
github.com/rs/zerolog v1.35.1
|
||||||
|
github.com/segmentio/kafka-go v0.4.47
|
||||||
|
github.com/stretchr/testify v1.8.0
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||||
|
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||||
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
|
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||||
|
github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0=
|
||||||
|
github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
|
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnalyticsHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAnalyticsHandler(db *sql.DB) *AnalyticsHandler {
|
||||||
|
return &AnalyticsHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetActiveUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"dau": 42,
|
||||||
|
"mau": 380,
|
||||||
|
"trend": 0.05,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetRevenue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
period := r.URL.Query().Get("period")
|
||||||
|
if period == "" {
|
||||||
|
period = "month"
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"period": period,
|
||||||
|
"revenue": 125000.00,
|
||||||
|
"currency": "USD",
|
||||||
|
"trend": 0.08,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetRetention(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"retention_30d": 0.85,
|
||||||
|
"retention_90d": 0.72,
|
||||||
|
"retention_1y": 0.58,
|
||||||
|
"churn_rate": 0.02,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Aggregate all key metrics for dashboard
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"kpis": map[string]interface{}{
|
||||||
|
"mrr": map[string]interface{}{
|
||||||
|
"value": 53333.00,
|
||||||
|
"currency": "USD",
|
||||||
|
"trend": 0.05,
|
||||||
|
},
|
||||||
|
"arr": map[string]interface{}{
|
||||||
|
"value": 640000.00,
|
||||||
|
"currency": "USD",
|
||||||
|
"trend": 0.12,
|
||||||
|
},
|
||||||
|
"customers": map[string]interface{}{
|
||||||
|
"total": 42,
|
||||||
|
"active": 38,
|
||||||
|
"new": 5,
|
||||||
|
"churned": 1,
|
||||||
|
},
|
||||||
|
"pipeline": map[string]interface{}{
|
||||||
|
"total_value": 850000.00,
|
||||||
|
"weighted_value": 425000.00,
|
||||||
|
"deals": 24,
|
||||||
|
},
|
||||||
|
"tickets": map[string]interface{}{
|
||||||
|
"open": 12,
|
||||||
|
"resolved": 45,
|
||||||
|
"avg_resolution_hours": 24,
|
||||||
|
},
|
||||||
|
"cash": map[string]interface{}{
|
||||||
|
"on_hand": 180000.00,
|
||||||
|
"burn_rate": 45000.00,
|
||||||
|
"runway_months": 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"charts": map[string]interface{}{
|
||||||
|
"revenue_trend": []map[string]interface{}{
|
||||||
|
{"month": "Jan", "revenue": 95000},
|
||||||
|
{"month": "Feb", "revenue": 102000},
|
||||||
|
{"month": "Mar", "revenue": 110000},
|
||||||
|
{"month": "Apr", "revenue": 115000},
|
||||||
|
{"month": "May", "revenue": 120000},
|
||||||
|
{"month": "Jun", "revenue": 125000},
|
||||||
|
},
|
||||||
|
"pipeline_by_stage": []map[string]interface{}{
|
||||||
|
{"stage": "Prospect", "value": 200000, "count": 8},
|
||||||
|
{"stage": "Qualified", "value": 300000, "count": 6},
|
||||||
|
{"stage": "Proposal", "value": 250000, "count": 5},
|
||||||
|
{"stage": "Negotiation", "value": 100000, "count": 3},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"alerts": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"type": "warning",
|
||||||
|
"message": "Momsdeklaration deadline approaching",
|
||||||
|
"due_date": "2026-07-26",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "info",
|
||||||
|
"message": "3 contracts up for renewal",
|
||||||
|
"count": 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenExpiry = 24 * time.Hour
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
JWTSecret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Email == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "email and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
role string
|
||||||
|
passwordHash string
|
||||||
|
)
|
||||||
|
err := h.DB.QueryRowContext(r.Context(),
|
||||||
|
`SELECT id, name, role, password_hash FROM boc_users WHERE email = $1`,
|
||||||
|
req.Email,
|
||||||
|
).Scan(&id, &name, &role, &passwordHash)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: id,
|
||||||
|
Email: req.Email,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(tokenExpiry)),
|
||||||
|
Subject: id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := token.SignedString(h.JWTSecret)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not sign token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"token": signed,
|
||||||
|
"user": userResponse{
|
||||||
|
ID: id,
|
||||||
|
Email: req.Email,
|
||||||
|
Name: name,
|
||||||
|
Role: role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value("user").(*Claims)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": claims.UserID,
|
||||||
|
"email": claims.Email,
|
||||||
|
"role": claims.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"boc/automation"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AutomationHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Engine *automation.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAutomationHandler(db *sql.DB, engine *automation.Engine) *AutomationHandler {
|
||||||
|
return &AutomationHandler{DB: db, Engine: engine}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TriggerType string `json:"trigger_type"`
|
||||||
|
TriggerConfig map[string]interface{} `json:"trigger_config"`
|
||||||
|
Actions []map[string]interface{} `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduledJobRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CronExpr string `json:"cron_expr"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
JobType string `json:"job_type"`
|
||||||
|
JobConfig map[string]interface{} `json:"job_config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AutomationHandler) ListWorkflows(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, description, trigger_type, trigger_config, actions,
|
||||||
|
status, last_run_at, next_run_at, run_count, fail_count, created_at
|
||||||
|
FROM boc_workflows
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
workflows := []map[string]interface{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name, description, triggerType, status string
|
||||||
|
var triggerConfig, actions []byte
|
||||||
|
var lastRunAt, nextRunAt *time.Time
|
||||||
|
var runCount, failCount int
|
||||||
|
var createdAt time.Time
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &name, &description, &triggerType, &triggerConfig,
|
||||||
|
&actions, &status, &lastRunAt, &nextRunAt, &runCount, &failCount, &createdAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var tc, ac map[string]interface{}
|
||||||
|
json.Unmarshal(triggerConfig, &tc)
|
||||||
|
json.Unmarshal(actions, &ac)
|
||||||
|
|
||||||
|
workflows = append(workflows, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"trigger_type": triggerType,
|
||||||
|
"trigger_config": tc,
|
||||||
|
"actions": ac,
|
||||||
|
"status": status,
|
||||||
|
"last_run_at": lastRunAt,
|
||||||
|
"next_run_at": nextRunAt,
|
||||||
|
"run_count": runCount,
|
||||||
|
"fail_count": failCount,
|
||||||
|
"created_at": createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"workflows": workflows,
|
||||||
|
"total": len(workflows),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AutomationHandler) CreateWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req WorkflowRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerConfig, _ := json.Marshal(req.TriggerConfig)
|
||||||
|
actions, _ := json.Marshal(req.Actions)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_workflows (name, description, trigger_type, trigger_config, actions, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'active')
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Description, req.TriggerType, triggerConfig, actions).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create workflow")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Workflow created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AutomationHandler) TriggerWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var input map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
input = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Engine.TriggerWorkflow(r.Context(), id, input); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to trigger workflow")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Workflow triggered",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AutomationHandler) ListScheduledJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, description, cron_expr, timezone, job_type, job_config,
|
||||||
|
status, last_run_at, next_run_at, run_count, fail_count, created_at
|
||||||
|
FROM boc_scheduled_jobs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
jobs := []map[string]interface{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name, description, cronExpr, timezone, jobType, status string
|
||||||
|
var jobConfig []byte
|
||||||
|
var lastRunAt, nextRunAt *time.Time
|
||||||
|
var runCount, failCount int
|
||||||
|
var createdAt time.Time
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &name, &description, &cronExpr, &timezone, &jobType,
|
||||||
|
&jobConfig, &status, &lastRunAt, &nextRunAt, &runCount, &failCount, &createdAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var jc map[string]interface{}
|
||||||
|
json.Unmarshal(jobConfig, &jc)
|
||||||
|
|
||||||
|
jobs = append(jobs, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"cron_expr": cronExpr,
|
||||||
|
"timezone": timezone,
|
||||||
|
"job_type": jobType,
|
||||||
|
"job_config": jc,
|
||||||
|
"status": status,
|
||||||
|
"last_run_at": lastRunAt,
|
||||||
|
"next_run_at": nextRunAt,
|
||||||
|
"run_count": runCount,
|
||||||
|
"fail_count": failCount,
|
||||||
|
"created_at": createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"jobs": jobs,
|
||||||
|
"total": len(jobs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AutomationHandler) CreateScheduledJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ScheduledJobRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobConfig, _ := json.Marshal(req.JobConfig)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_scheduled_jobs (name, description, cron_expr, timezone, job_type, job_config, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'active')
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Description, req.CronExpr, req.Timezone, req.JobType, jobConfig).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create scheduled job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Scheduled job created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AutomationHandler) ListRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
workflowID := r.URL.Query().Get("workflow_id")
|
||||||
|
jobID := r.URL.Query().Get("job_id")
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if workflowID != "" {
|
||||||
|
rows, err = h.DB.Query(`
|
||||||
|
SELECT id, workflow_id, status, input, output, error, started_at, completed_at
|
||||||
|
FROM boc_workflow_runs
|
||||||
|
WHERE workflow_id = $1
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, workflowID)
|
||||||
|
} else if jobID != "" {
|
||||||
|
rows, err = h.DB.Query(`
|
||||||
|
SELECT id, job_id, status, output, error, started_at, completed_at
|
||||||
|
FROM boc_scheduled_job_runs
|
||||||
|
WHERE job_id = $1
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, jobID)
|
||||||
|
} else {
|
||||||
|
rows, err = h.DB.Query(`
|
||||||
|
SELECT id, workflow_id, status, input, output, error, started_at, completed_at
|
||||||
|
FROM boc_workflow_runs
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
runs := []map[string]interface{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, status string
|
||||||
|
var input, output, errorMsg []byte
|
||||||
|
var startedAt time.Time
|
||||||
|
var completedAt *time.Time
|
||||||
|
|
||||||
|
if workflowID != "" || (!rows.Next() && workflowID == "" && jobID == "") {
|
||||||
|
// Workflow run
|
||||||
|
var workflowID sql.NullString
|
||||||
|
if err := rows.Scan(&id, &workflowID, &status, &input, &output, &errorMsg, &startedAt, &completedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var inp, out map[string]interface{}
|
||||||
|
json.Unmarshal(input, &inp)
|
||||||
|
json.Unmarshal(output, &out)
|
||||||
|
runs = append(runs, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"workflow_id": workflowID.String,
|
||||||
|
"status": status,
|
||||||
|
"input": inp,
|
||||||
|
"output": out,
|
||||||
|
"error": string(errorMsg),
|
||||||
|
"started_at": startedAt,
|
||||||
|
"completed_at": completedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"runs": runs,
|
||||||
|
"total": len(runs),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BankHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBankHandler(db *sql.DB) *BankHandler {
|
||||||
|
return &BankHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BankAccount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BankName string `json:"bank_name"`
|
||||||
|
AccountNumber string `json:"account_number"`
|
||||||
|
IBAN string `json:"iban"`
|
||||||
|
BIC string `json:"bic"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Balance float64 `json:"balance"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastSync *time.Time `json:"last_sync"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BankTransaction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
TransactionDate time.Time `json:"transaction_date"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Counterparty string `json:"counterparty"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MatchedToType string `json:"matched_to_type"`
|
||||||
|
MatchedToID string `json:"matched_to_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BankHandler) ListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, bank_name, account_number, iban, bic, currency, balance, is_default, status, last_sync, created_at
|
||||||
|
FROM boc_bank_accounts WHERE status = 'active' ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
accounts := []BankAccount{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a BankAccount
|
||||||
|
if err := rows.Scan(&a.ID, &a.Name, &a.BankName, &a.AccountNumber, &a.IBAN, &a.BIC, &a.Currency, &a.Balance, &a.IsDefault, &a.Status, &a.LastSync, &a.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accounts = append(accounts, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"accounts": accounts,
|
||||||
|
"total": len(accounts),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BankHandler) CreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req BankAccount
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_bank_accounts (name, bank_name, account_number, iban, bic, currency, is_default)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.BankName, req.AccountNumber, req.IBAN, req.BIC, req.Currency, req.IsDefault).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Bank account created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BankHandler) ListTransactions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := r.URL.Query().Get("account_id")
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if accountID != "" {
|
||||||
|
if status != "" {
|
||||||
|
query = `SELECT id, account_id, transaction_date, amount, currency, description, counterparty, reference, external_id, status, matched_to_type, matched_to_id, created_at FROM boc_bank_transactions WHERE account_id = $1 AND status = $2 ORDER BY transaction_date DESC LIMIT 200`
|
||||||
|
args = append(args, accountID, status)
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, account_id, transaction_date, amount, currency, description, counterparty, reference, external_id, status, matched_to_type, matched_to_id, created_at FROM boc_bank_transactions WHERE account_id = $1 ORDER BY transaction_date DESC LIMIT 200`
|
||||||
|
args = append(args, accountID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, account_id, transaction_date, amount, currency, description, counterparty, reference, external_id, status, matched_to_type, matched_to_id, created_at FROM boc_bank_transactions ORDER BY transaction_date DESC LIMIT 200`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
transactions := []BankTransaction{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t BankTransaction
|
||||||
|
if err := rows.Scan(&t.ID, &t.AccountID, &t.TransactionDate, &t.Amount, &t.Currency, &t.Description, &t.Counterparty, &t.Reference, &t.ExternalID, &t.Status, &t.MatchedToType, &t.MatchedToID, &t.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
transactions = append(transactions, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"transactions": transactions,
|
||||||
|
"total": len(transactions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BankHandler) SyncTransactions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual bank API sync (PSD2/Open Banking)
|
||||||
|
// For now, simulate sync
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_bank_accounts SET last_sync = NOW() WHERE id = $1
|
||||||
|
`, req.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Sync completed",
|
||||||
|
"synced": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BankHandler) MatchTransaction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
transactionID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
MatchID string `json:"match_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_bank_transactions
|
||||||
|
SET status = 'matched', matched_to_type = $1, matched_to_id = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`, req.MatchType, req.MatchID, transactionID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to match transaction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Transaction matched",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CRMHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCRMHandler(db *sql.DB) *CRMHandler {
|
||||||
|
return &CRMHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Customer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
OrgNumber string `json:"org_number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
AssignedTo *string `json:"assigned_to"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerInteraction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
CreatedBy *string `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PipelineStage struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) ListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
||||||
|
FROM boc_customers
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`, status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
customers := []Customer{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Customer
|
||||||
|
if err := rows.Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.OrgNumber,
|
||||||
|
&c.Status, &c.Source, &c.Tags, &c.AssignedTo, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
customers = append(customers, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"customers": customers,
|
||||||
|
"total": len(customers),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Customer
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_customers (name, email, phone, company, org_number, status, source, tags)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Email, req.Phone, req.Company, req.OrgNumber, req.Status, req.Source, req.Tags).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create customer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Customer created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) GetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var c Customer
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
||||||
|
FROM boc_customers WHERE id = $1
|
||||||
|
`, id).Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.OrgNumber,
|
||||||
|
&c.Status, &c.Source, &c.Tags, &c.AssignedTo, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "customer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req Customer
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_customers
|
||||||
|
SET name = $1, email = $2, phone = $3, company = $4, org_number = $5,
|
||||||
|
status = $6, source = $7, tags = $8, assigned_to = $9
|
||||||
|
WHERE id = $10
|
||||||
|
`, req.Name, req.Email, req.Phone, req.Company, req.OrgNumber,
|
||||||
|
req.Status, req.Source, req.Tags, req.AssignedTo, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update customer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Customer updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`DELETE FROM boc_customers WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete customer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Customer deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
||||||
|
FROM boc_customers
|
||||||
|
WHERE status = 'lead'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
leads := []Customer{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Customer
|
||||||
|
if err := rows.Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.OrgNumber,
|
||||||
|
&c.Status, &c.Source, &c.Tags, &c.AssignedTo, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
leads = append(leads, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"leads": leads,
|
||||||
|
"total": len(leads),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) GetPipeline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT stage, COUNT(*), COALESCE(SUM(value), 0)
|
||||||
|
FROM boc_deals
|
||||||
|
WHERE status = 'open'
|
||||||
|
GROUP BY stage
|
||||||
|
ORDER BY
|
||||||
|
CASE stage
|
||||||
|
WHEN 'prospect' THEN 1
|
||||||
|
WHEN 'qualified' THEN 2
|
||||||
|
WHEN 'proposal' THEN 3
|
||||||
|
WHEN 'negotiation' THEN 4
|
||||||
|
WHEN 'closed_won' THEN 5
|
||||||
|
ELSE 6
|
||||||
|
END
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
stages := []PipelineStage{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s PipelineStage
|
||||||
|
if err := rows.Scan(&s.Stage, &s.Count, &s.Value); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stages = append(stages, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"pipeline": stages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) CreateInteraction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CustomerInteraction
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, _ := json.Marshal(req.Metadata)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_customer_interactions (customer_id, type, direction, subject, content, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id
|
||||||
|
`, req.CustomerID, req.Type, req.Direction, req.Subject, req.Content, metadata).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create interaction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Interaction created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CRMHandler) GetCustomerInteractions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
customerID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, customer_id, type, direction, subject, content, metadata, created_by, created_at
|
||||||
|
FROM boc_customer_interactions
|
||||||
|
WHERE customer_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, customerID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
interactions := []CustomerInteraction{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i CustomerInteraction
|
||||||
|
var metadata []byte
|
||||||
|
if err := rows.Scan(&i.ID, &i.CustomerID, &i.Type, &i.Direction, &i.Subject,
|
||||||
|
&i.Content, &metadata, &i.CreatedBy, &i.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
json.Unmarshal(metadata, &i.Metadata)
|
||||||
|
interactions = append(interactions, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"interactions": interactions,
|
||||||
|
"total": len(interactions),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"boc/email"
|
||||||
|
"boc/pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FinanceHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
EmailClient *email.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFinanceHandler(db *sql.DB) *FinanceHandler {
|
||||||
|
return &FinanceHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FinanceHandler) SetEmailClient(client *email.Client) {
|
||||||
|
h.EmailClient = client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invoice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
DueDate sql.NullString `json:"due_date"`
|
||||||
|
PaidAt sql.NullString `json:"paid_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Expense struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Vendor string `json:"vendor"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FinanceHandler) GetCashFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get paid invoices this month
|
||||||
|
var income float64
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(amount), 0)
|
||||||
|
FROM boc_invoices
|
||||||
|
WHERE status = 'paid'
|
||||||
|
AND paid_at >= NOW() - INTERVAL '1 month'
|
||||||
|
`).Scan(&income)
|
||||||
|
if err != nil {
|
||||||
|
income = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get outstanding invoices
|
||||||
|
var outstanding float64
|
||||||
|
err = h.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(amount), 0)
|
||||||
|
FROM boc_invoices
|
||||||
|
WHERE status = 'sent'
|
||||||
|
`).Scan(&outstanding)
|
||||||
|
if err != nil {
|
||||||
|
outstanding = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get expenses this month
|
||||||
|
var expenses float64
|
||||||
|
err = h.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(amount), 0)
|
||||||
|
FROM boc_expenses
|
||||||
|
WHERE status = 'approved'
|
||||||
|
AND created_at >= NOW() - INTERVAL '1 month'
|
||||||
|
`).Scan(&expenses)
|
||||||
|
if err != nil {
|
||||||
|
expenses = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"income_this_month": income,
|
||||||
|
"outstanding": outstanding,
|
||||||
|
"expenses": expenses,
|
||||||
|
"net_cashflow": income - expenses,
|
||||||
|
"currency": "USD",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FinanceHandler) GetBudget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT name, fiscal_year, category, amount, spent, currency
|
||||||
|
FROM boc_budgets
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY fiscal_year DESC, category
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
budgets := []map[string]interface{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var name, category, currency string
|
||||||
|
var fiscalYear int
|
||||||
|
var amount, spent float64
|
||||||
|
if err := rows.Scan(&name, &fiscalYear, &category, &amount, &spent, ¤cy); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
budgets = append(budgets, map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"fiscal_year": fiscalYear,
|
||||||
|
"category": category,
|
||||||
|
"amount": amount,
|
||||||
|
"spent": spent,
|
||||||
|
"remaining": amount - spent,
|
||||||
|
"currency": currency,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"budgets": budgets,
|
||||||
|
"total": len(budgets),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FinanceHandler) ListInvoices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `
|
||||||
|
SELECT id, customer_id, amount, currency, status, due_date, paid_at, created_at
|
||||||
|
FROM boc_invoices
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT id, customer_id, amount, currency, status, due_date, paid_at, created_at
|
||||||
|
FROM boc_invoices
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
invoices := []Invoice{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Invoice
|
||||||
|
if err := rows.Scan(&i.ID, &i.CustomerID, &i.Amount, &i.Currency, &i.Status, &i.DueDate, &i.PaidAt, &i.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
invoices = append(invoices, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"invoices": invoices,
|
||||||
|
"total": len(invoices),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FinanceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Expense
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_expenses (category, description, amount, currency, vendor, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||||
|
RETURNING id
|
||||||
|
`, req.Category, req.Description, req.Amount, req.Currency, req.Vendor).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create expense")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Expense created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FinanceHandler) ListExpenses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `
|
||||||
|
SELECT id, category, description, amount, currency, vendor, status, created_at
|
||||||
|
FROM boc_expenses
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT id, category, description, amount, currency, vendor, status, created_at
|
||||||
|
FROM boc_expenses
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
expenses := []Expense{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e Expense
|
||||||
|
if err := rows.Scan(&e.ID, &e.Category, &e.Description, &e.Amount, &e.Currency,
|
||||||
|
&e.Vendor, &e.Status, &e.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expenses = append(expenses, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"expenses": expenses,
|
||||||
|
"total": len(expenses),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateInvoicePDF generates a PDF for an invoice
|
||||||
|
func (h *FinanceHandler) GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
invoiceID := r.URL.Query().Get("id")
|
||||||
|
if invoiceID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invoice id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerID, currency, status string
|
||||||
|
var amount float64
|
||||||
|
var dueDate sql.NullString
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT customer_id, amount, currency, status, due_date
|
||||||
|
FROM boc_invoices WHERE id = $1
|
||||||
|
`, invoiceID).Scan(&customerID, &amount, ¤cy, &status, &dueDate)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "invoice not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerName, customerAddress, customerOrgNr string
|
||||||
|
h.DB.QueryRow(`
|
||||||
|
SELECT name, address, org_nr FROM boc_customers WHERE id = $1
|
||||||
|
`, customerID).Scan(&customerName, &customerAddress, &customerOrgNr)
|
||||||
|
|
||||||
|
data := pdf.InvoiceData{
|
||||||
|
InvoiceNumber: invoiceID[:8],
|
||||||
|
InvoiceDate: time.Now(),
|
||||||
|
DueDate: time.Now().AddDate(0, 0, 30),
|
||||||
|
CustomerName: customerName,
|
||||||
|
CustomerAddress: customerAddress,
|
||||||
|
CustomerOrgNr: customerOrgNr,
|
||||||
|
Items: []pdf.InvoiceItem{
|
||||||
|
{
|
||||||
|
Description: "Tjänst",
|
||||||
|
Quantity: 1,
|
||||||
|
Unit: "st",
|
||||||
|
UnitPrice: amount,
|
||||||
|
Total: amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Subtotal: amount,
|
||||||
|
VATRate: 0.25,
|
||||||
|
VATAmount: amount * 0.25,
|
||||||
|
Total: amount * 1.25,
|
||||||
|
Currency: currency,
|
||||||
|
CompanyName: "Landvex Inc",
|
||||||
|
CompanyAddress: "Houston, TX",
|
||||||
|
CompanyOrgNr: "559141-7042",
|
||||||
|
CompanyBankgiro: "1234-5678",
|
||||||
|
Notes: fmt.Sprintf("Status: %s | Betalningsvillkor: 30 dagar", status),
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := pdf.GenerateInvoice(data)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"faktura-%s.pdf\"", invoiceID[:8]))
|
||||||
|
w.Write(pdfBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendInvoiceEmail sends an invoice via email with PDF attachment
|
||||||
|
func (h *FinanceHandler) SendInvoiceEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.EmailClient == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "email not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
InvoiceID string `json:"invoice_id"`
|
||||||
|
To []string `json:"to"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PDF first
|
||||||
|
var customerID, currency, status string
|
||||||
|
var amount float64
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT customer_id, amount, currency, status, due_date
|
||||||
|
FROM boc_invoices WHERE id = $1
|
||||||
|
`, req.InvoiceID).Scan(&customerID, &amount, ¤cy, &status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "invoice not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerName, customerAddress, customerOrgNr string
|
||||||
|
h.DB.QueryRow(`
|
||||||
|
SELECT name, address, org_nr FROM boc_customers WHERE id = $1
|
||||||
|
`, customerID).Scan(&customerName, &customerAddress, &customerOrgNr)
|
||||||
|
|
||||||
|
data := pdf.InvoiceData{
|
||||||
|
InvoiceNumber: req.InvoiceID[:8],
|
||||||
|
InvoiceDate: time.Now(),
|
||||||
|
DueDate: time.Now().AddDate(0, 0, 30),
|
||||||
|
CustomerName: customerName,
|
||||||
|
CustomerAddress: customerAddress,
|
||||||
|
CustomerOrgNr: customerOrgNr,
|
||||||
|
Items: []pdf.InvoiceItem{
|
||||||
|
{
|
||||||
|
Description: "Tjänst",
|
||||||
|
Quantity: 1,
|
||||||
|
Unit: "st",
|
||||||
|
UnitPrice: amount,
|
||||||
|
Total: amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Subtotal: amount,
|
||||||
|
VATRate: 0.25,
|
||||||
|
VATAmount: amount * 0.25,
|
||||||
|
Total: amount * 1.25,
|
||||||
|
Currency: currency,
|
||||||
|
CompanyName: "Landvex Inc",
|
||||||
|
CompanyAddress: "Houston, TX",
|
||||||
|
CompanyOrgNr: "559141-7042",
|
||||||
|
CompanyBankgiro: "1234-5678",
|
||||||
|
Notes: fmt.Sprintf("Status: %s", status),
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := pdf.GenerateInvoice(data)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.EmailClient.SendInvoice(req.To, req.InvoiceID[:8], pdfBytes, "")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("email failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Invoice sent",
|
||||||
|
"to": req.To,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockDB is a simple mock for testing
|
||||||
|
type MockDB struct{}
|
||||||
|
|
||||||
|
func TestHealthHandler(t *testing.T) {
|
||||||
|
handler := NewHealthHandler()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, true, response["ok"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteJSON(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
data := map[string]string{"key": "value"}
|
||||||
|
|
||||||
|
writeJSON(rr, http.StatusOK, data)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
var response map[string]string
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "value", response["key"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteError(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
writeError(rr, http.StatusBadRequest, "test error")
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "test error", response["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCRMHandler_CreateCustomer(t *testing.T) {
|
||||||
|
// This would need a real or mocked DB connection
|
||||||
|
// For now, just test the request parsing
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": "Test Customer",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"phone": "+46701234567",
|
||||||
|
"company": "Test AB",
|
||||||
|
"status": "lead",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/crm/customers", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Without DB, this will fail, but we test the request structure
|
||||||
|
assert.NotNil(t, req)
|
||||||
|
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteHandler_CreateQuote(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"customer_id": "test-customer-id",
|
||||||
|
"title": "Test Quote",
|
||||||
|
"description": "Test description",
|
||||||
|
"valid_until": "2026-12-31",
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"description": "Item 1",
|
||||||
|
"quantity": 2,
|
||||||
|
"unit_price": 100.00,
|
||||||
|
"tax_rate": 25.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/sales/quotes", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
assert.NotNil(t, req)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err := json.Unmarshal(body, &parsed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Test Quote", parsed["title"])
|
||||||
|
|
||||||
|
items := parsed["items"].([]interface{})
|
||||||
|
assert.Len(t, items, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionHandler_CreateSubscription(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"customer_id": "test-customer",
|
||||||
|
"plan_id": "test-plan",
|
||||||
|
"start_date": "2026-07-12",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/subscriptions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
assert.NotNil(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBankHandler_MatchTransaction(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"match_type": "invoice",
|
||||||
|
"match_id": "inv-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/bank/transactions/tx-123/match", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
assert.NotNil(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectHandler_AddTime(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"employee_id": "emp-1",
|
||||||
|
"date": "2026-07-12",
|
||||||
|
"hours": 8.0,
|
||||||
|
"description": "Development work",
|
||||||
|
"billable": true,
|
||||||
|
"hourly_rate": 150.00,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/proj-1/time", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
assert.NotNil(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiptHandler_UploadReceipt(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"employee_id": "emp-1",
|
||||||
|
"image_url": "https://example.com/receipt.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
assert.NotNil(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayrollHandler_ProcessPayroll(t *testing.T) {
|
||||||
|
// Test that the endpoint exists and accepts POST
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post("/api/v1/payroll/runs/{id}/process", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Payroll processed"})
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/payroll/runs/run-1/process", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Payroll processed", response["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInventoryHandler_AdjustStock(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"product_id": "prod-1",
|
||||||
|
"warehouse_id": "wh-1",
|
||||||
|
"quantity": 100.0,
|
||||||
|
"reason": "Initial stock",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/inventory/adjust", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
assert.NotNil(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark tests
|
||||||
|
func BenchmarkWriteJSON(b *testing.B) {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"id": "test-id",
|
||||||
|
"name": "Test",
|
||||||
|
"amount": 1000.00,
|
||||||
|
"items": []string{"a", "b", "c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
writeJSON(rr, http.StatusOK, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkQuoteCalculation(b *testing.B) {
|
||||||
|
items := []struct {
|
||||||
|
Quantity float64
|
||||||
|
UnitPrice float64
|
||||||
|
TaxRate float64
|
||||||
|
Discount float64
|
||||||
|
}{
|
||||||
|
{2, 100, 25, 0},
|
||||||
|
{5, 50, 25, 10},
|
||||||
|
{1, 200, 25, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
var total float64
|
||||||
|
for _, item := range items {
|
||||||
|
itemTotal := item.Quantity * item.UnitPrice * (1 - item.Discount/100)
|
||||||
|
itemTax := itemTotal * (item.TaxRate / 100)
|
||||||
|
total += itemTotal + itemTax
|
||||||
|
}
|
||||||
|
_ = total
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var startTime = time.Now()
|
||||||
|
|
||||||
|
func NewHealthHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"service": "boc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"uptime": time.Since(startTime).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HRHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHRHandler(db *sql.DB) *HRHandler {
|
||||||
|
return &HRHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Employee struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Department string `json:"department"`
|
||||||
|
Position string `json:"position"`
|
||||||
|
EmploymentType string `json:"employment_type"`
|
||||||
|
Salary float64 `json:"salary"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
StartDate *time.Time `json:"start_date"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leave struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EmployeeID string `json:"employee_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
StartDate time.Time `json:"start_date"`
|
||||||
|
EndDate time.Time `json:"end_date"`
|
||||||
|
Days float64 `json:"days"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ApprovedBy *string `json:"approved_by"`
|
||||||
|
ApprovedAt *time.Time `json:"approved_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Timesheet struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EmployeeID string `json:"employee_id"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Hours float64 `json:"hours"`
|
||||||
|
Project string `json:"project"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) ListEmployees(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, first_name, last_name, email, phone, department, position,
|
||||||
|
employment_type, salary, currency, start_date, status, created_at
|
||||||
|
FROM boc_employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
employees := []Employee{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e Employee
|
||||||
|
if err := rows.Scan(&e.ID, &e.FirstName, &e.LastName, &e.Email, &e.Phone,
|
||||||
|
&e.Department, &e.Position, &e.EmploymentType, &e.Salary, &e.Currency,
|
||||||
|
&e.StartDate, &e.Status, &e.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
employees = append(employees, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"employees": employees,
|
||||||
|
"total": len(employees),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) CreateEmployee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Employee
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_employees (first_name, last_name, email, phone, department, position,
|
||||||
|
employment_type, salary, currency, start_date, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'active')
|
||||||
|
RETURNING id
|
||||||
|
`, req.FirstName, req.LastName, req.Email, req.Phone, req.Department, req.Position,
|
||||||
|
req.EmploymentType, req.Salary, req.Currency, req.StartDate).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create employee")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Employee created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) GetEmployee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var e Employee
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, first_name, last_name, email, phone, department, position,
|
||||||
|
employment_type, salary, currency, start_date, status, created_at
|
||||||
|
FROM boc_employees WHERE id = $1
|
||||||
|
`, id).Scan(&e.ID, &e.FirstName, &e.LastName, &e.Email, &e.Phone,
|
||||||
|
&e.Department, &e.Position, &e.EmploymentType, &e.Salary, &e.Currency,
|
||||||
|
&e.StartDate, &e.Status, &e.CreatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "employee not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) UpdateEmployee(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req Employee
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_employees
|
||||||
|
SET first_name = $1, last_name = $2, email = $3, phone = $4,
|
||||||
|
department = $5, position = $6, employment_type = $7,
|
||||||
|
salary = $8, currency = $9, start_date = $10, status = $11
|
||||||
|
WHERE id = $12
|
||||||
|
`, req.FirstName, req.LastName, req.Email, req.Phone, req.Department,
|
||||||
|
req.Position, req.EmploymentType, req.Salary, req.Currency,
|
||||||
|
req.StartDate, req.Status, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update employee")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Employee updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) ListLeaves(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, employee_id, type, start_date, end_date, days, status, approved_by, approved_at
|
||||||
|
FROM boc_leaves
|
||||||
|
ORDER BY start_date DESC
|
||||||
|
LIMIT 100
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
leaves := []Leave{}
|
||||||
|
for rows.Next() {
|
||||||
|
var l Leave
|
||||||
|
if err := rows.Scan(&l.ID, &l.EmployeeID, &l.Type, &l.StartDate, &l.EndDate,
|
||||||
|
&l.Days, &l.Status, &l.ApprovedBy, &l.ApprovedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
leaves = append(leaves, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"leaves": leaves,
|
||||||
|
"total": len(leaves),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) CreateLeave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Leave
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_leaves (employee_id, type, start_date, end_date, days, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||||
|
RETURNING id
|
||||||
|
`, req.EmployeeID, req.Type, req.StartDate, req.EndDate, req.Days).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create leave")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Leave request created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) ListTimesheets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, employee_id, date, hours, project, task, description, status
|
||||||
|
FROM boc_timesheets
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 100
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
timesheets := []Timesheet{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t Timesheet
|
||||||
|
if err := rows.Scan(&t.ID, &t.EmployeeID, &t.Date, &t.Hours, &t.Project,
|
||||||
|
&t.Task, &t.Description, &t.Status); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timesheets = append(timesheets, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"timesheets": timesheets,
|
||||||
|
"total": len(timesheets),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HRHandler) CreateTimesheet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Timesheet
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_timesheets (employee_id, date, hours, project, task, description, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'draft')
|
||||||
|
RETURNING id
|
||||||
|
`, req.EmployeeID, req.Date, req.Hours, req.Project, req.Task, req.Description).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create timesheet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Timesheet created",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InventoryHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInventoryHandler(db *sql.DB) *InventoryHandler {
|
||||||
|
return &InventoryHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Warehouse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Address map[string]interface{} `json:"address"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
WarehouseID string `json:"warehouse_id"`
|
||||||
|
WarehouseName string `json:"warehouse_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
ReservedQty float64 `json:"reserved_qty"`
|
||||||
|
AvailableQty float64 `json:"available_qty"`
|
||||||
|
ReorderPoint float64 `json:"reorder_point"`
|
||||||
|
ReorderQty float64 `json:"reorder_qty"`
|
||||||
|
UnitCost float64 `json:"unit_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryMovement struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
WarehouseID string `json:"warehouse_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
ReferenceType string `json:"reference_type"`
|
||||||
|
ReferenceID string `json:"reference_id"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InventoryHandler) ListWarehouses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, location, address, is_default, created_at
|
||||||
|
FROM boc_warehouses ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
warehouses := []Warehouse{}
|
||||||
|
for rows.Next() {
|
||||||
|
var w Warehouse
|
||||||
|
var addr []byte
|
||||||
|
if err := rows.Scan(&w.ID, &w.Name, &w.Location, &addr, &w.IsDefault, &w.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
json.Unmarshal(addr, &w.Address)
|
||||||
|
warehouses = append(warehouses, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"warehouses": warehouses,
|
||||||
|
"total": len(warehouses),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InventoryHandler) CreateWarehouse(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Warehouse
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, _ := json.Marshal(req.Address)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_warehouses (name, location, address, is_default)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Location, addr, req.IsDefault).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create warehouse")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Warehouse created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InventoryHandler) ListInventory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
warehouseID := r.URL.Query().Get("warehouse_id")
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if warehouseID != "" {
|
||||||
|
query = `
|
||||||
|
SELECT i.id, i.product_id, p.name, i.warehouse_id, w.name, i.quantity, i.reserved_qty, i.reorder_point, i.reorder_qty, i.unit_cost
|
||||||
|
FROM boc_inventory i
|
||||||
|
JOIN boc_products p ON i.product_id = p.id
|
||||||
|
JOIN boc_warehouses w ON i.warehouse_id = w.id
|
||||||
|
WHERE i.warehouse_id = $1
|
||||||
|
ORDER BY p.name
|
||||||
|
`
|
||||||
|
args = append(args, warehouseID)
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT i.id, i.product_id, p.name, i.warehouse_id, w.name, i.quantity, i.reserved_qty, i.reorder_point, i.reorder_qty, i.unit_cost
|
||||||
|
FROM boc_inventory i
|
||||||
|
JOIN boc_products p ON i.product_id = p.id
|
||||||
|
JOIN boc_warehouses w ON i.warehouse_id = w.id
|
||||||
|
ORDER BY p.name
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []InventoryItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i InventoryItem
|
||||||
|
if err := rows.Scan(&i.ID, &i.ProductID, &i.ProductName, &i.WarehouseID, &i.WarehouseName, &i.Quantity, &i.ReservedQty, &i.ReorderPoint, &i.ReorderQty, &i.UnitCost); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i.AvailableQty = i.Quantity - i.ReservedQty
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"inventory": items,
|
||||||
|
"total": len(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InventoryHandler) AdjustStock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
WarehouseID string `json:"warehouse_id"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Update or insert inventory
|
||||||
|
var existingID string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
SELECT id FROM boc_inventory WHERE product_id = $1 AND warehouse_id = $2
|
||||||
|
`, req.ProductID, req.WarehouseID).Scan(&existingID)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Insert new
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO boc_inventory (product_id, warehouse_id, quantity)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`, req.ProductID, req.WarehouseID, req.Quantity)
|
||||||
|
} else if err == nil {
|
||||||
|
// Update existing
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE boc_inventory SET quantity = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`, req.Quantity, existingID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update inventory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record movement
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO boc_inventory_movements (product_id, warehouse_id, type, quantity, notes)
|
||||||
|
VALUES ($1, $2, 'adjustment', $3, $4)
|
||||||
|
`, req.ProductID, req.WarehouseID, req.Quantity, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to record movement")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Stock adjusted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InventoryHandler) ListMovements(w http.ResponseWriter, r *http.Request) {
|
||||||
|
productID := r.URL.Query().Get("product_id")
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if productID != "" {
|
||||||
|
query = `
|
||||||
|
SELECT m.id, m.product_id, p.name, m.warehouse_id, m.type, m.quantity, m.reference_type, m.reference_id, m.notes, m.created_at
|
||||||
|
FROM boc_inventory_movements m
|
||||||
|
JOIN boc_products p ON m.product_id = p.id
|
||||||
|
WHERE m.product_id = $1
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
args = append(args, productID)
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT m.id, m.product_id, p.name, m.warehouse_id, m.type, m.quantity, m.reference_type, m.reference_id, m.notes, m.created_at
|
||||||
|
FROM boc_inventory_movements m
|
||||||
|
JOIN boc_products p ON m.product_id = p.id
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
movements := []InventoryMovement{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m InventoryMovement
|
||||||
|
if err := rows.Scan(&m.ID, &m.ProductID, &m.ProductName, &m.WarehouseID, &m.Type, &m.Quantity, &m.ReferenceType, &m.ReferenceID, &m.Notes, &m.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
movements = append(movements, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"movements": movements,
|
||||||
|
"total": len(movements),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InventoryHandler) GetLowStock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT i.id, i.product_id, p.name, i.warehouse_id, w.name, i.quantity, i.reserved_qty, i.reorder_point, i.reorder_qty
|
||||||
|
FROM boc_inventory i
|
||||||
|
JOIN boc_products p ON i.product_id = p.id
|
||||||
|
JOIN boc_warehouses w ON i.warehouse_id = w.id
|
||||||
|
WHERE i.quantity <= i.reorder_point
|
||||||
|
ORDER BY (i.quantity / NULLIF(i.reorder_point, 0))
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []InventoryItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i InventoryItem
|
||||||
|
if err := rows.Scan(&i.ID, &i.ProductID, &i.ProductName, &i.WarehouseID, &i.WarehouseName, &i.Quantity, &i.ReservedQty, &i.ReorderPoint, &i.ReorderQty); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i.AvailableQty = i.Quantity - i.ReservedQty
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"low_stock": items,
|
||||||
|
"total": len(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ledgerBaseURL = getEnv("LEDGER_URL", "http://localhost:3250")
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerClient handles communication with aamos-ledger
|
||||||
|
type LedgerClient struct {
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLedgerClient() *LedgerClient {
|
||||||
|
return &LedgerClient{BaseURL: ledgerBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LedgerClient) Get(path string) (*http.Response, error) {
|
||||||
|
return http.Get(c.BaseURL + path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerFinanceHandler connects to aamos-ledger for financial data
|
||||||
|
type LedgerFinanceHandler struct {
|
||||||
|
Client *LedgerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLedgerFinanceHandler() *LedgerFinanceHandler {
|
||||||
|
return &LedgerFinanceHandler{Client: NewLedgerClient()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LedgerFinanceHandler) GetBalanceSheet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.Client.Get("/api/ledger/reports/balance")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
writeError(w, resp.StatusCode, "ledger error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "decode error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LedgerFinanceHandler) GetIncomeStatement(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.Client.Get("/api/ledger/reports/income")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
writeError(w, resp.StatusCode, "ledger error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "decode error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LedgerFinanceHandler) GetMomsReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.Client.Get("/api/ledger/tax/moms")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
writeError(w, resp.StatusCode, "ledger error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "decode error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LedgerFinanceHandler) GetAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.Client.Get("/api/ledger/accounts")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
writeError(w, resp.StatusCode, "ledger error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "decode error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LedgerFinanceHandler) GetCustomers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.Client.Get("/api/ledger/customers")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
writeError(w, resp.StatusCode, "ledger error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "decode error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LedgerFinanceHandler) GetInvoices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.Client.Get("/api/ledger/invoices")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "ledger unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
writeError(w, resp.StatusCode, "ledger error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "decode error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LegalHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLegalHandler(db *sql.DB) *LegalHandler {
|
||||||
|
return &LegalHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Contract struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Counterparty string `json:"counterparty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
StartDate *time.Time `json:"start_date"`
|
||||||
|
EndDate *time.Time `json:"end_date"`
|
||||||
|
RenewalDate *time.Time `json:"renewal_date"`
|
||||||
|
DocumentURL string `json:"document_url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContractReminder struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ContractID string `json:"contract_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DueDate time.Time `json:"due_date"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LegalHandler) ListContracts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, title, counterparty, type, status, value, currency,
|
||||||
|
start_date, end_date, renewal_date, document_url, created_at
|
||||||
|
FROM boc_contracts
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY renewal_date ASC NULLS LAST
|
||||||
|
`, status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
contracts := []Contract{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Contract
|
||||||
|
if err := rows.Scan(&c.ID, &c.Title, &c.Counterparty, &c.Type, &c.Status,
|
||||||
|
&c.Value, &c.Currency, &c.StartDate, &c.EndDate, &c.RenewalDate,
|
||||||
|
&c.DocumentURL, &c.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contracts = append(contracts, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"contracts": contracts,
|
||||||
|
"total": len(contracts),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LegalHandler) CreateContract(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Contract
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_contracts (title, counterparty, type, status, value, currency,
|
||||||
|
start_date, end_date, renewal_date, document_url)
|
||||||
|
VALUES ($1, $2, $3, 'draft', $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Title, req.Counterparty, req.Type, req.Value, req.Currency,
|
||||||
|
req.StartDate, req.EndDate, req.RenewalDate, req.DocumentURL).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create contract")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reminder if renewal date is set
|
||||||
|
if req.RenewalDate != nil {
|
||||||
|
reminderDate := req.RenewalDate.AddDate(0, 0, -30) // 30 days before
|
||||||
|
h.DB.Exec(`
|
||||||
|
INSERT INTO boc_contract_reminders (contract_id, type, due_date, status)
|
||||||
|
VALUES ($1, 'renewal', $2, 'pending')
|
||||||
|
`, id, reminderDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Contract created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LegalHandler) GetContract(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var c Contract
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, title, counterparty, type, status, value, currency,
|
||||||
|
start_date, end_date, renewal_date, document_url, created_at
|
||||||
|
FROM boc_contracts WHERE id = $1
|
||||||
|
`, id).Scan(&c.ID, &c.Title, &c.Counterparty, &c.Type, &c.Status,
|
||||||
|
&c.Value, &c.Currency, &c.StartDate, &c.EndDate, &c.RenewalDate,
|
||||||
|
&c.DocumentURL, &c.CreatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "contract not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LegalHandler) UpdateContract(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req Contract
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_contracts
|
||||||
|
SET title = $1, counterparty = $2, type = $3, status = $4,
|
||||||
|
value = $5, currency = $6, start_date = $7, end_date = $8,
|
||||||
|
renewal_date = $9, document_url = $10
|
||||||
|
WHERE id = $11
|
||||||
|
`, req.Title, req.Counterparty, req.Type, req.Status, req.Value, req.Currency,
|
||||||
|
req.StartDate, req.EndDate, req.RenewalDate, req.DocumentURL, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update contract")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Contract updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LegalHandler) ListReminders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT r.id, r.contract_id, r.type, r.due_date, r.status,
|
||||||
|
c.title as contract_title
|
||||||
|
FROM boc_contract_reminders r
|
||||||
|
JOIN boc_contracts c ON r.contract_id = c.id
|
||||||
|
WHERE r.status = 'pending'
|
||||||
|
ORDER BY r.due_date ASC
|
||||||
|
LIMIT 50
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
reminders := []map[string]interface{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, contractID, reminderType, status, contractTitle string
|
||||||
|
var dueDate time.Time
|
||||||
|
if err := rows.Scan(&id, &contractID, &reminderType, &dueDate, &status, &contractTitle); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reminders = append(reminders, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"contract_id": contractID,
|
||||||
|
"contract_title": contractTitle,
|
||||||
|
"type": reminderType,
|
||||||
|
"due_date": dueDate,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"reminders": reminders,
|
||||||
|
"total": len(reminders),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MarketingHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMarketingHandler(db *sql.DB) *MarketingHandler {
|
||||||
|
return &MarketingHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Campaign struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Budget float64 `json:"budget"`
|
||||||
|
Spent float64 `json:"spent"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
StartDate *time.Time `json:"start_date"`
|
||||||
|
EndDate *time.Time `json:"end_date"`
|
||||||
|
Metrics map[string]interface{} `json:"metrics"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Content struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CampaignID *string `json:"campaign_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PublishAt *time.Time `json:"publish_at"`
|
||||||
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Metrics map[string]interface{} `json:"metrics"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MarketingHandler) ListCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, description, type, status, budget, spent, currency, start_date, end_date, metrics, created_at
|
||||||
|
FROM boc_campaigns
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
campaigns := []Campaign{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Campaign
|
||||||
|
var metrics []byte
|
||||||
|
if err := rows.Scan(&c.ID, &c.Name, &c.Description, &c.Type, &c.Status, &c.Budget,
|
||||||
|
&c.Spent, &c.Currency, &c.StartDate, &c.EndDate, &metrics, &c.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
json.Unmarshal(metrics, &c.Metrics)
|
||||||
|
campaigns = append(campaigns, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"campaigns": campaigns,
|
||||||
|
"total": len(campaigns),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MarketingHandler) CreateCampaign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Campaign
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics, _ := json.Marshal(req.Metrics)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_campaigns (name, description, type, status, budget, currency, start_date, end_date, metrics)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Description, req.Type, req.Status, req.Budget, req.Currency,
|
||||||
|
req.StartDate, req.EndDate, metrics).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create campaign")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Campaign created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MarketingHandler) ListContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `
|
||||||
|
SELECT id, campaign_id, title, type, status, publish_at, published_at, url, metrics, created_at
|
||||||
|
FROM boc_content
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT id, campaign_id, title, type, status, publish_at, published_at, url, metrics, created_at
|
||||||
|
FROM boc_content
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
contents := []Content{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Content
|
||||||
|
var metrics []byte
|
||||||
|
if err := rows.Scan(&c.ID, &c.CampaignID, &c.Title, &c.Type, &c.Status, &c.PublishAt,
|
||||||
|
&c.PublishedAt, &c.URL, &metrics, &c.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
json.Unmarshal(metrics, &c.Metrics)
|
||||||
|
contents = append(contents, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"content": contents,
|
||||||
|
"total": len(contents),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MarketingHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Content
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics, _ := json.Marshal(req.Metrics)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_content (campaign_id, title, type, status, publish_at, url, metrics)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, req.CampaignID, req.Title, req.Type, req.Status, req.PublishAt, req.URL, metrics).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create content")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Content created",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderHandler(db *sql.DB) *OrderHandler {
|
||||||
|
return &OrderHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
QuoteID *string `json:"quote_id"`
|
||||||
|
OrderNumber string `json:"order_number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
TaxAmount float64 `json:"tax_amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
DeliveryDate *time.Time `json:"delivery_date"`
|
||||||
|
ShippedAt *time.Time `json:"shipped_at"`
|
||||||
|
DeliveredAt *time.Time `json:"delivered_at"`
|
||||||
|
TrackingNumber string `json:"tracking_number"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) ListOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `SELECT id, customer_id, quote_id, order_number, title, status, amount, currency, delivery_date, shipped_at, delivered_at, tracking_number, created_at FROM boc_orders ORDER BY created_at DESC LIMIT 100`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, customer_id, quote_id, order_number, title, status, amount, currency, delivery_date, shipped_at, delivered_at, tracking_number, created_at FROM boc_orders WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
orders := []Order{}
|
||||||
|
for rows.Next() {
|
||||||
|
var o Order
|
||||||
|
if err := rows.Scan(&o.ID, &o.CustomerID, &o.QuoteID, &o.OrderNumber, &o.Title, &o.Status, &o.Amount, &o.Currency, &o.DeliveryDate, &o.ShippedAt, &o.DeliveredAt, &o.TrackingNumber, &o.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orders = append(orders, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"orders": orders,
|
||||||
|
"total": len(orders),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DeliveryDate *time.Time `json:"delivery_date"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Items []struct {
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
TaxRate float64 `json:"tax_rate"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNumber := "O-" + time.Now().Format("20060102-150405")
|
||||||
|
|
||||||
|
var totalAmount, totalTax float64
|
||||||
|
for _, item := range req.Items {
|
||||||
|
itemTotal := item.Quantity * item.UnitPrice
|
||||||
|
itemTax := itemTotal * (item.TaxRate / 100)
|
||||||
|
totalAmount += itemTotal
|
||||||
|
totalTax += itemTax
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
INSERT INTO boc_orders (customer_id, order_number, title, amount, tax_amount, currency, delivery_date, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'USD', $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, req.CustomerID, orderNumber, req.Title, totalAmount, totalTax, req.DeliveryDate, req.Notes).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range req.Items {
|
||||||
|
itemTotal := item.Quantity * item.UnitPrice
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO boc_order_items (order_id, product_id, description, quantity, unit_price, tax_rate, total)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, id, item.ProductID, item.Description, item.Quantity, item.UnitPrice, item.TaxRate, itemTotal)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create order items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"number": orderNumber,
|
||||||
|
"message": "Order created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var o Order
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, customer_id, quote_id, order_number, title, status, amount, tax_amount, currency, delivery_date, shipped_at, delivered_at, tracking_number, notes, created_at
|
||||||
|
FROM boc_orders WHERE id = $1
|
||||||
|
`, id).Scan(&o.ID, &o.CustomerID, &o.QuoteID, &o.OrderNumber, &o.Title, &o.Status, &o.Amount, &o.TaxAmount, &o.Currency, &o.DeliveryDate, &o.ShippedAt, &o.DeliveredAt, &o.TrackingNumber, &o.Notes, &o.CreatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "order not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) UpdateOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req Order
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_orders
|
||||||
|
SET status = $1, delivery_date = $2, tracking_number = $3, notes = $4
|
||||||
|
WHERE id = $5
|
||||||
|
`, req.Status, req.DeliveryDate, req.TrackingNumber, req.Notes, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Order updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) ShipOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
TrackingNumber string `json:"tracking_number"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_orders SET status = 'shipped', shipped_at = NOW(), tracking_number = $1 WHERE id = $2
|
||||||
|
`, req.TrackingNumber, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to ship order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Order shipped",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) DeliverOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_orders SET status = 'delivered', delivered_at = NOW() WHERE id = $1
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to deliver order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Order delivered",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PayrollHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPayrollHandler(db *sql.DB) *PayrollHandler {
|
||||||
|
return &PayrollHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PayrollRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PeriodStart time.Time `json:"period_start"`
|
||||||
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
|
PayDate time.Time `json:"pay_date"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TotalGross float64 `json:"total_gross"`
|
||||||
|
TotalTax float64 `json:"total_tax"`
|
||||||
|
TotalNet float64 `json:"total_net"`
|
||||||
|
TotalEmployerTax float64 `json:"total_employer_tax"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PayrollLine struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EmployeeID string `json:"employee_id"`
|
||||||
|
EmployeeName string `json:"employee_name"`
|
||||||
|
GrossSalary float64 `json:"gross_salary"`
|
||||||
|
TaxDeduction float64 `json:"tax_deduction"`
|
||||||
|
SocialFees float64 `json:"social_fees"`
|
||||||
|
Pension float64 `json:"pension"`
|
||||||
|
OtherDeductions float64 `json:"other_deductions"`
|
||||||
|
NetSalary float64 `json:"net_salary"`
|
||||||
|
HoursWorked float64 `json:"hours_worked"`
|
||||||
|
VacationDaysUsed float64 `json:"vacation_days_used"`
|
||||||
|
SickDays float64 `json:"sick_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PayrollHandler) ListPayrollRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, period_start, period_end, pay_date, status, total_gross, total_tax, total_net, total_employer_tax, currency, created_at
|
||||||
|
FROM boc_payroll_runs ORDER BY period_start DESC LIMIT 50
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
runs := []PayrollRun{}
|
||||||
|
for rows.Next() {
|
||||||
|
var pr PayrollRun
|
||||||
|
if err := rows.Scan(&pr.ID, &pr.PeriodStart, &pr.PeriodEnd, &pr.PayDate, &pr.Status, &pr.TotalGross, &pr.TotalTax, &pr.TotalNet, &pr.TotalEmployerTax, &pr.Currency, &pr.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runs = append(runs, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"payroll_runs": runs,
|
||||||
|
"total": len(runs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PayrollHandler) CreatePayrollRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
PeriodStart time.Time `json:"period_start"`
|
||||||
|
PeriodEnd time.Time `json:"period_end"`
|
||||||
|
PayDate time.Time `json:"pay_date"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_payroll_runs (period_start, period_end, pay_date, status)
|
||||||
|
VALUES ($1, $2, $3, 'draft')
|
||||||
|
RETURNING id
|
||||||
|
`, req.PeriodStart, req.PeriodEnd, req.PayDate).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create payroll run")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Payroll run created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PayrollHandler) GetPayrollRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var pr PayrollRun
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, period_start, period_end, pay_date, status, total_gross, total_tax, total_net, total_employer_tax, currency, created_at
|
||||||
|
FROM boc_payroll_runs WHERE id = $1
|
||||||
|
`, id).Scan(&pr.ID, &pr.PeriodStart, &pr.PeriodEnd, &pr.PayDate, &pr.Status, &pr.TotalGross, &pr.TotalTax, &pr.TotalNet, &pr.TotalEmployerTax, &pr.Currency, &pr.CreatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "payroll run not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT pl.id, pl.employee_id, e.first_name || ' ' || e.last_name, pl.gross_salary, pl.tax_deduction, pl.social_fees, pl.pension, pl.other_deductions, pl.net_salary, pl.hours_worked, pl.vacation_days_used, pl.sick_days
|
||||||
|
FROM boc_payroll_lines pl
|
||||||
|
JOIN boc_employees e ON pl.employee_id = e.id
|
||||||
|
WHERE pl.payroll_run_id = $1
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
lines := []PayrollLine{}
|
||||||
|
for rows.Next() {
|
||||||
|
var l PayrollLine
|
||||||
|
if err := rows.Scan(&l.ID, &l.EmployeeID, &l.EmployeeName, &l.GrossSalary, &l.TaxDeduction, &l.SocialFees, &l.Pension, &l.OtherDeductions, &l.NetSalary, &l.HoursWorked, &l.VacationDaysUsed, &l.SickDays); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"payroll_run": pr,
|
||||||
|
"lines": lines,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PayrollHandler) ProcessPayroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Get all active employees
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, salary, employment_type FROM boc_employees WHERE status = 'active'
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var totalGross, totalTax, totalNet, totalEmployerTax float64
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var empID string
|
||||||
|
var salary float64
|
||||||
|
var empType string
|
||||||
|
if err := rows.Scan(&empID, &salary, &empType); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple Swedish tax calculation (placeholder)
|
||||||
|
gross := salary
|
||||||
|
tax := gross * 0.30 // 30% income tax
|
||||||
|
socialFees := gross * 0.3142 // 31.42% employer tax
|
||||||
|
pension := gross * 0.045 // 4.5% pension
|
||||||
|
net := gross - tax - pension
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO boc_payroll_lines (payroll_run_id, employee_id, gross_salary, tax_deduction, social_fees, pension, other_deductions, net_salary, hours_worked)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 160)
|
||||||
|
`, id, empID, gross, tax, socialFees, pension, 0, net)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create payroll line")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalGross += gross
|
||||||
|
totalTax += tax
|
||||||
|
totalNet += net
|
||||||
|
totalEmployerTax += socialFees
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE boc_payroll_runs
|
||||||
|
SET status = 'processing', total_gross = $1, total_tax = $2, total_net = $3, total_employer_tax = $4
|
||||||
|
WHERE id = $5
|
||||||
|
`, totalGross, totalTax, totalNet, totalEmployerTax, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update payroll run")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Payroll processed",
|
||||||
|
"summary": map[string]interface{}{
|
||||||
|
"total_gross": totalGross,
|
||||||
|
"total_tax": totalTax,
|
||||||
|
"total_net": totalNet,
|
||||||
|
"total_employer_tax": totalEmployerTax,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PayrollHandler) ApprovePayroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_payroll_runs SET status = 'approved' WHERE id = $1
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to approve payroll")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Payroll approved",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjectHandler(db *sql.DB) *ProjectHandler {
|
||||||
|
return &ProjectHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CustomerID *string `json:"customer_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Budget float64 `json:"budget"`
|
||||||
|
Spent float64 `json:"spent"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
StartDate *time.Time `json:"start_date"`
|
||||||
|
EndDate *time.Time `json:"end_date"`
|
||||||
|
ManagerID *string `json:"manager_id"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectTime struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
EmployeeID string `json:"employee_id"`
|
||||||
|
EmployeeName string `json:"employee_name"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Hours float64 `json:"hours"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Billable bool `json:"billable"`
|
||||||
|
HourlyRate float64 `json:"hourly_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `SELECT id, name, description, customer_id, status, budget, spent, currency, start_date, end_date, manager_id, created_at FROM boc_projects ORDER BY created_at DESC LIMIT 100`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, name, description, customer_id, status, budget, spent, currency, start_date, end_date, manager_id, created_at FROM boc_projects WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
projects := []Project{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Project
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CustomerID, &p.Status, &p.Budget, &p.Spent, &p.Currency, &p.StartDate, &p.EndDate, &p.ManagerID, &p.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Budget > 0 {
|
||||||
|
p.Progress = (p.Spent / p.Budget) * 100
|
||||||
|
}
|
||||||
|
projects = append(projects, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"projects": projects,
|
||||||
|
"total": len(projects),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectHandler) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Project
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_projects (name, description, customer_id, status, budget, currency, start_date, end_date, manager_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Description, req.CustomerID, req.Status, req.Budget, req.Currency, req.StartDate, req.EndDate, req.ManagerID).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Project created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectHandler) GetProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var p Project
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, name, description, customer_id, status, budget, spent, currency, start_date, end_date, manager_id, created_at
|
||||||
|
FROM boc_projects WHERE id = $1
|
||||||
|
`, id).Scan(&p.ID, &p.Name, &p.Description, &p.CustomerID, &p.Status, &p.Budget, &p.Spent, &p.Currency, &p.StartDate, &p.EndDate, &p.ManagerID, &p.CreatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "project not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Budget > 0 {
|
||||||
|
p.Progress = (p.Spent / p.Budget) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get time entries
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT pt.id, pt.project_id, pt.employee_id, e.first_name || ' ' || e.last_name, pt.date, pt.hours, pt.description, pt.billable, pt.hourly_rate
|
||||||
|
FROM boc_project_times pt
|
||||||
|
JOIN boc_employees e ON pt.employee_id = e.id
|
||||||
|
WHERE pt.project_id = $1
|
||||||
|
ORDER BY pt.date DESC
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
times := []ProjectTime{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t ProjectTime
|
||||||
|
if err := rows.Scan(&t.ID, &t.ProjectID, &t.EmployeeID, &t.EmployeeName, &t.Date, &t.Hours, &t.Description, &t.Billable, &t.HourlyRate); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
times = append(times, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get expenses
|
||||||
|
expenseRows, err := h.DB.Query(`
|
||||||
|
SELECT e.id, e.category, e.description, e.amount, e.created_at
|
||||||
|
FROM boc_project_expenses pe
|
||||||
|
JOIN boc_expenses e ON pe.expense_id = e.id
|
||||||
|
WHERE pe.project_id = $1
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer expenseRows.Close()
|
||||||
|
|
||||||
|
expenses := []map[string]interface{}{}
|
||||||
|
for expenseRows.Next() {
|
||||||
|
var eID, category, description string
|
||||||
|
var amount float64
|
||||||
|
var createdAt time.Time
|
||||||
|
if err := expenseRows.Scan(&eID, &category, &description, &amount, &createdAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expenses = append(expenses, map[string]interface{}{
|
||||||
|
"id": eID,
|
||||||
|
"category": category,
|
||||||
|
"description": description,
|
||||||
|
"amount": amount,
|
||||||
|
"created_at": createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"project": p,
|
||||||
|
"times": times,
|
||||||
|
"expenses": expenses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectHandler) AddTime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req ProjectTime
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_project_times (project_id, employee_id, date, hours, description, billable, hourly_rate)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, projectID, req.EmployeeID, req.Date, req.Hours, req.Description, req.Billable, req.HourlyRate).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to add time")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project spent
|
||||||
|
h.DB.Exec(`
|
||||||
|
UPDATE boc_projects SET spent = (
|
||||||
|
SELECT COALESCE(SUM(pt.hours * pt.hourly_rate), 0) + COALESCE(SUM(pe.amount), 0)
|
||||||
|
FROM boc_project_times pt
|
||||||
|
LEFT JOIN boc_project_expenses pe ON pe.project_id = pt.project_id
|
||||||
|
WHERE pt.project_id = $1
|
||||||
|
) WHERE id = $1
|
||||||
|
`, projectID)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Time entry added",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProjectHandler) GetProjectSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Summary across all projects
|
||||||
|
var totalBudget, totalSpent float64
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(budget), 0), COALESCE(SUM(spent), 0)
|
||||||
|
FROM boc_projects WHERE status = 'active'
|
||||||
|
`).Scan(&totalBudget, &totalSpent)
|
||||||
|
if err != nil {
|
||||||
|
totalBudget, totalSpent = 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalHours float64
|
||||||
|
err = h.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(hours), 0)
|
||||||
|
FROM boc_project_times pt
|
||||||
|
JOIN boc_projects p ON pt.project_id = p.id
|
||||||
|
WHERE p.status = 'active'
|
||||||
|
`).Scan(&totalHours)
|
||||||
|
if err != nil {
|
||||||
|
totalHours = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"total_budget": totalBudget,
|
||||||
|
"total_spent": totalSpent,
|
||||||
|
"remaining": totalBudget - totalSpent,
|
||||||
|
"utilization": map[string]interface{}{
|
||||||
|
"percentage": map[bool]float64{true: (totalSpent / totalBudget) * 100, false: 0}[totalBudget > 0],
|
||||||
|
},
|
||||||
|
"total_hours": totalHours,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"boc/email"
|
||||||
|
"boc/pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QuoteHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
EmailClient *email.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuoteHandler(db *sql.DB) *QuoteHandler {
|
||||||
|
return &QuoteHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) SetEmailClient(client *email.Client) {
|
||||||
|
h.EmailClient = client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Quote struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
QuoteNumber string `json:"quote_number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
TaxAmount float64 `json:"tax_amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
ValidUntil *time.Time `json:"valid_until"`
|
||||||
|
AcceptedAt *time.Time `json:"accepted_at"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Terms string `json:"terms"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuoteItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
QuoteID string `json:"quote_id"`
|
||||||
|
ProductID *string `json:"product_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
TaxRate float64 `json:"tax_rate"`
|
||||||
|
Discount float64 `json:"discount"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) ListQuotes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `SELECT id, customer_id, quote_number, title, status, amount, currency, valid_until, created_at FROM boc_quotes ORDER BY created_at DESC LIMIT 100`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, customer_id, quote_number, title, status, amount, currency, valid_until, created_at FROM boc_quotes WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
quotes := []Quote{}
|
||||||
|
for rows.Next() {
|
||||||
|
var q Quote
|
||||||
|
if err := rows.Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Status, &q.Amount, &q.Currency, &q.ValidUntil, &q.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
quotes = append(quotes, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"quotes": quotes,
|
||||||
|
"total": len(quotes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) CreateQuote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ValidUntil *time.Time `json:"valid_until"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Terms string `json:"terms"`
|
||||||
|
Items []QuoteItem `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate quote number
|
||||||
|
quoteNumber := fmt.Sprintf("Q-%d", time.Now().Unix())
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
var totalAmount, totalTax float64
|
||||||
|
for _, item := range req.Items {
|
||||||
|
itemTotal := item.Quantity * item.UnitPrice * (1 - item.Discount/100)
|
||||||
|
itemTax := itemTotal * (item.TaxRate / 100)
|
||||||
|
totalAmount += itemTotal
|
||||||
|
totalTax += itemTax
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
INSERT INTO boc_quotes (customer_id, quote_number, title, description, amount, tax_amount, currency, valid_until, notes, terms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'USD', $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`, req.CustomerID, quoteNumber, req.Title, req.Description, totalAmount, totalTax, req.ValidUntil, req.Notes, req.Terms).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create quote")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert items
|
||||||
|
for _, item := range req.Items {
|
||||||
|
itemTotal := item.Quantity * item.UnitPrice * (1 - item.Discount/100)
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO boc_quote_items (quote_id, product_id, description, quantity, unit_price, tax_rate, discount, total)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
`, id, item.ProductID, item.Description, item.Quantity, item.UnitPrice, item.TaxRate, item.Discount, itemTotal)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create quote items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"number": quoteNumber,
|
||||||
|
"message": "Quote created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) GetQuote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var q Quote
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, customer_id, quote_number, title, description, status, amount, tax_amount, currency, valid_until, accepted_at, notes, terms, created_at
|
||||||
|
FROM boc_quotes WHERE id = $1
|
||||||
|
`, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Description, &q.Status, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.AcceptedAt, &q.Notes, &q.Terms, &q.CreatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "quote not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, product_id, description, quantity, unit_price, tax_rate, discount, total
|
||||||
|
FROM boc_quote_items WHERE quote_id = $1 ORDER BY sort_order
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []QuoteItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i QuoteItem
|
||||||
|
if err := rows.Scan(&i.ID, &i.ProductID, &i.Description, &i.Quantity, &i.UnitPrice, &i.TaxRate, &i.Discount, &i.Total); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"quote": q,
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) AcceptQuote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_quotes SET status = 'accepted', accepted_at = NOW() WHERE id = $1
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to accept quote")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Quote accepted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *QuoteHandler) ConvertToOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Get quote details
|
||||||
|
var customerID string
|
||||||
|
var amount, taxAmount float64
|
||||||
|
err = tx.QueryRow(`SELECT customer_id, amount, tax_amount FROM boc_quotes WHERE id = $1`, id).Scan(&customerID, &amount, &taxAmount)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "quote not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
orderNumber := fmt.Sprintf("O-%d", time.Now().Unix())
|
||||||
|
var orderID string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
INSERT INTO boc_orders (customer_id, quote_id, order_number, title, amount, tax_amount, currency, status)
|
||||||
|
SELECT customer_id, id, $2, title, amount, tax_amount, currency, 'confirmed'
|
||||||
|
FROM boc_quotes WHERE id = $1
|
||||||
|
RETURNING id
|
||||||
|
`, id, orderNumber).Scan(&orderID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy quote items to order items
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO boc_order_items (order_id, product_id, description, quantity, unit_price, tax_rate, discount, total)
|
||||||
|
SELECT $1, product_id, description, quantity, unit_price, tax_rate, discount, total
|
||||||
|
FROM boc_quote_items WHERE quote_id = $2
|
||||||
|
`, orderID, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to copy items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update quote
|
||||||
|
_, err = tx.Exec(`UPDATE boc_quotes SET status = 'converted', converted_to_order_id = $1 WHERE id = $2`, orderID, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update quote")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"order_id": orderID,
|
||||||
|
"number": orderNumber,
|
||||||
|
"message": "Quote converted to order",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQuotePDF generates a PDF for a quote
|
||||||
|
func (h *QuoteHandler) GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var q Quote
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, customer_id, quote_number, title, description, status, amount, tax_amount, currency, valid_until, accepted_at, notes, terms, created_at
|
||||||
|
FROM boc_quotes WHERE id = $1
|
||||||
|
`, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Description, &q.Status, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.AcceptedAt, &q.Notes, &q.Terms, &q.CreatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "quote not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerName, customerAddress string
|
||||||
|
h.DB.QueryRow(`SELECT name, address FROM boc_customers WHERE id = $1`, q.CustomerID).Scan(&customerName, &customerAddress)
|
||||||
|
|
||||||
|
validUntil := time.Now().AddDate(0, 0, 30)
|
||||||
|
if q.ValidUntil != nil {
|
||||||
|
validUntil = *q.ValidUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []pdf.QuoteItem{
|
||||||
|
{
|
||||||
|
Description: q.Title,
|
||||||
|
Quantity: 1,
|
||||||
|
Unit: "st",
|
||||||
|
UnitPrice: q.Amount,
|
||||||
|
Total: q.Amount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pdf.QuoteData{
|
||||||
|
QuoteNumber: q.QuoteNumber,
|
||||||
|
QuoteDate: q.CreatedAt,
|
||||||
|
ValidUntil: validUntil,
|
||||||
|
CustomerName: customerName,
|
||||||
|
CustomerAddress: customerAddress,
|
||||||
|
Items: items,
|
||||||
|
Subtotal: q.Amount,
|
||||||
|
VATRate: 0.25,
|
||||||
|
VATAmount: q.TaxAmount,
|
||||||
|
Total: q.Amount + q.TaxAmount,
|
||||||
|
Currency: q.Currency,
|
||||||
|
CompanyName: "Landvex Inc",
|
||||||
|
CompanyAddress: "Houston, TX",
|
||||||
|
Notes: q.Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := pdf.GenerateQuote(data)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"offert-%s.pdf\"", q.QuoteNumber))
|
||||||
|
w.Write(pdfBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendQuoteEmail sends a quote via email with PDF attachment
|
||||||
|
func (h *QuoteHandler) SendQuoteEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.EmailClient == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "email not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
To []string `json:"to"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var q Quote
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, customer_id, quote_number, title, amount, tax_amount, currency, valid_until, created_at
|
||||||
|
FROM boc_quotes WHERE id = $1
|
||||||
|
`, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "quote not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerName, customerAddress string
|
||||||
|
h.DB.QueryRow(`SELECT name, address FROM boc_customers WHERE id = $1`, q.CustomerID).Scan(&customerName, &customerAddress)
|
||||||
|
|
||||||
|
validUntil := time.Now().AddDate(0, 0, 30)
|
||||||
|
if q.ValidUntil != nil {
|
||||||
|
validUntil = *q.ValidUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pdf.QuoteData{
|
||||||
|
QuoteNumber: q.QuoteNumber,
|
||||||
|
QuoteDate: q.CreatedAt,
|
||||||
|
ValidUntil: validUntil,
|
||||||
|
CustomerName: customerName,
|
||||||
|
CustomerAddress: customerAddress,
|
||||||
|
Items: []pdf.QuoteItem{
|
||||||
|
{
|
||||||
|
Description: q.Title,
|
||||||
|
Quantity: 1,
|
||||||
|
Unit: "st",
|
||||||
|
UnitPrice: q.Amount,
|
||||||
|
Total: q.Amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Subtotal: q.Amount,
|
||||||
|
VATRate: 0.25,
|
||||||
|
VATAmount: q.TaxAmount,
|
||||||
|
Total: q.Amount + q.TaxAmount,
|
||||||
|
Currency: q.Currency,
|
||||||
|
CompanyName: "Landvex Inc",
|
||||||
|
CompanyAddress: "Houston, TX",
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := pdf.GenerateQuote(data)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.EmailClient.SendQuote(req.To, q.QuoteNumber, pdfBytes, "")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("email failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Quote sent",
|
||||||
|
"to": req.To,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReceiptHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReceiptHandler(db *sql.DB) *ReceiptHandler {
|
||||||
|
return &ReceiptHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Receipt struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EmployeeID string `json:"employee_id"`
|
||||||
|
ExpenseID *string `json:"expense_id"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
OCRText string `json:"ocr_text"`
|
||||||
|
OCRData map[string]interface{} `json:"ocr_data"`
|
||||||
|
OCRConfidence float64 `json:"ocr_confidence"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ProcessedAt *time.Time `json:"processed_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ReceiptHandler) ListReceipts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `SELECT id, employee_id, expense_id, image_url, ocr_text, ocr_data, ocr_confidence, status, processed_at, created_at FROM boc_receipts ORDER BY created_at DESC LIMIT 100`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, employee_id, expense_id, image_url, ocr_text, ocr_data, ocr_confidence, status, processed_at, created_at FROM boc_receipts WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
receipts := []Receipt{}
|
||||||
|
for rows.Next() {
|
||||||
|
var rc Receipt
|
||||||
|
var ocrData []byte
|
||||||
|
if err := rows.Scan(&rc.ID, &rc.EmployeeID, &rc.ExpenseID, &rc.ImageURL, &rc.OCRText, &ocrData, &rc.OCRConfidence, &rc.Status, &rc.ProcessedAt, &rc.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
json.Unmarshal(ocrData, &rc.OCRData)
|
||||||
|
receipts = append(receipts, rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"receipts": receipts,
|
||||||
|
"total": len(receipts),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ReceiptHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
EmployeeID string `json:"employee_id"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_receipts (employee_id, image_url, status)
|
||||||
|
VALUES ($1, $2, 'pending')
|
||||||
|
RETURNING id
|
||||||
|
`, req.EmployeeID, req.ImageURL).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to upload receipt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Trigger async OCR processing
|
||||||
|
go h.processOCR(id, req.ImageURL)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Receipt uploaded, OCR processing started",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ReceiptHandler) processOCR(receiptID, imageURL string) {
|
||||||
|
// Placeholder for OCR processing
|
||||||
|
// In production, this would call an OCR service (AWS Textract, Google Vision, etc.)
|
||||||
|
|
||||||
|
// Simulate OCR processing
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
ocrData := map[string]interface{}{
|
||||||
|
"amount": 125.50,
|
||||||
|
"date": time.Now().Format("2006-01-02"),
|
||||||
|
"vendor": "Example Store",
|
||||||
|
"category": "Mat",
|
||||||
|
}
|
||||||
|
ocrJSON, _ := json.Marshal(ocrData)
|
||||||
|
|
||||||
|
h.DB.Exec(`
|
||||||
|
UPDATE boc_receipts
|
||||||
|
SET ocr_text = $1, ocr_data = $2, ocr_confidence = $3, status = 'processed', processed_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
`, "Example Store\nDate: 2026-07-12\nTotal: $125.50", ocrJSON, 0.95, receiptID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ReceiptHandler) ApproveReceipt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ReceiptID string `json:"receipt_id"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Create expense from receipt
|
||||||
|
var expenseID string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
INSERT INTO boc_expenses (category, description, amount, currency, status, receipt_url)
|
||||||
|
VALUES ($1, $2, $3, 'USD', 'pending', (SELECT image_url FROM boc_receipts WHERE id = $4))
|
||||||
|
RETURNING id
|
||||||
|
`, req.Category, req.Description, req.Amount, req.ReceiptID).Scan(&expenseID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create expense")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link receipt to expense
|
||||||
|
_, err = tx.Exec(`UPDATE boc_receipts SET expense_id = $1, status = 'approved' WHERE id = $2`, expenseID, req.ReceiptID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update receipt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"expense_id": expenseID,
|
||||||
|
"message": "Receipt approved and expense created",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SalesHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSalesHandler(db *sql.DB) *SalesHandler {
|
||||||
|
return &SalesHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deal struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
ContactID *string `json:"contact_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Probability int `json:"probability"`
|
||||||
|
ExpectedClose *time.Time `json:"expected_close"`
|
||||||
|
ActualClose *time.Time `json:"actual_close"`
|
||||||
|
AssignedTo *string `json:"assigned_to"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Product struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SKU string `json:"sku"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
IsRecurring bool `json:"is_recurring"`
|
||||||
|
BillingPeriod string `json:"billing_period"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) ListDeals(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "open"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, customer_id, contact_id, name, description, value, currency, status, stage, probability, expected_close, actual_close, assigned_to, created_at, updated_at
|
||||||
|
FROM boc_deals
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`, status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
deals := []Deal{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d Deal
|
||||||
|
if err := rows.Scan(&d.ID, &d.CustomerID, &d.ContactID, &d.Name, &d.Description, &d.Value,
|
||||||
|
&d.Currency, &d.Status, &d.Stage, &d.Probability, &d.ExpectedClose, &d.ActualClose,
|
||||||
|
&d.AssignedTo, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deals = append(deals, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"deals": deals,
|
||||||
|
"total": len(deals),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) CreateDeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Deal
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_deals (customer_id, contact_id, name, description, value, currency, status, stage, probability, expected_close)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id
|
||||||
|
`, req.CustomerID, req.ContactID, req.Name, req.Description, req.Value, req.Currency,
|
||||||
|
req.Status, req.Stage, req.Probability, req.ExpectedClose).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create deal")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Deal created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) GetDeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var d Deal
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, customer_id, contact_id, name, description, value, currency, status, stage, probability, expected_close, actual_close, assigned_to, created_at, updated_at
|
||||||
|
FROM boc_deals WHERE id = $1
|
||||||
|
`, id).Scan(&d.ID, &d.CustomerID, &d.ContactID, &d.Name, &d.Description, &d.Value,
|
||||||
|
&d.Currency, &d.Status, &d.Stage, &d.Probability, &d.ExpectedClose, &d.ActualClose,
|
||||||
|
&d.AssignedTo, &d.CreatedAt, &d.UpdatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "deal not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) UpdateDeal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req Deal
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_deals
|
||||||
|
SET customer_id = $1, contact_id = $2, name = $3, description = $4, value = $5,
|
||||||
|
currency = $6, status = $7, stage = $8, probability = $9, expected_close = $10,
|
||||||
|
actual_close = $11, assigned_to = $12
|
||||||
|
WHERE id = $13
|
||||||
|
`, req.CustomerID, req.ContactID, req.Name, req.Description, req.Value, req.Currency,
|
||||||
|
req.Status, req.Stage, req.Probability, req.ExpectedClose, req.ActualClose,
|
||||||
|
req.AssignedTo, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update deal")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Deal updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) GetMRR(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var mrr float64
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(value), 0)
|
||||||
|
FROM boc_deals
|
||||||
|
WHERE status = 'closed_won'
|
||||||
|
AND created_at >= NOW() - INTERVAL '1 month'
|
||||||
|
`).Scan(&mrr)
|
||||||
|
if err != nil {
|
||||||
|
mrr = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"mrr": mrr,
|
||||||
|
"currency": "USD",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) GetARR(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var arr float64
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(value), 0)
|
||||||
|
FROM boc_deals
|
||||||
|
WHERE status = 'closed_won'
|
||||||
|
AND created_at >= NOW() - INTERVAL '1 year'
|
||||||
|
`).Scan(&arr)
|
||||||
|
if err != nil {
|
||||||
|
arr = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"arr": arr,
|
||||||
|
"currency": "USD",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) ListProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, description, sku, price, currency, unit, is_recurring, billing_period, status
|
||||||
|
FROM boc_products
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
products := []Product{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Product
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.SKU, &p.Price, &p.Currency,
|
||||||
|
&p.Unit, &p.IsRecurring, &p.BillingPeriod, &p.Status); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
products = append(products, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"products": products,
|
||||||
|
"total": len(products),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SalesHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Product
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_products (name, description, sku, price, currency, unit, is_recurring, billing_period, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Description, req.SKU, req.Price, req.Currency, req.Unit,
|
||||||
|
req.IsRecurring, req.BillingPeriod).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create product")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Product created",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SubscriptionHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSubscriptionHandler(db *sql.DB) *SubscriptionHandler {
|
||||||
|
return &SubscriptionHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionPlan struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ProductID *string `json:"product_id"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
IntervalCount int `json:"interval_count"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
TrialDays int `json:"trial_days"`
|
||||||
|
SetupFee float64 `json:"setup_fee"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscription struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
PlanID string `json:"plan_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartDate time.Time `json:"start_date"`
|
||||||
|
EndDate *time.Time `json:"end_date"`
|
||||||
|
TrialEnd *time.Time `json:"trial_end"`
|
||||||
|
CurrentPeriodStart *time.Time `json:"current_period_start"`
|
||||||
|
CurrentPeriodEnd *time.Time `json:"current_period_end"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) ListPlans(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, description, product_id, interval, interval_count, price, currency, trial_days, setup_fee, status, created_at
|
||||||
|
FROM boc_subscription_plans WHERE status = 'active' ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
plans := []SubscriptionPlan{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p SubscriptionPlan
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.ProductID, &p.Interval, &p.IntervalCount, &p.Price, &p.Currency, &p.TrialDays, &p.SetupFee, &p.Status, &p.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plans = append(plans, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"plans": plans,
|
||||||
|
"total": len(plans),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) CreatePlan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SubscriptionPlan
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_subscription_plans (name, description, product_id, interval, interval_count, price, currency, trial_days, setup_fee)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Description, req.ProductID, req.Interval, req.IntervalCount, req.Price, req.Currency, req.TrialDays, req.SetupFee).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create plan")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Subscription plan created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `SELECT id, customer_id, plan_id, status, start_date, end_date, trial_end, current_period_start, current_period_end, price, currency, created_at FROM boc_subscriptions ORDER BY created_at DESC LIMIT 100`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, customer_id, plan_id, status, start_date, end_date, trial_end, current_period_start, current_period_end, price, currency, created_at FROM boc_subscriptions WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
subs := []Subscription{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s Subscription
|
||||||
|
if err := rows.Scan(&s.ID, &s.CustomerID, &s.PlanID, &s.Status, &s.StartDate, &s.EndDate, &s.TrialEnd, &s.CurrentPeriodStart, &s.CurrentPeriodEnd, &s.Price, &s.Currency, &s.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"subscriptions": subs,
|
||||||
|
"total": len(subs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) CreateSubscription(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
PlanID string `json:"plan_id"`
|
||||||
|
StartDate time.Time `json:"start_date"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get plan details
|
||||||
|
var planPrice float64
|
||||||
|
var planCurrency string
|
||||||
|
var trialDays int
|
||||||
|
err := h.DB.QueryRow(`SELECT price, currency, trial_days FROM boc_subscription_plans WHERE id = $1`, req.PlanID).Scan(&planPrice, &planCurrency, &trialDays)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "plan not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dates
|
||||||
|
var trialEnd, periodStart, periodEnd *time.Time
|
||||||
|
start := req.StartDate
|
||||||
|
periodStart = &start
|
||||||
|
|
||||||
|
if trialDays > 0 {
|
||||||
|
t := start.AddDate(0, 0, trialDays)
|
||||||
|
trialEnd = &t
|
||||||
|
periodStart = trialEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
pe := periodStart.AddDate(0, 1, 0) // Monthly default
|
||||||
|
periodEnd = &pe
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err = h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_subscriptions (customer_id, plan_id, status, start_date, end_date, trial_end, current_period_start, current_period_end, price, currency)
|
||||||
|
VALUES ($1, $2, 'active', $3, NULL, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id
|
||||||
|
`, req.CustomerID, req.PlanID, start, trialEnd, periodStart, periodEnd, planPrice, planCurrency).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create subscription")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Subscription created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) GenerateRecurringInvoices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Find subscriptions with period ending soon
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT s.id, s.customer_id, s.plan_id, s.price, s.currency, s.current_period_end
|
||||||
|
FROM boc_subscriptions s
|
||||||
|
WHERE s.status = 'active'
|
||||||
|
AND s.current_period_end <= NOW() + INTERVAL '7 days'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM boc_recurring_invoices ri
|
||||||
|
WHERE ri.subscription_id = s.id
|
||||||
|
AND ri.scheduled_date = s.current_period_end
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
generated := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var subID, customerID, planID string
|
||||||
|
var price float64
|
||||||
|
var currency string
|
||||||
|
var periodEnd time.Time
|
||||||
|
if err := rows.Scan(&subID, &customerID, &planID, &price, ¤cy, &periodEnd); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
invoiceNumber := fmt.Sprintf("SUB-%d-%s", time.Now().Unix(), subID[:8])
|
||||||
|
_, err = h.DB.Exec(`
|
||||||
|
INSERT INTO boc_recurring_invoices (customer_id, subscription_id, plan_id, invoice_number, amount, currency, scheduled_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, customerID, subID, planID, invoiceNumber, price, currency, periodEnd)
|
||||||
|
if err == nil {
|
||||||
|
generated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"generated": generated,
|
||||||
|
"message": fmt.Sprintf("Generated %d recurring invoices", generated),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SubscriptionHandler) ListRecurringInvoices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, customer_id, subscription_id, plan_id, invoice_number, amount, currency, status, scheduled_date, generated_at, sent_at
|
||||||
|
FROM boc_recurring_invoices
|
||||||
|
ORDER BY scheduled_date DESC
|
||||||
|
LIMIT 100
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
invoices := []map[string]interface{}{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, customerID, subID, planID, invNumber, status, currency string
|
||||||
|
var amount float64
|
||||||
|
var scheduledDate time.Time
|
||||||
|
var generatedAt, sentAt *time.Time
|
||||||
|
if err := rows.Scan(&id, &customerID, &subID, &planID, &invNumber, &amount, ¤cy, &status, &scheduledDate, &generatedAt, &sentAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
invoices = append(invoices, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"customer_id": customerID,
|
||||||
|
"subscription_id": subID,
|
||||||
|
"invoice_number": invNumber,
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"status": status,
|
||||||
|
"scheduled_date": scheduledDate,
|
||||||
|
"generated_at": generatedAt,
|
||||||
|
"sent_at": sentAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"invoices": invoices,
|
||||||
|
"total": len(invoices),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SupplierHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSupplierHandler(db *sql.DB) *SupplierHandler {
|
||||||
|
return &SupplierHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Supplier struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
OrgNumber string `json:"org_number"`
|
||||||
|
Address map[string]interface{} `json:"address"`
|
||||||
|
PaymentTerms string `json:"payment_terms"`
|
||||||
|
BankAccount string `json:"bank_account"`
|
||||||
|
Bankgiro string `json:"bankgiro"`
|
||||||
|
Postgiro string `json:"postgiro"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchaseOrder struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SupplierID string `json:"supplier_id"`
|
||||||
|
PONumber string `json:"po_number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
TaxAmount float64 `json:"tax_amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
ExpectedDelivery *time.Time `json:"expected_delivery"`
|
||||||
|
ReceivedAt *time.Time `json:"received_at"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupplierInvoice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SupplierID string `json:"supplier_id"`
|
||||||
|
POID *string `json:"po_id"`
|
||||||
|
InvoiceNumber string `json:"invoice_number"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
TaxAmount float64 `json:"tax_amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
DueDate *time.Time `json:"due_date"`
|
||||||
|
PaidAt *time.Time `json:"paid_at"`
|
||||||
|
OCRNumber string `json:"ocr_number"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupplierHandler) ListSuppliers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, name, email, phone, org_number, address, payment_terms, bank_account, bankgiro, postgiro, currency, status, created_at
|
||||||
|
FROM boc_suppliers WHERE status = 'active' ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
suppliers := []Supplier{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s Supplier
|
||||||
|
var addr []byte
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name, &s.Email, &s.Phone, &s.OrgNumber, &addr, &s.PaymentTerms, &s.BankAccount, &s.Bankgiro, &s.Postgiro, &s.Currency, &s.Status, &s.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
json.Unmarshal(addr, &s.Address)
|
||||||
|
suppliers = append(suppliers, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"suppliers": suppliers,
|
||||||
|
"total": len(suppliers),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupplierHandler) CreateSupplier(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Supplier
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, _ := json.Marshal(req.Address)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_suppliers (name, email, phone, org_number, address, payment_terms, bank_account, bankgiro, postgiro, currency)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Email, req.Phone, req.OrgNumber, addr, req.PaymentTerms, req.BankAccount, req.Bankgiro, req.Postgiro, req.Currency).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create supplier")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Supplier created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupplierHandler) ListPurchaseOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `SELECT id, supplier_id, po_number, status, amount, currency, expected_delivery, received_at, created_at FROM boc_purchase_orders ORDER BY created_at DESC LIMIT 100`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, supplier_id, po_number, status, amount, currency, expected_delivery, received_at, created_at FROM boc_purchase_orders WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
pos := []PurchaseOrder{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p PurchaseOrder
|
||||||
|
if err := rows.Scan(&p.ID, &p.SupplierID, &p.PONumber, &p.Status, &p.Amount, &p.Currency, &p.ExpectedDelivery, &p.ReceivedAt, &p.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos = append(pos, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"purchase_orders": pos,
|
||||||
|
"total": len(pos),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupplierHandler) CreatePurchaseOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
SupplierID string `json:"supplier_id"`
|
||||||
|
ExpectedDelivery *time.Time `json:"expected_delivery"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Items []struct {
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
TaxRate float64 `json:"tax_rate"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
poNumber := "PO-" + time.Now().Format("20060102-150405")
|
||||||
|
|
||||||
|
var totalAmount, totalTax float64
|
||||||
|
for _, item := range req.Items {
|
||||||
|
itemTotal := item.Quantity * item.UnitPrice
|
||||||
|
itemTax := itemTotal * (item.TaxRate / 100)
|
||||||
|
totalAmount += itemTotal
|
||||||
|
totalTax += itemTax
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
INSERT INTO boc_purchase_orders (supplier_id, po_number, amount, tax_amount, currency, expected_delivery, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, 'USD', $5, $6)
|
||||||
|
RETURNING id
|
||||||
|
`, req.SupplierID, poNumber, totalAmount, totalTax, req.ExpectedDelivery, req.Notes).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create PO")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range req.Items {
|
||||||
|
itemTotal := item.Quantity * item.UnitPrice
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO boc_purchase_order_items (po_id, product_id, description, quantity, unit_price, tax_rate, total)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, id, item.ProductID, item.Description, item.Quantity, item.UnitPrice, item.TaxRate, itemTotal)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create PO items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"number": poNumber,
|
||||||
|
"message": "Purchase order created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupplierHandler) ListSupplierInvoices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if status == "all" {
|
||||||
|
query = `SELECT id, supplier_id, po_id, invoice_number, amount, currency, status, due_date, paid_at, ocr_number, created_at FROM boc_supplier_invoices ORDER BY created_at DESC LIMIT 100`
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, supplier_id, po_id, invoice_number, amount, currency, status, due_date, paid_at, ocr_number, created_at FROM boc_supplier_invoices WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
invoices := []SupplierInvoice{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i SupplierInvoice
|
||||||
|
if err := rows.Scan(&i.ID, &i.SupplierID, &i.POID, &i.InvoiceNumber, &i.Amount, &i.Currency, &i.Status, &i.DueDate, &i.PaidAt, &i.OCRNumber, &i.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
invoices = append(invoices, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"invoices": invoices,
|
||||||
|
"total": len(invoices),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupplierHandler) CreateSupplierInvoice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SupplierInvoice
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_supplier_invoices (supplier_id, po_id, invoice_number, amount, tax_amount, currency, due_date, ocr_number, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'USD', $6, $7, $8)
|
||||||
|
RETURNING id
|
||||||
|
`, req.SupplierID, req.POID, req.InvoiceNumber, req.Amount, req.TaxAmount, req.DueDate, req.OCRNumber, req.Notes).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create supplier invoice")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Supplier invoice created",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SupportHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSupportHandler(db *sql.DB) *SupportHandler {
|
||||||
|
return &SupportHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ticket struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomerID *string `json:"customer_id"`
|
||||||
|
ContactID *string `json:"contact_id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
AssignedTo *string `json:"assigned_to"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketComment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TicketID string `json:"ticket_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsInternal bool `json:"is_internal"`
|
||||||
|
CreatedBy *string `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupportHandler) ListTickets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "open"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, customer_id, contact_id, subject, description, status, priority, category, source, assigned_to, resolved_at, resolution, created_at, updated_at
|
||||||
|
FROM boc_tickets
|
||||||
|
WHERE status = $1
|
||||||
|
ORDER BY
|
||||||
|
CASE priority
|
||||||
|
WHEN 'critical' THEN 1
|
||||||
|
WHEN 'high' THEN 2
|
||||||
|
WHEN 'medium' THEN 3
|
||||||
|
WHEN 'low' THEN 4
|
||||||
|
ELSE 5
|
||||||
|
END,
|
||||||
|
created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`, status)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tickets := []Ticket{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t Ticket
|
||||||
|
if err := rows.Scan(&t.ID, &t.CustomerID, &t.ContactID, &t.Subject, &t.Description,
|
||||||
|
&t.Status, &t.Priority, &t.Category, &t.Source, &t.AssignedTo, &t.ResolvedAt,
|
||||||
|
&t.Resolution, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tickets = append(tickets, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"tickets": tickets,
|
||||||
|
"total": len(tickets),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupportHandler) CreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req Ticket
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_tickets (customer_id, contact_id, subject, description, status, priority, category, source)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id
|
||||||
|
`, req.CustomerID, req.ContactID, req.Subject, req.Description, req.Status,
|
||||||
|
req.Priority, req.Category, req.Source).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create ticket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Ticket created",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupportHandler) GetTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var t Ticket
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
SELECT id, customer_id, contact_id, subject, description, status, priority, category, source, assigned_to, resolved_at, resolution, created_at, updated_at
|
||||||
|
FROM boc_tickets WHERE id = $1
|
||||||
|
`, id).Scan(&t.ID, &t.CustomerID, &t.ContactID, &t.Subject, &t.Description,
|
||||||
|
&t.Status, &t.Priority, &t.Category, &t.Source, &t.AssignedTo, &t.ResolvedAt,
|
||||||
|
&t.Resolution, &t.CreatedAt, &t.UpdatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "ticket not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get comments
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, ticket_id, content, is_internal, created_by, created_at
|
||||||
|
FROM boc_ticket_comments
|
||||||
|
WHERE ticket_id = $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
comments := []TicketComment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c TicketComment
|
||||||
|
if err := rows.Scan(&c.ID, &c.TicketID, &c.Content, &c.IsInternal, &c.CreatedBy, &c.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ticket": t,
|
||||||
|
"comments": comments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupportHandler) UpdateTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req Ticket
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.DB.Exec(`
|
||||||
|
UPDATE boc_tickets
|
||||||
|
SET customer_id = $1, contact_id = $2, subject = $3, description = $4,
|
||||||
|
status = $5, priority = $6, category = $7, source = $8,
|
||||||
|
assigned_to = $9, resolved_at = $10, resolution = $11
|
||||||
|
WHERE id = $12
|
||||||
|
`, req.CustomerID, req.ContactID, req.Subject, req.Description, req.Status,
|
||||||
|
req.Priority, req.Category, req.Source, req.AssignedTo, req.ResolvedAt,
|
||||||
|
req.Resolution, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update ticket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "Ticket updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupportHandler) AddComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticketID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req TicketComment
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := h.DB.QueryRow(`
|
||||||
|
INSERT INTO boc_ticket_comments (ticket_id, content, is_internal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id
|
||||||
|
`, ticketID, req.Content, req.IsInternal).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to add comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"message": "Comment added",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SupportHandler) GetCSAT(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO: Implement actual CSAT calculation from ticket ratings
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"csat_score": 4.2,
|
||||||
|
"total_ratings": 156,
|
||||||
|
"response_rate": 0.78,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Integration tests require a running database
|
||||||
|
// Run with: go test -tags=integration -v ./...
|
||||||
|
|
||||||
|
func TestIntegration_HealthEndpoint(t *testing.T) {
|
||||||
|
if os.Getenv("INTEGRATION") != "1" {
|
||||||
|
t.Skip("Skipping integration test. Set INTEGRATION=1 to run.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
go main()
|
||||||
|
time.Sleep(2 * time.Second) // Wait for server to start
|
||||||
|
|
||||||
|
resp, err := http.Get("http://localhost:9092/health")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, true, result["ok"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_FullWorkflow(t *testing.T) {
|
||||||
|
if os.Getenv("INTEGRATION") != "1" {
|
||||||
|
t.Skip("Skipping integration test. Set INTEGRATION=1 to run.")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := "http://localhost:9092"
|
||||||
|
|
||||||
|
// 1. Create customer
|
||||||
|
customer := map[string]interface{}{
|
||||||
|
"name": "Integration Test Customer",
|
||||||
|
"email": "integration@test.com",
|
||||||
|
"phone": "+46701234567",
|
||||||
|
"company": "Test AB",
|
||||||
|
"status": "lead",
|
||||||
|
}
|
||||||
|
customerBody, _ := json.Marshal(customer)
|
||||||
|
|
||||||
|
resp, err := http.Post(baseURL+"/api/v1/crm/customers", "application/json", bytes.NewReader(customerBody))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
var customerResult map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&customerResult)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
customerID := customerResult["id"].(string)
|
||||||
|
assert.NotEmpty(t, customerID)
|
||||||
|
|
||||||
|
// 2. Create quote
|
||||||
|
quote := map[string]interface{}{
|
||||||
|
"customer_id": customerID,
|
||||||
|
"title": "Test Quote",
|
||||||
|
"description": "Integration test quote",
|
||||||
|
"valid_until": time.Now().AddDate(0, 1, 0).Format("2006-01-02"),
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"description": "Service A",
|
||||||
|
"quantity": 10,
|
||||||
|
"unit_price": 100.00,
|
||||||
|
"tax_rate": 25.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
quoteBody, _ := json.Marshal(quote)
|
||||||
|
|
||||||
|
resp, err = http.Post(baseURL+"/api/v1/sales/quotes", "application/json", bytes.NewReader(quoteBody))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
var quoteResult map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode("eResult)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
quoteID := quoteResult["id"].(string)
|
||||||
|
assert.NotEmpty(t, quoteID)
|
||||||
|
|
||||||
|
// 3. Accept quote
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, baseURL+"/api/v1/sales/quotes/"+quoteID+"/accept", nil)
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// 4. Convert to order
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, baseURL+"/api/v1/sales/quotes/"+quoteID+"/convert", nil)
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
var orderResult map[string]interface{}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&orderResult)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
orderID := orderResult["order_id"].(string)
|
||||||
|
assert.NotEmpty(t, orderID)
|
||||||
|
|
||||||
|
// 5. Create invoice from order (would need invoice handler)
|
||||||
|
// Skipped for now
|
||||||
|
|
||||||
|
t.Logf("Created customer: %s, quote: %s, order: %s", customerID, quoteID, orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_Performance(t *testing.T) {
|
||||||
|
if os.Getenv("INTEGRATION") != "1" {
|
||||||
|
t.Skip("Skipping integration test. Set INTEGRATION=1 to run.")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := "http://localhost:9092"
|
||||||
|
|
||||||
|
// Test response time for health endpoint
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := http.Get(baseURL + "/health")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Less(t, elapsed, 100*time.Millisecond, "Health endpoint too slow")
|
||||||
|
|
||||||
|
t.Logf("Health endpoint response time: %v", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock test for handlers without DB
|
||||||
|
func TestMock_CRMHandler(t *testing.T) {
|
||||||
|
// This is a placeholder for future mock-based tests
|
||||||
|
// Would use sqlmock to mock database interactions
|
||||||
|
assert.True(t, true)
|
||||||
|
}
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
|
||||||
|
"boc/automation"
|
||||||
|
"boc/cache"
|
||||||
|
"boc/config"
|
||||||
|
"boc/db"
|
||||||
|
"boc/email"
|
||||||
|
"boc/events"
|
||||||
|
"boc/handlers"
|
||||||
|
"boc/middleware"
|
||||||
|
"boc/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "9092"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database connection with migrations
|
||||||
|
database, err := db.Connect(cfg.DBURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("database connect failed")
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
migrationsDir := os.Getenv("MIGRATIONS_DIR")
|
||||||
|
if migrationsDir == "" {
|
||||||
|
migrationsDir = "./db/migrations"
|
||||||
|
}
|
||||||
|
if err := db.RunMigrations(database, migrationsDir); err != nil {
|
||||||
|
logger.Fatal().Err(err).Msg("migrations failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis cache
|
||||||
|
redisClient, err := cache.NewRedisClient(cfg.RedisURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("redis connection failed, continuing without cache")
|
||||||
|
redisClient = nil
|
||||||
|
} else {
|
||||||
|
defer redisClient.Close()
|
||||||
|
logger.Info().Msg("redis connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kafka event streaming
|
||||||
|
var kafkaClient *events.KafkaClient
|
||||||
|
if len(cfg.KafkaBrokers) > 0 && cfg.KafkaBrokers[0] != "" {
|
||||||
|
kafkaClient, err = events.NewKafkaClient(cfg.KafkaBrokers)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("kafka connection failed, continuing without event streaming")
|
||||||
|
} else {
|
||||||
|
defer kafkaClient.Close()
|
||||||
|
if err := kafkaClient.EnsureTopics(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to ensure kafka topics")
|
||||||
|
}
|
||||||
|
logger.Info().Strs("brokers", cfg.KafkaBrokers).Msg("kafka connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automation engine
|
||||||
|
autoEngine := automation.NewEngine(database, logger)
|
||||||
|
autoEngine.Start(context.Background())
|
||||||
|
defer autoEngine.Stop()
|
||||||
|
|
||||||
|
// WebSocket hub
|
||||||
|
wsHub := websocket.NewHub(logger)
|
||||||
|
go wsHub.Run()
|
||||||
|
|
||||||
|
// Email client (Resend)
|
||||||
|
var emailClient *email.Client
|
||||||
|
if cfg.ResendAPIKey != "" {
|
||||||
|
emailClient = email.NewClient(cfg.ResendAPIKey, cfg.FromEmail, cfg.FromName)
|
||||||
|
logger.Info().Str("from", cfg.FromEmail).Msg("email client configured")
|
||||||
|
} else {
|
||||||
|
logger.Warn().Msg("RESEND_API_KEY not set, email features disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
authH := &handlers.AuthHandler{
|
||||||
|
DB: database,
|
||||||
|
JWTSecret: []byte(cfg.JWTSecret),
|
||||||
|
}
|
||||||
|
|
||||||
|
crmH := handlers.NewCRMHandler(database)
|
||||||
|
salesH := handlers.NewSalesHandler(database)
|
||||||
|
financeH := handlers.NewFinanceHandler(database)
|
||||||
|
financeH.SetEmailClient(emailClient)
|
||||||
|
marketingH := handlers.NewMarketingHandler(database)
|
||||||
|
supportH := handlers.NewSupportHandler(database)
|
||||||
|
analyticsH := handlers.NewAnalyticsHandler(database)
|
||||||
|
hrH := handlers.NewHRHandler(database)
|
||||||
|
legalH := handlers.NewLegalHandler(database)
|
||||||
|
autoH := handlers.NewAutomationHandler(database, autoEngine)
|
||||||
|
|
||||||
|
// Ledger integration
|
||||||
|
quoteH := handlers.NewQuoteHandler(database)
|
||||||
|
quoteH.SetEmailClient(emailClient)
|
||||||
|
orderH := handlers.NewOrderHandler(database)
|
||||||
|
supplierH := handlers.NewSupplierHandler(database)
|
||||||
|
inventoryH := handlers.NewInventoryHandler(database)
|
||||||
|
subscriptionH := handlers.NewSubscriptionHandler(database)
|
||||||
|
receiptH := handlers.NewReceiptHandler(database)
|
||||||
|
payrollH := handlers.NewPayrollHandler(database)
|
||||||
|
bankH := handlers.NewBankHandler(database)
|
||||||
|
projectH := handlers.NewProjectHandler(database)
|
||||||
|
ledgerFinanceH := handlers.NewLedgerFinanceHandler()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.CORS)
|
||||||
|
r.Use(hlog.NewHandler(logger))
|
||||||
|
r.Use(hlog.RequestIDHandler("req_id", "X-Request-ID"))
|
||||||
|
r.Use(middleware.Logger(logger))
|
||||||
|
r.Use(chimw.Recoverer)
|
||||||
|
|
||||||
|
// Public
|
||||||
|
r.Handle("/health", handlers.NewHealthHandler())
|
||||||
|
r.Post("/api/v1/auth/login", authH.Login)
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
r.Get("/ws", wsHub.HandleWebSocket)
|
||||||
|
|
||||||
|
// Protected
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.Auth(cfg))
|
||||||
|
|
||||||
|
r.Get("/api/v1/auth/me", authH.Me)
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
r.Get("/api/v1/crm/customers", crmH.ListCustomers)
|
||||||
|
r.Post("/api/v1/crm/customers", crmH.CreateCustomer)
|
||||||
|
r.Get("/api/v1/crm/customers/{id}", crmH.GetCustomer)
|
||||||
|
r.Put("/api/v1/crm/customers/{id}", crmH.UpdateCustomer)
|
||||||
|
r.Delete("/api/v1/crm/customers/{id}", crmH.DeleteCustomer)
|
||||||
|
r.Get("/api/v1/crm/leads", crmH.ListLeads)
|
||||||
|
r.Get("/api/v1/crm/pipeline", crmH.GetPipeline)
|
||||||
|
r.Post("/api/v1/crm/interactions", crmH.CreateInteraction)
|
||||||
|
r.Get("/api/v1/crm/customers/{id}/interactions", crmH.GetCustomerInteractions)
|
||||||
|
|
||||||
|
// Sales
|
||||||
|
r.Get("/api/v1/sales/deals", salesH.ListDeals)
|
||||||
|
r.Post("/api/v1/sales/deals", salesH.CreateDeal)
|
||||||
|
r.Get("/api/v1/sales/deals/{id}", salesH.GetDeal)
|
||||||
|
r.Put("/api/v1/sales/deals/{id}", salesH.UpdateDeal)
|
||||||
|
r.Get("/api/v1/sales/mrr", salesH.GetMRR)
|
||||||
|
r.Get("/api/v1/sales/arr", salesH.GetARR)
|
||||||
|
r.Get("/api/v1/sales/products", salesH.ListProducts)
|
||||||
|
r.Post("/api/v1/sales/products", salesH.CreateProduct)
|
||||||
|
|
||||||
|
// Quotes
|
||||||
|
r.Get("/api/v1/sales/quotes", quoteH.ListQuotes)
|
||||||
|
r.Post("/api/v1/sales/quotes", quoteH.CreateQuote)
|
||||||
|
r.Get("/api/v1/sales/quotes/{id}", quoteH.GetQuote)
|
||||||
|
r.Post("/api/v1/sales/quotes/{id}/accept", quoteH.AcceptQuote)
|
||||||
|
r.Post("/api/v1/sales/quotes/{id}/convert", quoteH.ConvertToOrder)
|
||||||
|
r.Get("/api/v1/sales/quotes/{id}/pdf", quoteH.GenerateQuotePDF)
|
||||||
|
r.Post("/api/v1/sales/quotes/{id}/send", quoteH.SendQuoteEmail)
|
||||||
|
|
||||||
|
// Orders
|
||||||
|
r.Get("/api/v1/sales/orders", orderH.ListOrders)
|
||||||
|
r.Post("/api/v1/sales/orders", orderH.CreateOrder)
|
||||||
|
r.Get("/api/v1/sales/orders/{id}", orderH.GetOrder)
|
||||||
|
r.Put("/api/v1/sales/orders/{id}", orderH.UpdateOrder)
|
||||||
|
r.Post("/api/v1/sales/orders/{id}/ship", orderH.ShipOrder)
|
||||||
|
r.Post("/api/v1/sales/orders/{id}/deliver", orderH.DeliverOrder)
|
||||||
|
|
||||||
|
// Finance (from aamos-ledger)
|
||||||
|
r.Get("/api/v1/finance/balance", ledgerFinanceH.GetBalanceSheet)
|
||||||
|
r.Get("/api/v1/finance/income", ledgerFinanceH.GetIncomeStatement)
|
||||||
|
r.Get("/api/v1/finance/moms", ledgerFinanceH.GetMomsReport)
|
||||||
|
r.Get("/api/v1/finance/accounts", ledgerFinanceH.GetAccounts)
|
||||||
|
r.Get("/api/v1/finance/invoices", ledgerFinanceH.GetInvoices)
|
||||||
|
r.Get("/api/v1/finance/cashflow", financeH.GetCashFlow)
|
||||||
|
r.Get("/api/v1/finance/budget", financeH.GetBudget)
|
||||||
|
r.Post("/api/v1/finance/expenses", financeH.CreateExpense)
|
||||||
|
r.Get("/api/v1/finance/expenses", financeH.ListExpenses)
|
||||||
|
r.Get("/api/v1/finance/invoices/{id}/pdf", financeH.GenerateInvoicePDF)
|
||||||
|
r.Post("/api/v1/finance/invoices/{id}/send", financeH.SendInvoiceEmail)
|
||||||
|
|
||||||
|
// Suppliers & Purchase
|
||||||
|
r.Get("/api/v1/purchase/suppliers", supplierH.ListSuppliers)
|
||||||
|
r.Post("/api/v1/purchase/suppliers", supplierH.CreateSupplier)
|
||||||
|
r.Get("/api/v1/purchase/orders", supplierH.ListPurchaseOrders)
|
||||||
|
r.Post("/api/v1/purchase/orders", supplierH.CreatePurchaseOrder)
|
||||||
|
r.Get("/api/v1/purchase/invoices", supplierH.ListSupplierInvoices)
|
||||||
|
r.Post("/api/v1/purchase/invoices", supplierH.CreateSupplierInvoice)
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
r.Get("/api/v1/inventory/warehouses", inventoryH.ListWarehouses)
|
||||||
|
r.Post("/api/v1/inventory/warehouses", inventoryH.CreateWarehouse)
|
||||||
|
r.Get("/api/v1/inventory", inventoryH.ListInventory)
|
||||||
|
r.Post("/api/v1/inventory/adjust", inventoryH.AdjustStock)
|
||||||
|
r.Get("/api/v1/inventory/movements", inventoryH.ListMovements)
|
||||||
|
r.Get("/api/v1/inventory/low-stock", inventoryH.GetLowStock)
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
r.Get("/api/v1/subscriptions/plans", subscriptionH.ListPlans)
|
||||||
|
r.Post("/api/v1/subscriptions/plans", subscriptionH.CreatePlan)
|
||||||
|
r.Get("/api/v1/subscriptions", subscriptionH.ListSubscriptions)
|
||||||
|
r.Post("/api/v1/subscriptions", subscriptionH.CreateSubscription)
|
||||||
|
r.Post("/api/v1/subscriptions/generate-invoices", subscriptionH.GenerateRecurringInvoices)
|
||||||
|
r.Get("/api/v1/subscriptions/invoices", subscriptionH.ListRecurringInvoices)
|
||||||
|
|
||||||
|
// Receipts
|
||||||
|
r.Get("/api/v1/receipts", receiptH.ListReceipts)
|
||||||
|
r.Post("/api/v1/receipts", receiptH.UploadReceipt)
|
||||||
|
r.Post("/api/v1/receipts/approve", receiptH.ApproveReceipt)
|
||||||
|
|
||||||
|
// Payroll
|
||||||
|
r.Get("/api/v1/payroll/runs", payrollH.ListPayrollRuns)
|
||||||
|
r.Post("/api/v1/payroll/runs", payrollH.CreatePayrollRun)
|
||||||
|
r.Get("/api/v1/payroll/runs/{id}", payrollH.GetPayrollRun)
|
||||||
|
r.Post("/api/v1/payroll/runs/{id}/process", payrollH.ProcessPayroll)
|
||||||
|
r.Post("/api/v1/payroll/runs/{id}/approve", payrollH.ApprovePayroll)
|
||||||
|
|
||||||
|
// Bank
|
||||||
|
r.Get("/api/v1/bank/accounts", bankH.ListAccounts)
|
||||||
|
r.Post("/api/v1/bank/accounts", bankH.CreateAccount)
|
||||||
|
r.Get("/api/v1/bank/transactions", bankH.ListTransactions)
|
||||||
|
r.Post("/api/v1/bank/transactions/sync", bankH.SyncTransactions)
|
||||||
|
r.Post("/api/v1/bank/transactions/{id}/match", bankH.MatchTransaction)
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
r.Get("/api/v1/projects", projectH.ListProjects)
|
||||||
|
r.Post("/api/v1/projects", projectH.CreateProject)
|
||||||
|
r.Get("/api/v1/projects/{id}", projectH.GetProject)
|
||||||
|
r.Post("/api/v1/projects/{id}/time", projectH.AddTime)
|
||||||
|
r.Get("/api/v1/projects/summary", projectH.GetProjectSummary)
|
||||||
|
|
||||||
|
// HR
|
||||||
|
r.Get("/api/v1/hr/employees", hrH.ListEmployees)
|
||||||
|
r.Post("/api/v1/hr/employees", hrH.CreateEmployee)
|
||||||
|
r.Get("/api/v1/hr/employees/{id}", hrH.GetEmployee)
|
||||||
|
r.Put("/api/v1/hr/employees/{id}", hrH.UpdateEmployee)
|
||||||
|
r.Get("/api/v1/hr/leaves", hrH.ListLeaves)
|
||||||
|
r.Post("/api/v1/hr/leaves", hrH.CreateLeave)
|
||||||
|
r.Get("/api/v1/hr/timesheets", hrH.ListTimesheets)
|
||||||
|
r.Post("/api/v1/hr/timesheets", hrH.CreateTimesheet)
|
||||||
|
|
||||||
|
// Legal
|
||||||
|
r.Get("/api/v1/legal/contracts", legalH.ListContracts)
|
||||||
|
r.Post("/api/v1/legal/contracts", legalH.CreateContract)
|
||||||
|
r.Get("/api/v1/legal/contracts/{id}", legalH.GetContract)
|
||||||
|
r.Put("/api/v1/legal/contracts/{id}", legalH.UpdateContract)
|
||||||
|
r.Get("/api/v1/legal/reminders", legalH.ListReminders)
|
||||||
|
|
||||||
|
// Marketing
|
||||||
|
r.Get("/api/v1/marketing/campaigns", marketingH.ListCampaigns)
|
||||||
|
r.Post("/api/v1/marketing/campaigns", marketingH.CreateCampaign)
|
||||||
|
r.Get("/api/v1/marketing/content", marketingH.ListContent)
|
||||||
|
r.Post("/api/v1/marketing/content", marketingH.CreateContent)
|
||||||
|
|
||||||
|
// Support
|
||||||
|
r.Get("/api/v1/support/tickets", supportH.ListTickets)
|
||||||
|
r.Post("/api/v1/support/tickets", supportH.CreateTicket)
|
||||||
|
r.Get("/api/v1/support/tickets/{id}", supportH.GetTicket)
|
||||||
|
r.Put("/api/v1/support/tickets/{id}", supportH.UpdateTicket)
|
||||||
|
r.Post("/api/v1/support/tickets/{id}/comments", supportH.AddComment)
|
||||||
|
r.Get("/api/v1/support/csat", supportH.GetCSAT)
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
r.Get("/api/v1/analytics/users", analyticsH.GetActiveUsers)
|
||||||
|
r.Get("/api/v1/analytics/revenue", analyticsH.GetRevenue)
|
||||||
|
r.Get("/api/v1/analytics/retention", analyticsH.GetRetention)
|
||||||
|
r.Get("/api/v1/analytics/dashboard", analyticsH.GetDashboard)
|
||||||
|
|
||||||
|
// Automation
|
||||||
|
r.Get("/api/v1/automation/workflows", autoH.ListWorkflows)
|
||||||
|
r.Post("/api/v1/automation/workflows", autoH.CreateWorkflow)
|
||||||
|
r.Post("/api/v1/automation/workflows/{id}/trigger", autoH.TriggerWorkflow)
|
||||||
|
r.Get("/api/v1/automation/jobs", autoH.ListScheduledJobs)
|
||||||
|
r.Post("/api/v1/automation/jobs", autoH.CreateScheduledJob)
|
||||||
|
r.Get("/api/v1/automation/runs", autoH.ListRuns)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inject dependencies into context for handlers that need them
|
||||||
|
_ = redisClient
|
||||||
|
_ = kafkaClient
|
||||||
|
_ = wsHub
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logger.Info().Str("addr", srv.Addr).Msg("BOC server starting")
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Fatal().Err(err).Msg("listen error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
logger.Info().Msg("shutting down")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("shutdown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"boc/config"
|
||||||
|
"boc/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if tokenString == authHeader {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &handlers.Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(cfg.JWTSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*handlers.Claims)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), "user", claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
w.Write([]byte(`{"error":"` + message + `"}`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Logger(logger zerolog.Logger) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
logger.Info().
|
||||||
|
Str("method", r.Method).
|
||||||
|
Str("path", r.URL.Path).
|
||||||
|
Dur("duration", time.Since(start)).
|
||||||
|
Msg("request")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InvoiceData contains all data needed to generate an invoice PDF
|
||||||
|
type InvoiceData struct {
|
||||||
|
InvoiceNumber string
|
||||||
|
InvoiceDate time.Time
|
||||||
|
DueDate time.Time
|
||||||
|
CustomerName string
|
||||||
|
CustomerAddress string
|
||||||
|
CustomerOrgNr string
|
||||||
|
Items []InvoiceItem
|
||||||
|
Subtotal float64
|
||||||
|
VATRate float64
|
||||||
|
VATAmount float64
|
||||||
|
Total float64
|
||||||
|
Currency string
|
||||||
|
CompanyName string
|
||||||
|
CompanyAddress string
|
||||||
|
CompanyOrgNr string
|
||||||
|
CompanyBankgiro string
|
||||||
|
Notes string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceItem represents a line item on an invoice
|
||||||
|
type InvoiceItem struct {
|
||||||
|
Description string
|
||||||
|
Quantity float64
|
||||||
|
Unit string
|
||||||
|
UnitPrice float64
|
||||||
|
Total float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteData contains all data needed to generate a quote PDF
|
||||||
|
type QuoteData struct {
|
||||||
|
QuoteNumber string
|
||||||
|
QuoteDate time.Time
|
||||||
|
ValidUntil time.Time
|
||||||
|
CustomerName string
|
||||||
|
CustomerAddress string
|
||||||
|
Items []QuoteItem
|
||||||
|
Subtotal float64
|
||||||
|
VATRate float64
|
||||||
|
VATAmount float64
|
||||||
|
Total float64
|
||||||
|
Currency string
|
||||||
|
CompanyName string
|
||||||
|
CompanyAddress string
|
||||||
|
Notes string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteItem represents a line item on a quote
|
||||||
|
type QuoteItem struct {
|
||||||
|
Description string
|
||||||
|
Quantity float64
|
||||||
|
Unit string
|
||||||
|
UnitPrice float64
|
||||||
|
Total float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateInvoice creates a professional invoice PDF
|
||||||
|
func GenerateInvoice(data InvoiceData) ([]byte, error) {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
// Header with company info
|
||||||
|
pdf.SetFont("Arial", "B", 20)
|
||||||
|
pdf.SetTextColor(201, 106, 58) // Terracotta
|
||||||
|
pdf.Cell(0, 12, "FAKTURA")
|
||||||
|
pdf.Ln(8)
|
||||||
|
|
||||||
|
// Company info
|
||||||
|
pdf.SetFont("Arial", "B", 10)
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.Cell(0, 5, data.CompanyName)
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.Cell(0, 4, data.CompanyAddress)
|
||||||
|
pdf.Ln(4)
|
||||||
|
if data.CompanyOrgNr != "" {
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Org.nr: %s", data.CompanyOrgNr))
|
||||||
|
pdf.Ln(4)
|
||||||
|
}
|
||||||
|
if data.CompanyBankgiro != "" {
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Bankgiro: %s", data.CompanyBankgiro))
|
||||||
|
pdf.Ln(4)
|
||||||
|
}
|
||||||
|
pdf.Ln(5)
|
||||||
|
|
||||||
|
// Invoice details box
|
||||||
|
pdf.SetFillColor(250, 248, 245)
|
||||||
|
pdf.Rect(130, 30, 70, 35, "F")
|
||||||
|
pdf.SetXY(135, 33)
|
||||||
|
pdf.SetFont("Arial", "B", 9)
|
||||||
|
pdf.SetTextColor(201, 106, 58)
|
||||||
|
pdf.Cell(0, 5, "FAKTURAINFORMATION")
|
||||||
|
pdf.Ln(6)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.SetX(135)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Fakturanr: %s", data.InvoiceNumber))
|
||||||
|
pdf.Ln(4)
|
||||||
|
pdf.SetX(135)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Datum: %s", data.InvoiceDate.Format("2006-01-02")))
|
||||||
|
pdf.Ln(4)
|
||||||
|
pdf.SetX(135)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Förfallo: %s", data.DueDate.Format("2006-01-02")))
|
||||||
|
pdf.Ln(4)
|
||||||
|
pdf.SetX(135)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Valuta: %s", data.Currency))
|
||||||
|
pdf.Ln(4)
|
||||||
|
|
||||||
|
// Customer info
|
||||||
|
pdf.SetXY(10, 75)
|
||||||
|
pdf.SetFont("Arial", "B", 10)
|
||||||
|
pdf.SetTextColor(201, 106, 58)
|
||||||
|
pdf.Cell(0, 5, "KUND")
|
||||||
|
pdf.Ln(6)
|
||||||
|
pdf.SetFont("Arial", "B", 10)
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.Cell(0, 5, data.CustomerName)
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.Cell(0, 4, data.CustomerAddress)
|
||||||
|
pdf.Ln(4)
|
||||||
|
if data.CustomerOrgNr != "" {
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Org.nr: %s", data.CustomerOrgNr))
|
||||||
|
pdf.Ln(4)
|
||||||
|
}
|
||||||
|
pdf.Ln(10)
|
||||||
|
|
||||||
|
// Items table header
|
||||||
|
pdf.SetFillColor(201, 106, 58)
|
||||||
|
pdf.SetTextColor(255, 255, 255)
|
||||||
|
pdf.SetFont("Arial", "B", 9)
|
||||||
|
pdf.Cell(80, 8, "Beskrivning")
|
||||||
|
pdf.Cell(25, 8, "Antal")
|
||||||
|
pdf.Cell(25, 8, "Enhet")
|
||||||
|
pdf.Cell(30, 8, "Pris")
|
||||||
|
pdf.Cell(30, 8, "Belopp")
|
||||||
|
pdf.Ln(8)
|
||||||
|
|
||||||
|
// Items
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
for i, item := range data.Items {
|
||||||
|
if i%2 == 0 {
|
||||||
|
pdf.SetFillColor(250, 248, 245)
|
||||||
|
pdf.Rect(10, pdf.GetY(), 190, 6, "F")
|
||||||
|
}
|
||||||
|
pdf.Cell(80, 6, item.Description)
|
||||||
|
pdf.Cell(25, 6, fmt.Sprintf("%.2f", item.Quantity))
|
||||||
|
pdf.Cell(25, 6, item.Unit)
|
||||||
|
pdf.Cell(30, 6, fmt.Sprintf("%.2f", item.UnitPrice))
|
||||||
|
pdf.Cell(30, 6, fmt.Sprintf("%.2f", item.Total))
|
||||||
|
pdf.Ln(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.Ln(5)
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
pdf.SetX(120)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.Cell(40, 5, "Delsumma:")
|
||||||
|
pdf.Cell(30, 5, fmt.Sprintf("%.2f %s", data.Subtotal, data.Currency))
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetX(120)
|
||||||
|
pdf.Cell(40, 5, fmt.Sprintf("Moms (%.0f%%):", data.VATRate*100))
|
||||||
|
pdf.Cell(30, 5, fmt.Sprintf("%.2f %s", data.VATAmount, data.Currency))
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetX(120)
|
||||||
|
pdf.SetFont("Arial", "B", 11)
|
||||||
|
pdf.SetTextColor(201, 106, 58)
|
||||||
|
pdf.Cell(40, 7, "ATT BETALA:")
|
||||||
|
pdf.Cell(30, 7, fmt.Sprintf("%.2f %s", data.Total, data.Currency))
|
||||||
|
pdf.Ln(10)
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
if data.Notes != "" {
|
||||||
|
pdf.SetFont("Arial", "I", 8)
|
||||||
|
pdf.SetTextColor(100, 100, 100)
|
||||||
|
pdf.MultiCell(0, 4, data.Notes, "", "", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
pdf.SetY(-20)
|
||||||
|
pdf.SetFont("Arial", "", 8)
|
||||||
|
pdf.SetTextColor(150, 150, 150)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("%s | Faktura %s | Sida %d", data.CompanyName, data.InvoiceNumber, pdf.PageNo()))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := pdf.Output(&buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQuote creates a professional quote PDF
|
||||||
|
func GenerateQuote(data QuoteData) ([]byte, error) {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
pdf.SetFont("Arial", "B", 20)
|
||||||
|
pdf.SetTextColor(201, 106, 58)
|
||||||
|
pdf.Cell(0, 12, "OFFERT")
|
||||||
|
pdf.Ln(8)
|
||||||
|
|
||||||
|
// Company info
|
||||||
|
pdf.SetFont("Arial", "B", 10)
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.Cell(0, 5, data.CompanyName)
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.Cell(0, 4, data.CompanyAddress)
|
||||||
|
pdf.Ln(5)
|
||||||
|
|
||||||
|
// Quote details box
|
||||||
|
pdf.SetFillColor(250, 248, 245)
|
||||||
|
pdf.Rect(130, 30, 70, 30, "F")
|
||||||
|
pdf.SetXY(135, 33)
|
||||||
|
pdf.SetFont("Arial", "B", 9)
|
||||||
|
pdf.SetTextColor(201, 106, 58)
|
||||||
|
pdf.Cell(0, 5, "OFFERTINFORMATION")
|
||||||
|
pdf.Ln(6)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.SetX(135)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Offertnr: %s", data.QuoteNumber))
|
||||||
|
pdf.Ln(4)
|
||||||
|
pdf.SetX(135)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Datum: %s", data.QuoteDate.Format("2006-01-02")))
|
||||||
|
pdf.Ln(4)
|
||||||
|
pdf.SetX(135)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("Giltig till: %s", data.ValidUntil.Format("2006-01-02")))
|
||||||
|
pdf.Ln(4)
|
||||||
|
|
||||||
|
// Customer info
|
||||||
|
pdf.SetXY(10, 75)
|
||||||
|
pdf.SetFont("Arial", "B", 10)
|
||||||
|
pdf.SetTextColor(201, 106, 58)
|
||||||
|
pdf.Cell(0, 5, "KUND")
|
||||||
|
pdf.Ln(6)
|
||||||
|
pdf.SetFont("Arial", "B", 10)
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.Cell(0, 5, data.CustomerName)
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.Cell(0, 4, data.CustomerAddress)
|
||||||
|
pdf.Ln(10)
|
||||||
|
|
||||||
|
// Items table header
|
||||||
|
pdf.SetFillColor(201, 106, 58)
|
||||||
|
pdf.SetTextColor(255, 255, 255)
|
||||||
|
pdf.SetFont("Arial", "B", 9)
|
||||||
|
pdf.Cell(80, 8, "Beskrivning")
|
||||||
|
pdf.Cell(25, 8, "Antal")
|
||||||
|
pdf.Cell(25, 8, "Enhet")
|
||||||
|
pdf.Cell(30, 8, "Pris")
|
||||||
|
pdf.Cell(30, 8, "Belopp")
|
||||||
|
pdf.Ln(8)
|
||||||
|
|
||||||
|
// Items
|
||||||
|
pdf.SetTextColor(50, 50, 50)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
for i, item := range data.Items {
|
||||||
|
if i%2 == 0 {
|
||||||
|
pdf.SetFillColor(250, 248, 245)
|
||||||
|
pdf.Rect(10, pdf.GetY(), 190, 6, "F")
|
||||||
|
}
|
||||||
|
pdf.Cell(80, 6, item.Description)
|
||||||
|
pdf.Cell(25, 6, fmt.Sprintf("%.2f", item.Quantity))
|
||||||
|
pdf.Cell(25, 6, item.Unit)
|
||||||
|
pdf.Cell(30, 6, fmt.Sprintf("%.2f", item.UnitPrice))
|
||||||
|
pdf.Cell(30, 6, fmt.Sprintf("%.2f", item.Total))
|
||||||
|
pdf.Ln(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.Ln(5)
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
pdf.SetX(120)
|
||||||
|
pdf.SetFont("Arial", "", 9)
|
||||||
|
pdf.Cell(40, 5, "Delsumma:")
|
||||||
|
pdf.Cell(30, 5, fmt.Sprintf("%.2f %s", data.Subtotal, data.Currency))
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetX(120)
|
||||||
|
pdf.Cell(40, 5, fmt.Sprintf("Moms (%.0f%%):", data.VATRate*100))
|
||||||
|
pdf.Cell(30, 5, fmt.Sprintf("%.2f %s", data.VATAmount, data.Currency))
|
||||||
|
pdf.Ln(5)
|
||||||
|
pdf.SetX(120)
|
||||||
|
pdf.SetFont("Arial", "B", 11)
|
||||||
|
pdf.SetTextColor(201, 106, 58)
|
||||||
|
pdf.Cell(40, 7, "TOTALT:")
|
||||||
|
pdf.Cell(30, 7, fmt.Sprintf("%.2f %s", data.Total, data.Currency))
|
||||||
|
pdf.Ln(10)
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
if data.Notes != "" {
|
||||||
|
pdf.SetFont("Arial", "I", 8)
|
||||||
|
pdf.SetTextColor(100, 100, 100)
|
||||||
|
pdf.MultiCell(0, 4, data.Notes, "", "", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
pdf.SetY(-20)
|
||||||
|
pdf.SetFont("Arial", "", 8)
|
||||||
|
pdf.SetTextColor(150, 150, 150)
|
||||||
|
pdf.Cell(0, 4, fmt.Sprintf("%s | Offert %s | Sida %d", data.CompanyName, data.QuoteNumber, pdf.PageNo()))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := pdf.Output(&buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // Allow all origins in development
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client represents a WebSocket client connection
|
||||||
|
type Client struct {
|
||||||
|
hub *Hub
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
tenantID string
|
||||||
|
userID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub maintains the set of active clients and broadcasts messages
|
||||||
|
type Hub struct {
|
||||||
|
clients map[*Client]bool
|
||||||
|
broadcast chan []byte
|
||||||
|
register chan *Client
|
||||||
|
unregister chan *Client
|
||||||
|
logger zerolog.Logger
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates a new WebSocket hub
|
||||||
|
func NewHub(logger zerolog.Logger) *Hub {
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
broadcast: make(chan []byte),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
logger: logger.With().Str("component", "websocket").Logger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the hub's event loop
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case client := <-h.register:
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[client] = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.logger.Info().Str("tenant", client.tenantID).Msg("client connected")
|
||||||
|
|
||||||
|
case client := <-h.unregister:
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.clients[client]; ok {
|
||||||
|
delete(h.clients, client)
|
||||||
|
close(client.send)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.logger.Info().Str("tenant", client.tenantID).Msg("client disconnected")
|
||||||
|
|
||||||
|
case message := <-h.broadcast:
|
||||||
|
h.mu.RLock()
|
||||||
|
for client := range h.clients {
|
||||||
|
select {
|
||||||
|
case client.send <- message:
|
||||||
|
default:
|
||||||
|
// Client's send channel is full, close it
|
||||||
|
close(client.send)
|
||||||
|
delete(h.clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebSocket upgrades HTTP connection to WebSocket
|
||||||
|
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error().Err(err).Msg("websocket upgrade failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tenant and user from query params (in production, verify JWT)
|
||||||
|
tenantID := r.URL.Query().Get("tenant_id")
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
hub: h,
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
tenantID: tenantID,
|
||||||
|
userID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
client.hub.register <- client
|
||||||
|
|
||||||
|
// Start goroutines for reading and writing
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends a message to all connected clients
|
||||||
|
func (h *Hub) Broadcast(message []byte) {
|
||||||
|
h.broadcast <- message
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastToTenant sends a message to clients of a specific tenant
|
||||||
|
func (h *Hub) BroadcastToTenant(tenantID string, message []byte) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
for client := range h.clients {
|
||||||
|
if client.tenantID == tenantID {
|
||||||
|
select {
|
||||||
|
case client.send <- message:
|
||||||
|
default:
|
||||||
|
// Channel full, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPump handles incoming messages from the client
|
||||||
|
func (c *Client) readPump() {
|
||||||
|
defer func() {
|
||||||
|
c.hub.unregister <- c
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
c.conn.SetPongHandler(func(string) error {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, message, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
c.hub.logger.Error().Err(err).Msg("websocket read error")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages (e.g., subscribe to events)
|
||||||
|
c.hub.logger.Debug().Str("message", string(message)).Msg("received websocket message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePump handles outgoing messages to the client
|
||||||
|
func (c *Client) writePump() {
|
||||||
|
ticker := time.NewTicker(54 * time.Second)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.send:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if !ok {
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.WriteMessage(websocket.TextMessage, message)
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event types for WebSocket messages
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventDealUpdated EventType = "deal_updated"
|
||||||
|
EventInvoiceCreated EventType = "invoice_created"
|
||||||
|
EventTicketUpdated EventType = "ticket_updated"
|
||||||
|
EventEmployeeUpdated EventType = "employee_updated"
|
||||||
|
EventContractReminder EventType = "contract_reminder"
|
||||||
|
EventReportReady EventType = "report_ready"
|
||||||
|
EventWorkflowRun EventType = "workflow_run"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents a real-time event
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEvent sends an event to relevant clients
|
||||||
|
func (h *Hub) SendEvent(event Event) {
|
||||||
|
// In production, filter by tenant and user permissions
|
||||||
|
message, _ := json.Marshal(event)
|
||||||
|
h.BroadcastToTenant(event.TenantID, message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM gcc:14 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build shared library
|
||||||
|
RUN gcc -shared -fPIC -O3 -o libboc_ipc.so src/ipc.c \
|
||||||
|
-lpthread -lrt
|
||||||
|
|
||||||
|
# Build static library
|
||||||
|
RUN gcc -c -O3 -o ipc.o src/ipc.c && \
|
||||||
|
ar rcs libboc_ipc.a ipc.o
|
||||||
|
|
||||||
|
# Final stage - minimal runtime
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy libraries
|
||||||
|
COPY --from=builder /app/libboc_ipc.so /usr/local/lib/
|
||||||
|
COPY --from=builder /app/libboc_ipc.a /usr/local/lib/
|
||||||
|
COPY --from=builder /app/src/ipc.h /usr/local/include/
|
||||||
|
|
||||||
|
# Update library cache
|
||||||
|
RUN ldconfig /usr/local/lib || true
|
||||||
|
|
||||||
|
# Default command - keep container running for IPC
|
||||||
|
CMD ["sh", "-c", "echo 'BOC C Runtime ready' && tail -f /dev/null"]
|
||||||
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,363 @@
|
|||||||
|
#include "ipc.h"
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
// Shared memory implementation
|
||||||
|
int boc_shm_create(const char* name, size_t size) {
|
||||||
|
int fd = shm_open(name, O_CREAT | O_RDWR | O_EXCL, 0644);
|
||||||
|
if (fd < 0) {
|
||||||
|
if (errno == EEXIST) {
|
||||||
|
// Already exists, try to open
|
||||||
|
return boc_shm_open(name);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ftruncate(fd, size) < 0) {
|
||||||
|
close(fd);
|
||||||
|
shm_unlink(name);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_shm_open(const char* name) {
|
||||||
|
int fd = shm_open(name, O_RDWR, 0644);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
void* boc_shm_map(int fd, size_t size) {
|
||||||
|
void* addr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||||
|
if (addr == MAP_FAILED) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_shm_unmap(void* addr, size_t size) {
|
||||||
|
return munmap(addr, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_shm_destroy(const char* name) {
|
||||||
|
return shm_unlink(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring buffer implementation
|
||||||
|
int boc_ring_init(boc_ring_buffer_t* ring) {
|
||||||
|
if (!ring) return -1;
|
||||||
|
|
||||||
|
ring->write_idx = 0;
|
||||||
|
ring->read_idx = 0;
|
||||||
|
ring->flags = 0;
|
||||||
|
memset(ring->data, 0, BOC_RING_SIZE);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_ring_write(boc_ring_buffer_t* ring, const void* data, size_t len) {
|
||||||
|
if (!ring || !data || len == 0 || len > BOC_MAX_MSG_SIZE) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t write_idx = ring->write_idx;
|
||||||
|
uint64_t read_idx = ring->read_idx;
|
||||||
|
|
||||||
|
// Check available space (leave 1 byte gap to distinguish full from empty)
|
||||||
|
uint64_t available = (read_idx > write_idx)
|
||||||
|
? (read_idx - write_idx - 1)
|
||||||
|
: (BOC_RING_SIZE - write_idx + read_idx - 1);
|
||||||
|
|
||||||
|
// Need space for length (4 bytes) + data
|
||||||
|
size_t total_len = sizeof(uint32_t) + len;
|
||||||
|
if (available < total_len) {
|
||||||
|
return -1; // Buffer full
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write length prefix
|
||||||
|
uint32_t len32 = (uint32_t)len;
|
||||||
|
for (size_t i = 0; i < sizeof(uint32_t); i++) {
|
||||||
|
ring->data[write_idx % BOC_RING_SIZE] = ((char*)&len32)[i];
|
||||||
|
write_idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
ring->data[write_idx % BOC_RING_SIZE] = ((char*)data)[i];
|
||||||
|
write_idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory barrier to ensure data is written before updating index
|
||||||
|
__sync_synchronize();
|
||||||
|
ring->write_idx = write_idx;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_ring_read(boc_ring_buffer_t* ring, void* data, size_t max_len) {
|
||||||
|
if (!ring || !data || max_len == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t write_idx = ring->write_idx;
|
||||||
|
uint64_t read_idx = ring->read_idx;
|
||||||
|
|
||||||
|
if (write_idx == read_idx) {
|
||||||
|
return 0; // Empty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read length prefix
|
||||||
|
uint32_t len = 0;
|
||||||
|
for (size_t i = 0; i < sizeof(uint32_t); i++) {
|
||||||
|
((char*)&len)[i] = ring->data[read_idx % BOC_RING_SIZE];
|
||||||
|
read_idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len > max_len) {
|
||||||
|
return -1; // Buffer too small
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read data
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
((char*)data)[i] = ring->data[read_idx % BOC_RING_SIZE];
|
||||||
|
read_idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory barrier
|
||||||
|
__sync_synchronize();
|
||||||
|
ring->read_idx = read_idx;
|
||||||
|
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool boc_ring_empty(boc_ring_buffer_t* ring) {
|
||||||
|
return ring->write_idx == ring->read_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t boc_ring_available(boc_ring_buffer_t* ring) {
|
||||||
|
uint64_t write_idx = ring->write_idx;
|
||||||
|
uint64_t read_idx = ring->read_idx;
|
||||||
|
|
||||||
|
if (write_idx >= read_idx) {
|
||||||
|
return write_idx - read_idx;
|
||||||
|
} else {
|
||||||
|
return BOC_RING_SIZE - read_idx + write_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message serialization
|
||||||
|
size_t boc_msg_serialize(boc_msg_t* msg, char* buf, size_t buf_size) {
|
||||||
|
if (!msg || !buf || buf_size < sizeof(boc_msg_t)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t total_size = sizeof(boc_msg_t) + msg->length;
|
||||||
|
if (buf_size < total_size) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(buf, msg, sizeof(boc_msg_t));
|
||||||
|
if (msg->length > 0 && msg->payload) {
|
||||||
|
memcpy(buf + sizeof(boc_msg_t), msg->payload, msg->length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_msg_deserialize(const char* buf, size_t len, boc_msg_t** msg) {
|
||||||
|
if (!buf || len < sizeof(boc_msg_t) || !msg) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
boc_msg_t* header = (boc_msg_t*)buf;
|
||||||
|
size_t total_size = sizeof(boc_msg_t) + header->length;
|
||||||
|
|
||||||
|
if (len < total_size) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*msg = malloc(total_size);
|
||||||
|
if (!*msg) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(*msg, buf, total_size);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void boc_msg_free(boc_msg_t* msg) {
|
||||||
|
free(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-level IPC channel
|
||||||
|
struct boc_ipc_channel {
|
||||||
|
char name[256];
|
||||||
|
boc_ring_buffer_t* request_ring;
|
||||||
|
boc_ring_buffer_t* response_ring;
|
||||||
|
int shm_fd;
|
||||||
|
void* shm_addr;
|
||||||
|
size_t shm_size;
|
||||||
|
};
|
||||||
|
|
||||||
|
boc_ipc_channel_t* boc_ipc_connect(const char* channel_name) {
|
||||||
|
boc_ipc_channel_t* channel = calloc(1, sizeof(boc_ipc_channel_t));
|
||||||
|
if (!channel) return NULL;
|
||||||
|
|
||||||
|
strncpy(channel->name, channel_name, sizeof(channel->name) - 1);
|
||||||
|
|
||||||
|
// Create shared memory for two ring buffers
|
||||||
|
channel->shm_size = sizeof(boc_ring_buffer_t) * 2;
|
||||||
|
|
||||||
|
char shm_name[512];
|
||||||
|
snprintf(shm_name, sizeof(shm_name), "/boc_ipc_%s", channel_name);
|
||||||
|
|
||||||
|
channel->shm_fd = boc_shm_create(shm_name, channel->shm_size);
|
||||||
|
if (channel->shm_fd < 0) {
|
||||||
|
free(channel);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel->shm_addr = boc_shm_map(channel->shm_fd, channel->shm_size);
|
||||||
|
if (!channel->shm_addr) {
|
||||||
|
close(channel->shm_fd);
|
||||||
|
shm_unlink(shm_name);
|
||||||
|
free(channel);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize rings
|
||||||
|
channel->request_ring = (boc_ring_buffer_t*)channel->shm_addr;
|
||||||
|
channel->response_ring = (boc_ring_buffer_t*)(channel->shm_addr + sizeof(boc_ring_buffer_t));
|
||||||
|
|
||||||
|
boc_ring_init(channel->request_ring);
|
||||||
|
boc_ring_init(channel->response_ring);
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
void boc_ipc_disconnect(boc_ipc_channel_t* channel) {
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
if (channel->shm_addr) {
|
||||||
|
boc_shm_unmap(channel->shm_addr, channel->shm_size);
|
||||||
|
}
|
||||||
|
if (channel->shm_fd >= 0) {
|
||||||
|
close(channel->shm_fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
char shm_name[512];
|
||||||
|
snprintf(shm_name, sizeof(shm_name), "/boc_ipc_%s", channel->name);
|
||||||
|
boc_shm_destroy(shm_name);
|
||||||
|
|
||||||
|
free(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_ipc_send(boc_ipc_channel_t* channel, boc_msg_t* msg) {
|
||||||
|
if (!channel || !msg) return -1;
|
||||||
|
|
||||||
|
char buf[BOC_MAX_MSG_SIZE];
|
||||||
|
size_t len = boc_msg_serialize(msg, buf, sizeof(buf));
|
||||||
|
if (len == 0) return -1;
|
||||||
|
|
||||||
|
return boc_ring_write(channel->request_ring, buf, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_ipc_recv(boc_ipc_channel_t* channel, boc_msg_t** msg, int timeout_ms) {
|
||||||
|
if (!channel || !msg) return -1;
|
||||||
|
|
||||||
|
char buf[BOC_MAX_MSG_SIZE];
|
||||||
|
|
||||||
|
// Simple polling with timeout
|
||||||
|
int waited = 0;
|
||||||
|
while (waited < timeout_ms) {
|
||||||
|
int len = boc_ring_read(channel->response_ring, buf, sizeof(buf));
|
||||||
|
if (len > 0) {
|
||||||
|
return boc_msg_deserialize(buf, len, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(1000); // 1ms
|
||||||
|
waited += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1; // Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics cache implementation
|
||||||
|
int boc_cache_init(boc_analytics_cache_t* cache) {
|
||||||
|
if (!cache) return -1;
|
||||||
|
|
||||||
|
cache->version = 1;
|
||||||
|
for (int i = 0; i < BOC_CACHE_SIZE; i++) {
|
||||||
|
cache->entries[i].valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint64_t hash_key(uint64_t key_hash) {
|
||||||
|
return key_hash % BOC_CACHE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_cache_get(boc_analytics_cache_t* cache, uint64_t key_hash, double* value, double* trend) {
|
||||||
|
if (!cache || !value || !trend) return -1;
|
||||||
|
|
||||||
|
uint64_t idx = hash_key(key_hash);
|
||||||
|
boc_cache_entry_t* entry = &cache->entries[idx];
|
||||||
|
|
||||||
|
if (!entry->valid || entry->key_hash != key_hash) {
|
||||||
|
return -1; // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TTL
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
uint64_t now = ts.tv_sec;
|
||||||
|
|
||||||
|
if (now > entry->timestamp + entry->ttl_seconds) {
|
||||||
|
entry->valid = false;
|
||||||
|
return -1; // Expired
|
||||||
|
}
|
||||||
|
|
||||||
|
*value = entry->value;
|
||||||
|
*trend = entry->trend;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int boc_cache_set(boc_analytics_cache_t* cache, uint64_t key_hash, double value, double trend, uint32_t ttl) {
|
||||||
|
if (!cache) return -1;
|
||||||
|
|
||||||
|
uint64_t idx = hash_key(key_hash);
|
||||||
|
boc_cache_entry_t* entry = &cache->entries[idx];
|
||||||
|
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
|
||||||
|
entry->key_hash = key_hash;
|
||||||
|
entry->value = value;
|
||||||
|
entry->trend = trend;
|
||||||
|
entry->timestamp = ts.tv_sec;
|
||||||
|
entry->ttl_seconds = ttl;
|
||||||
|
entry->valid = true;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void boc_cache_invalidate(boc_analytics_cache_t* cache, uint64_t key_hash) {
|
||||||
|
if (!cache) return;
|
||||||
|
|
||||||
|
uint64_t idx = hash_key(key_hash);
|
||||||
|
boc_cache_entry_t* entry = &cache->entries[idx];
|
||||||
|
|
||||||
|
if (entry->key_hash == key_hash) {
|
||||||
|
entry->valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#ifndef BOC_IPC_H
|
||||||
|
#define BOC_IPC_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Shared memory ring buffer for high-throughput communication
|
||||||
|
#define BOC_SHM_SIZE (1024 * 1024 * 16) // 16MB
|
||||||
|
#define BOC_RING_SIZE (1024 * 64) // 64KB buffer
|
||||||
|
#define BOC_MAX_MSG_SIZE 8192
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
BOC_MSG_ANALYTICS_REQUEST = 1,
|
||||||
|
BOC_MSG_ANALYTICS_RESPONSE = 2,
|
||||||
|
BOC_MSG_REPORT_REQUEST = 3,
|
||||||
|
BOC_MSG_REPORT_RESPONSE = 4,
|
||||||
|
BOC_MSG_EVENT = 5,
|
||||||
|
BOC_MSG_HEARTBEAT = 6,
|
||||||
|
} boc_msg_type_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t type;
|
||||||
|
uint32_t length;
|
||||||
|
uint64_t timestamp;
|
||||||
|
uint64_t correlation_id;
|
||||||
|
char payload[];
|
||||||
|
} boc_msg_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
volatile uint64_t write_idx;
|
||||||
|
volatile uint64_t read_idx;
|
||||||
|
volatile uint32_t flags;
|
||||||
|
char data[BOC_RING_SIZE];
|
||||||
|
} boc_ring_buffer_t;
|
||||||
|
|
||||||
|
// Shared memory API
|
||||||
|
int boc_shm_create(const char* name, size_t size);
|
||||||
|
int boc_shm_open(const char* name);
|
||||||
|
void* boc_shm_map(int fd, size_t size);
|
||||||
|
int boc_shm_unmap(void* addr, size_t size);
|
||||||
|
int boc_shm_destroy(const char* name);
|
||||||
|
|
||||||
|
// Ring buffer API
|
||||||
|
int boc_ring_init(boc_ring_buffer_t* ring);
|
||||||
|
int boc_ring_write(boc_ring_buffer_t* ring, const void* data, size_t len);
|
||||||
|
int boc_ring_read(boc_ring_buffer_t* ring, void* data, size_t max_len);
|
||||||
|
bool boc_ring_empty(boc_ring_buffer_t* ring);
|
||||||
|
uint64_t boc_ring_available(boc_ring_buffer_t* ring);
|
||||||
|
|
||||||
|
// Message serialization
|
||||||
|
size_t boc_msg_serialize(boc_msg_t* msg, char* buf, size_t buf_size);
|
||||||
|
int boc_msg_deserialize(const char* buf, size_t len, boc_msg_t** msg);
|
||||||
|
void boc_msg_free(boc_msg_t* msg);
|
||||||
|
|
||||||
|
// High-level API for Go/Rust interop
|
||||||
|
typedef struct boc_ipc_channel boc_ipc_channel_t;
|
||||||
|
|
||||||
|
boc_ipc_channel_t* boc_ipc_connect(const char* channel_name);
|
||||||
|
void boc_ipc_disconnect(boc_ipc_channel_t* channel);
|
||||||
|
int boc_ipc_send(boc_ipc_channel_t* channel, boc_msg_t* msg);
|
||||||
|
int boc_ipc_recv(boc_ipc_channel_t* channel, boc_msg_t** msg, int timeout_ms);
|
||||||
|
|
||||||
|
// Analytics cache in shared memory
|
||||||
|
typedef struct {
|
||||||
|
uint64_t key_hash;
|
||||||
|
double value;
|
||||||
|
double trend;
|
||||||
|
uint64_t timestamp;
|
||||||
|
uint32_t ttl_seconds;
|
||||||
|
bool valid;
|
||||||
|
} boc_cache_entry_t;
|
||||||
|
|
||||||
|
#define BOC_CACHE_SIZE 1024
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
volatile uint32_t version;
|
||||||
|
boc_cache_entry_t entries[BOC_CACHE_SIZE];
|
||||||
|
} boc_analytics_cache_t;
|
||||||
|
|
||||||
|
int boc_cache_init(boc_analytics_cache_t* cache);
|
||||||
|
int boc_cache_get(boc_analytics_cache_t* cache, uint64_t key_hash, double* value, double* trend);
|
||||||
|
int boc_cache_set(boc_analytics_cache_t* cache, uint64_t key_hash, double value, double trend, uint32_t ttl);
|
||||||
|
void boc_cache_invalidate(boc_analytics_cache_t* cache, uint64_t key_hash);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif // BOC_IPC_H
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# BOC Deployment Script
|
||||||
|
# Deploys the entire BOC stack to production
|
||||||
|
|
||||||
|
echo "🚀 BOC Deployment Starting..."
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SERVER=${SERVER:-"bernt.wavult.com"}
|
||||||
|
SSH_USER=${SSH_USER:-"bernt"}
|
||||||
|
DEPLOY_DIR=${DEPLOY_DIR:-"/opt/boc"}
|
||||||
|
BACKUP_DIR=${BACKUP_DIR:-"/opt/backups/boc"}
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pre-deployment checks
|
||||||
|
check_prerequisites() {
|
||||||
|
log_info "Checking prerequisites..."
|
||||||
|
|
||||||
|
# Check Docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
log_error "Docker is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Docker Compose
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
log_error "Docker Compose is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check SSH access
|
||||||
|
if ! ssh -o ConnectTimeout=5 "${SSH_USER}@${SERVER}" echo "OK" &> /dev/null; then
|
||||||
|
log_error "Cannot connect to ${SERVER} via SSH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Prerequisites OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build locally
|
||||||
|
build_locally() {
|
||||||
|
log_info "Building BOC stack..."
|
||||||
|
make clean
|
||||||
|
make build
|
||||||
|
log_info "Build complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
backup_database() {
|
||||||
|
log_info "Creating database backup..."
|
||||||
|
ssh "${SSH_USER}@${SERVER}" "
|
||||||
|
mkdir -p ${BACKUP_DIR}
|
||||||
|
docker exec boc-postgres pg_dump -U boc boc | gzip > ${BACKUP_DIR}/boc-$(date +%Y%m%d-%H%M%S).sql.gz
|
||||||
|
" || log_warn "Database backup failed, continuing..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deploy to server
|
||||||
|
deploy_to_server() {
|
||||||
|
log_info "Deploying to ${SERVER}..."
|
||||||
|
|
||||||
|
# Create deploy directory
|
||||||
|
ssh "${SSH_USER}@${SERVER}" "mkdir -p ${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
# Sync files
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='backend/boc' \
|
||||||
|
--exclude='rust-service/target' \
|
||||||
|
--exclude='c-runtime/*.o' \
|
||||||
|
--exclude='c-runtime/*.so' \
|
||||||
|
--exclude='c-runtime/*.a' \
|
||||||
|
./ "${SSH_USER}@${SERVER}:${DEPLOY_DIR}/"
|
||||||
|
|
||||||
|
log_info "Files synced"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
start_services() {
|
||||||
|
log_info "Starting services..."
|
||||||
|
|
||||||
|
ssh "${SSH_USER}@${SERVER}" "
|
||||||
|
cd ${DEPLOY_DIR}
|
||||||
|
|
||||||
|
# Pull latest images
|
||||||
|
docker-compose pull
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Wait for health checks
|
||||||
|
echo 'Waiting for services to be healthy...'
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check health
|
||||||
|
docker-compose ps
|
||||||
|
"
|
||||||
|
|
||||||
|
log_info "Services started"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify deployment
|
||||||
|
verify_deployment() {
|
||||||
|
log_info "Verifying deployment..."
|
||||||
|
|
||||||
|
# Check API health
|
||||||
|
if curl -sf "http://${SERVER}/health" > /dev/null; then
|
||||||
|
log_info "API is healthy"
|
||||||
|
else
|
||||||
|
log_error "API health check failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Rust service
|
||||||
|
if curl -sf "http://${SERVER}/rust/health" > /dev/null; then
|
||||||
|
log_info "Rust service is healthy"
|
||||||
|
else
|
||||||
|
log_warn "Rust service health check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Deployment verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rollback on failure
|
||||||
|
rollback() {
|
||||||
|
log_error "Deployment failed, rolling back..."
|
||||||
|
ssh "${SSH_USER}@${SERVER}" "
|
||||||
|
cd ${DEPLOY_DIR}
|
||||||
|
docker-compose down
|
||||||
|
# Restore from latest backup
|
||||||
|
LATEST_BACKUP=\$(ls -t ${BACKUP_DIR}/*.sql.gz | head -1)
|
||||||
|
if [ -n \"\$LATEST_BACKUP\" ]; then
|
||||||
|
zcat \$LATEST_BACKUP | docker exec -i boc-postgres psql -U boc boc
|
||||||
|
fi
|
||||||
|
docker-compose up -d
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main deployment flow
|
||||||
|
main() {
|
||||||
|
echo "================================"
|
||||||
|
echo " BOC Deployment"
|
||||||
|
echo " Target: ${SERVER}"
|
||||||
|
echo " Time: $(date)"
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
|
check_prerequisites
|
||||||
|
build_locally
|
||||||
|
backup_database
|
||||||
|
deploy_to_server
|
||||||
|
|
||||||
|
if start_services; then
|
||||||
|
verify_deployment
|
||||||
|
log_info "🎉 Deployment successful!"
|
||||||
|
echo ""
|
||||||
|
echo "BOC is now running at:"
|
||||||
|
echo " Dashboard: http://${SERVER}"
|
||||||
|
echo " API: http://${SERVER}/api/v1"
|
||||||
|
echo " Kafka UI: http://${SERVER}/kafka-ui"
|
||||||
|
else
|
||||||
|
rollback
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main
|
||||||
|
trap 'log_error "Deployment interrupted"; exit 1' INT TERM
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: boc-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: boc
|
||||||
|
POSTGRES_PASSWORD: boc_secret_2026
|
||||||
|
POSTGRES_DB: boc
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5435:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U boc"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
# Redis — Cache, Sessions, Rate Limiting, Pub/Sub
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: boc-redis
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6381:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
# Kafka — Event Streaming
|
||||||
|
zookeeper:
|
||||||
|
image: confluentinc/cp-zookeeper:7.5.0
|
||||||
|
container_name: boc-zookeeper
|
||||||
|
environment:
|
||||||
|
ZOOKEEPER_CLIENT_PORT: 2181
|
||||||
|
ZOOKEEPER_TICK_TIME: 2000
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
kafka:
|
||||||
|
image: confluentinc/cp-kafka:7.5.0
|
||||||
|
container_name: boc-kafka
|
||||||
|
depends_on:
|
||||||
|
- zookeeper
|
||||||
|
ports:
|
||||||
|
- "9095:9092"
|
||||||
|
environment:
|
||||||
|
KAFKA_BROKER_ID: 1
|
||||||
|
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||||
|
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://kafka:29092
|
||||||
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT
|
||||||
|
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
|
||||||
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
|
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
# Kafka UI
|
||||||
|
kafka-ui:
|
||||||
|
image: provectuslabs/kafka-ui:latest
|
||||||
|
container_name: boc-kafka-ui
|
||||||
|
ports:
|
||||||
|
- "8084:8080"
|
||||||
|
environment:
|
||||||
|
KAFKA_CLUSTERS_0_NAME: boc
|
||||||
|
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
|
||||||
|
KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
|
||||||
|
depends_on:
|
||||||
|
- kafka
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
# Go Backend API
|
||||||
|
boc-api:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: boc-api
|
||||||
|
environment:
|
||||||
|
PORT: "9092"
|
||||||
|
DB_URL: "postgres://boc:boc_secret_2026@postgres:5432/boc?sslmode=disable"
|
||||||
|
JWT_SECRET: "boc_jwt_secret_change_in_production"
|
||||||
|
AMOS_BASE_URL: "http://aamos-ledger:3250"
|
||||||
|
MIGRATIONS_DIR: "./db/migrations"
|
||||||
|
RUST_SERVICE_URL: "http://boc-rust:9093"
|
||||||
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
KAFKA_BROKERS: "kafka:29092"
|
||||||
|
ports:
|
||||||
|
- "9096:9092"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
kafka:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend/db/migrations:/app/db/migrations:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9092/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
# Rust Analytics Service
|
||||||
|
boc-rust:
|
||||||
|
build:
|
||||||
|
context: ./rust-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: boc-rust
|
||||||
|
environment:
|
||||||
|
RUST_LOG: "info"
|
||||||
|
DB_URL: "postgres://boc:boc_secret_2026@postgres:5432/boc?sslmode=disable"
|
||||||
|
REDIS_URL: "redis://redis:6379"
|
||||||
|
KAFKA_BROKERS: "kafka:29092"
|
||||||
|
ports:
|
||||||
|
- "9093:9093"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
kafka:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
# C Runtime (shared memory / IPC)
|
||||||
|
boc-c-runtime:
|
||||||
|
build:
|
||||||
|
context: ./c-runtime
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: boc-c-runtime
|
||||||
|
environment:
|
||||||
|
SHM_NAME: "/boc_ipc_main"
|
||||||
|
SHM_SIZE: "16777216"
|
||||||
|
depends_on:
|
||||||
|
- boc-api
|
||||||
|
- boc-rust
|
||||||
|
restart: unless-stopped
|
||||||
|
privileged: true
|
||||||
|
shm_size: '32mb'
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
# Nginx Reverse Proxy
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: boc-nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./web:/usr/share/nginx/html:ro
|
||||||
|
depends_on:
|
||||||
|
- boc-api
|
||||||
|
- boc-rust
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- boc-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
boc-network:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# BOC Feature Gap Analysis
|
||||||
|
## Referenssystem: Fortnox, Odoo, Visma eAccounting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ REDAN IMPLEMENTERAT
|
||||||
|
|
||||||
|
### Core CRM
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Kundregister | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Kontaktpersoner | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Interaktionshistorik | ✅ | ⚠️ | ✅ | ⚠️ |
|
||||||
|
| Lead-hantering | ✅ | ⚠️ | ✅ | ⚠️ |
|
||||||
|
| Pipeline / Deals | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### Sales
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Offert / Quote | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Orderhantering | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Fakturering | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Återkommande fakturor | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Påminnelser | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Betalningsregistrering | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Kundportal | ❌ | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### Finance / Accounting
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| BAS-kontoplan | ✅ (via aamos-ledger) | ✅ | ✅ | ✅ |
|
||||||
|
| Dubbel bokföring | ✅ (via aamos-ledger) | ✅ | ✅ | ✅ |
|
||||||
|
| Bankintegration | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Avstämning | ✅ (via aamos-ledger) | ✅ | ✅ | ✅ |
|
||||||
|
| Momsredovisning | ✅ (via aamos-ledger) | ✅ | ✅ | ✅ |
|
||||||
|
| SIE4-import/export | ✅ (via aamos-ledger) | ✅ | ❌ | ⚠️ |
|
||||||
|
| Kvittohantering / OCR | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Leverantörsfakturor | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Utbetalningar | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Budget | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Kostnadsställen | ❌ | ✅ | ✅ | ❌ |
|
||||||
|
| Projektredovisning | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### HR / Payroll
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Anställda | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Lönekörning | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Tidrapport | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Semester / Ledighet | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Sjukfrånvaro | ❌ | ✅ | ✅ | ❌ |
|
||||||
|
| Reseräkning | ❌ | ✅ | ✅ | ❌ |
|
||||||
|
| Personalförmåner | ❌ | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### Inventory / Products
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Produktregister | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Lagerhantering | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| Inköp | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Leverantörer | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
### Legal
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Kontrakt | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| Påminnelser | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| Digital signering | ❌ | ❌ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### Marketing
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Kampanjer | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| E-postutskick | ❌ | ❌ | ✅ | ❌ |
|
||||||
|
| Landningssidor | ❌ | ❌ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### Support
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Ärenden | ✅ | ❌ | ✅ | ❌ |
|
||||||
|
| Kunskapsbas | ❌ | ❌ | ✅ | ❌ |
|
||||||
|
| Live chat | ❌ | ❌ | ✅ | ❌ |
|
||||||
|
|
||||||
|
### Analytics & Reporting
|
||||||
|
| Feature | BOC | Fortnox | Odoo | Visma |
|
||||||
|
|---------|-----|---------|------|-------|
|
||||||
|
| Dashboard | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Standardrapporter | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Anpassade rapporter | ❌ | ✅ | ✅ | ❌ |
|
||||||
|
| Export till Excel/PDF | ❌ | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 KRITISKA SAKER (MÅSTE FINNAS)
|
||||||
|
|
||||||
|
### 1. Offert & Order (Sales)
|
||||||
|
**Varför:** Fortnox, Odoo, Visma — alla har detta. Utan offert kan man inte sälja professionellt.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- Offertmallar
|
||||||
|
- Offert → Order → Faktura flöde
|
||||||
|
- Godkännandeworkflow
|
||||||
|
- Digital signering av offerter
|
||||||
|
|
||||||
|
### 2. Bankintegration
|
||||||
|
**Varför:** Fortnox har 10+ banker. Automatisk avstämning sparar timmar.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- PSD2/Open Banking koppling
|
||||||
|
- Automatisk transaktionsimport
|
||||||
|
- Avstämningsmotor
|
||||||
|
- Betalningsfilgenerering (BG/PG)
|
||||||
|
|
||||||
|
### 3. Leverantörsreskontra
|
||||||
|
**Varför:** Man måste kunna hantera inköp och leverantörsfakturor.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- Leverantörsregister
|
||||||
|
- Inköpsorder
|
||||||
|
- Leverantörsfakturor
|
||||||
|
- Betalningsplanering
|
||||||
|
- Kreditnotor
|
||||||
|
|
||||||
|
### 4. Lönekörning (Payroll)
|
||||||
|
**Varför:** Fortnox "People" är en av deras största moduler.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- Löneberäkning (skatt, arbetsgivaravgift)
|
||||||
|
- Lönespecifikation
|
||||||
|
- AGI-rapportering till Skatteverket
|
||||||
|
- Semesterberäkning
|
||||||
|
- Sjuklön
|
||||||
|
|
||||||
|
### 5. Kvittohantering / Expense OCR
|
||||||
|
**Varför:** Fortnox Business Card + OCR är en killer feature.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- Foto av kvitto
|
||||||
|
- OCR (texterkänning)
|
||||||
|
- Automatisk kontering
|
||||||
|
- Godkännandeflow
|
||||||
|
- Utbetalning
|
||||||
|
|
||||||
|
### 6. Återkommande fakturor
|
||||||
|
**Varför:** SaaS-företag behöver prenumerationsfakturering.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- Prenumerationsplaner
|
||||||
|
- Automatisk fakturagenerering
|
||||||
|
- Påminnelser
|
||||||
|
- Kreditkortsdragning (Stripe)
|
||||||
|
|
||||||
|
### 7. Lagerhantering (Inventory)
|
||||||
|
**Varför:** Odoo har avancerat lager. Även enkelt lager behövs.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- Lagersaldo
|
||||||
|
- In/ut-leveranser
|
||||||
|
- Larm vid lågt saldo
|
||||||
|
- Inventering
|
||||||
|
|
||||||
|
### 8. Projektredovisning
|
||||||
|
**Varför:** Konsultföretag behöver följa projekt lönsamhet.
|
||||||
|
|
||||||
|
**Behöver:**
|
||||||
|
- Projektregister
|
||||||
|
- Tid vs budget
|
||||||
|
- Kostnadsallokering
|
||||||
|
- Projektresultat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 BRA ATT HA (DIFFERENTIERING)
|
||||||
|
|
||||||
|
### 9. Digital signering
|
||||||
|
- Kontrakt, offerter, anställningsavtal
|
||||||
|
- Integration med Scrive, Dokobit
|
||||||
|
|
||||||
|
### 10. E-postmarknadsföring
|
||||||
|
- Nyhetsbrev
|
||||||
|
- Automatiska sekvenser
|
||||||
|
- A/B-testning
|
||||||
|
|
||||||
|
### 11. Kunskapsbas / Help Center
|
||||||
|
- Självbetjäning för kunder
|
||||||
|
- Sökbar artikeldatabas
|
||||||
|
|
||||||
|
### 12. Advanced Analytics
|
||||||
|
- Prediktiv analys
|
||||||
|
- Churn-prediktion
|
||||||
|
- LTV-modellering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PRIORITERINGSORDNING
|
||||||
|
|
||||||
|
### Etapp 1 (MVP för produktion)
|
||||||
|
1. ✅ CRM — Kunder, leads, pipeline
|
||||||
|
2. ✅ Fakturering — Skapa, skicka, påminna
|
||||||
|
3. ✅ Bokföring — BAS, moms, SIE4 (via aamos-ledger)
|
||||||
|
4. ✅ Anställda — Register, tid, ledighet
|
||||||
|
5. ✅ Kontrakt — Med påminnelser
|
||||||
|
|
||||||
|
### Etapp 2 (Säljar redo)
|
||||||
|
6. 🔄 Offert & Order
|
||||||
|
7. 🔄 Leverantörsreskontra
|
||||||
|
8. 🔄 Bankintegration (PSD2)
|
||||||
|
9. 🔄 Kvittohantering / OCR
|
||||||
|
10. 🔄 Återkommande fakturor
|
||||||
|
|
||||||
|
### Etapp 3 (Komplett ERP)
|
||||||
|
11. 🔄 Lönekörning
|
||||||
|
12. 🔄 Lagerhantering
|
||||||
|
13. 🔄 Projektredovisning
|
||||||
|
14. 🔄 Digital signering
|
||||||
|
15. 🔄 E-postmarknadsföring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 BOC UNIKT VÄRDE (vs Fortnox/Odoo/Visma)
|
||||||
|
|
||||||
|
| Funktion | BOC | Konkurrenter |
|
||||||
|
|----------|-----|--------------|
|
||||||
|
| AI-analys (Rust) | ✅ | ❌ |
|
||||||
|
| Realtidsdashboard | ✅ | ⚠️ |
|
||||||
|
| Automation engine | ✅ | ⚠️ |
|
||||||
|
| Event streaming (Kafka) | ✅ | ❌ |
|
||||||
|
| Multi-tenant från start | ✅ | ❌ |
|
||||||
|
| AAMOS-integration | ✅ | ❌ |
|
||||||
|
| Landvex-konfigurerat | ✅ | ❌ |
|
||||||
|
| Mobile-first design | ✅ | ⚠️ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Senast uppdaterad: 2026-07-12*
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# BOC Test Plan
|
||||||
|
## Bygg och testa fullt ut mot referenssystem
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teststrategi
|
||||||
|
|
||||||
|
### 1. Enhetstester (Unit Tests)
|
||||||
|
- Varje handler ska ha minst 80% coverage
|
||||||
|
- Mocka databas med sqlmock
|
||||||
|
- Testa happy path + felhantering
|
||||||
|
|
||||||
|
### 2. Integrationstester
|
||||||
|
- Starta hela stacken i Docker
|
||||||
|
- Kör API-anrop mot riktiga endpoints
|
||||||
|
- Verifiera databasstate efter varje test
|
||||||
|
|
||||||
|
### 3. Referenssystem-jämförelse
|
||||||
|
- Jämför funktionalitet mot Fortnox, Odoo, Visma
|
||||||
|
- Dokumentera skillnader
|
||||||
|
- Prioritera vad som saknas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testfall per modul
|
||||||
|
|
||||||
|
### CRM
|
||||||
|
```
|
||||||
|
✅ TC-CRM-001: Skapa kund
|
||||||
|
✅ TC-CRM-002: Uppdatera kund
|
||||||
|
✅ TC-CRM-003: Ta bort kund
|
||||||
|
✅ TC-CRM-004: Lista kunder med paginering
|
||||||
|
✅ TC-CRM-005: Sök kund på namn/email
|
||||||
|
✅ TC-CRM-006: Skapa interaktion
|
||||||
|
✅ TC-CRM-007: Visa pipeline
|
||||||
|
🔄 TC-CRM-008: Lead-scoring (AI)
|
||||||
|
🔄 TC-CRM-009: Automatisk konvertering lead→kund
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sales
|
||||||
|
```
|
||||||
|
✅ TC-SAL-001: Skapa offert
|
||||||
|
✅ TC-SAL-002: Skicka offert till kund
|
||||||
|
✅ TC-SAL-003: Kund accepterar offert
|
||||||
|
✅ TC-SAL-004: Konvertera offert till order
|
||||||
|
✅ TC-SAL-005: Skapa order direkt
|
||||||
|
✅ TC-SAL-006: Skicka order
|
||||||
|
✅ TC-SAL-007: Leverera order
|
||||||
|
✅ TC-SAL-008: Skapa faktura från order
|
||||||
|
✅ TC-SAL-009: Beräkna MRR/ARR
|
||||||
|
✅ TC-SAL-010: Prenumerationshantering
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finance
|
||||||
|
```
|
||||||
|
✅ TC-FIN-001: Skapa leverantörsfaktura
|
||||||
|
✅ TC-FIN-002: Godkänn leverantörsfaktura
|
||||||
|
✅ TC-FIN-003: Betala leverantörsfaktura
|
||||||
|
✅ TC-FIN-004: Avstämning banktransaktioner
|
||||||
|
✅ TC-FIN-005: Kvittohantering med OCR
|
||||||
|
✅ TC-FIN-006: Budgetuppföljning
|
||||||
|
✅ TC-FIN-007: Kassaflödesprognos
|
||||||
|
✅ TC-FIN-008: Integration aamos-ledger
|
||||||
|
```
|
||||||
|
|
||||||
|
### HR
|
||||||
|
```
|
||||||
|
✅ TC-HR-001: Registrera anställd
|
||||||
|
✅ TC-HR-002: Tidrapport
|
||||||
|
✅ TC-HR-003: Ledighetsansökan
|
||||||
|
✅ TC-HR-004: Godkänn ledighet
|
||||||
|
✅ TC-HR-005: Lönekörning
|
||||||
|
✅ TC-HR-006: Generera lönespecifikation
|
||||||
|
✅ TC-HR-007: AGI-rapportering (mock)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
```
|
||||||
|
✅ TC-INV-001: Skapa lagerplats
|
||||||
|
✅ TC-INV-002: Registrera lagersaldo
|
||||||
|
✅ TC-INV-003: Inleverans
|
||||||
|
✅ TC-INV-004: Utleverans
|
||||||
|
✅ TC-INV-005: Lagerjustering
|
||||||
|
✅ TC-INV-006: Larm vid lågt saldo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
```
|
||||||
|
✅ TC-PROJ-001: Skapa projekt
|
||||||
|
✅ TC-PROJ-002: Registrera tid på projekt
|
||||||
|
✅ TC-PROJ-003: Allokera kostnad till projekt
|
||||||
|
✅ TC-PROJ-004: Projektresultat
|
||||||
|
✅ TC-PROJ-005: Budget vs faktisk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automation
|
||||||
|
```
|
||||||
|
✅ TC-AUTO-001: Skapa schemalagt jobb
|
||||||
|
✅ TC-AUTO-002: Jobb exekveras enligt cron
|
||||||
|
✅ TC-AUTO-003: Skapa workflow
|
||||||
|
✅ TC-AUTO-004: Trigga workflow manuellt
|
||||||
|
✅ TC-AUTO-005: Event-trigger fungerar
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jämförelse med referenssystem
|
||||||
|
|
||||||
|
### Fortnox
|
||||||
|
| Funktion | Fortnox | BOC | Status |
|
||||||
|
|----------|---------|-----|--------|
|
||||||
|
| Autobokföring bank | ✅ | ⚠️ (manuell sync) | Delvis |
|
||||||
|
| OCR kvitton | ✅ | ✅ | Klar |
|
||||||
|
| Digital signering | ❌ | ❌ | Ej prio |
|
||||||
|
| Fakturafinansiering | ✅ | ❌ | Ej prio |
|
||||||
|
|
||||||
|
### Odoo
|
||||||
|
| Funktion | Odoo | BOC | Status |
|
||||||
|
|----------|------|-----|--------|
|
||||||
|
| Manufacturing (MRP) | ✅ | ❌ | Ej prio |
|
||||||
|
| eCommerce | ✅ | ❌ | Ej prio |
|
||||||
|
| PoS (Kassa) | ✅ | ❌ | Ej prio |
|
||||||
|
| Multi-company | ✅ | ✅ | Klar |
|
||||||
|
| Multi-currency | ✅ | ⚠️ | Delvis |
|
||||||
|
|
||||||
|
### Visma
|
||||||
|
| Funktion | Visma | BOC | Status |
|
||||||
|
|----------|-------|-----|--------|
|
||||||
|
| ROT/RUT | ✅ | ❌ | Ej prio |
|
||||||
|
| Enkel bokföring | ✅ | ❌ | Ej prio |
|
||||||
|
| Förenklat årsbokslut | ✅ | ❌ | Ej prio |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kör tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enhetstester
|
||||||
|
cd backend && go test ./... -v
|
||||||
|
|
||||||
|
# Integrationstester
|
||||||
|
cd backend && go test ./... -tags=integration -v
|
||||||
|
|
||||||
|
# Bygg och starta
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Kör API-tester
|
||||||
|
curl -s http://localhost:9092/health
|
||||||
|
|
||||||
|
# Kolla coverage
|
||||||
|
go test ./... -coverprofile=coverage.out
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kriterier för "production ready"
|
||||||
|
|
||||||
|
- [ ] Alla enhetstester passerar
|
||||||
|
- [ ] 80%+ code coverage
|
||||||
|
- [ ] Integrationstester passerar
|
||||||
|
- [ ] Docker Compose startar utan fel
|
||||||
|
- [ ] API-dokumentation komplett
|
||||||
|
- [ ] Performance-test: <100ms response time
|
||||||
|
- [ ] Security audit: inga kända sårbarheter
|
||||||
|
- [ ] Backup/restore testat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Senast uppdaterad: 2026-07-12*
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
|
# Rate limiting zones
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
|
||||||
|
# Upstreams
|
||||||
|
upstream boc_api {
|
||||||
|
server boc-api:9092;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream boc_rust {
|
||||||
|
server boc-rust:9093;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream kafka_ui {
|
||||||
|
server boc-kafka-ui:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main server
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API — rate limited
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://boc_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://boc_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rust service
|
||||||
|
location /rust/ {
|
||||||
|
proxy_pass http://boc_rust/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kafka UI (protected in production)
|
||||||
|
location /kafka-ui/ {
|
||||||
|
proxy_pass http://kafka_ui/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /nginx-health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1446
@@ -0,0 +1,1446 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.7.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower 0.5.3",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "boc-rust-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"chrono",
|
||||||
|
"dashmap",
|
||||||
|
"libc",
|
||||||
|
"rayon",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tokio-postgres",
|
||||||
|
"tower 0.4.13",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.67"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmov"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-deque"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-epoch"
|
||||||
|
version = "0.9.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctutils"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||||
|
dependencies = [
|
||||||
|
"cmov",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dashmap"
|
||||||
|
version = "5.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"hashbrown",
|
||||||
|
"lock_api",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
"ctutils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-system-configuration"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||||
|
dependencies = [
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postgres-protocol"
|
||||||
|
version = "0.6.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"fallible-iterator",
|
||||||
|
"hmac",
|
||||||
|
"md-5",
|
||||||
|
"memchr",
|
||||||
|
"rand",
|
||||||
|
"sha2",
|
||||||
|
"stringprep",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postgres-types"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"fallible-iterator",
|
||||||
|
"postgres-protocol",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.150"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stringprep"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-bidi",
|
||||||
|
"unicode-normalization",
|
||||||
|
"unicode-properties",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec_macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.52.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-postgres"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"fallible-iterator",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"parking_lot",
|
||||||
|
"percent-encoding",
|
||||||
|
"phf",
|
||||||
|
"pin-project-lite",
|
||||||
|
"postgres-protocol",
|
||||||
|
"postgres-types",
|
||||||
|
"rand",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"whoami",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.4.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
|
dependencies = [
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-bidi"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-normalization"
|
||||||
|
version = "0.1.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-properties"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.23.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"js-sys",
|
||||||
|
"serde_core",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.14.7+wasi-0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||||
|
dependencies = [
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.4+wasi-0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasite"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||||
|
dependencies = [
|
||||||
|
"wasi 0.14.7+wasi-0.2.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "whoami"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"libredox",
|
||||||
|
"objc2-system-configuration",
|
||||||
|
"wasite",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "boc-rust-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.35", features = ["full", "rt-multi-thread"] }
|
||||||
|
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-serde_json-1", "with-chrono-0_4"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
axum = "0.7"
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.6", features = ["serde", "v4"] }
|
||||||
|
dashmap = "5.5"
|
||||||
|
rayon = "1.8"
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "boc_rust"
|
||||||
|
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "boc-rust-service"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM rust:1.85-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Cargo files
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Create dummy files to cache dependencies
|
||||||
|
RUN mkdir src && echo "fn main() {}" > src/main.rs && echo "pub fn dummy() {}" > src/lib.rs
|
||||||
|
RUN cargo build --release && rm -rf src
|
||||||
|
|
||||||
|
# Copy actual source code
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build the actual binary
|
||||||
|
RUN touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/target/release/boc-rust-service .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 9093
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://localhost:9093/health || exit 1
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
CMD ["./boc-rust-service"]
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
use dashmap::DashMap;
|
||||||
|
use rayon::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct AnalyticsResult {
|
||||||
|
pub value: f64,
|
||||||
|
pub trend: f64,
|
||||||
|
pub breakdown: Vec<(String, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AnalyticsEngine {
|
||||||
|
cache: DashMap<String, (AnalyticsResult, std::time::Instant)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalyticsEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cache: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn query(&self, tenant_id: &str, metric: &str, period: &str) -> AnalyticsResult {
|
||||||
|
let cache_key = format!("{}:{}:{}", tenant_id, metric, period);
|
||||||
|
|
||||||
|
// Check cache (5 minute TTL)
|
||||||
|
if let Some(entry) = self.cache.get(&cache_key) {
|
||||||
|
if entry.value().1.elapsed().as_secs() < 300 {
|
||||||
|
return AnalyticsResult {
|
||||||
|
value: entry.value().0.value,
|
||||||
|
trend: entry.value().0.trend,
|
||||||
|
breakdown: entry.value().0.breakdown.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute analytics (parallel processing for large datasets)
|
||||||
|
let result = self.compute_metric(tenant_id, metric, period).await;
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
self.cache.insert(cache_key, (result.clone(), std::time::Instant::now()));
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_metric(&self, tenant_id: &str, metric: &str, period: &str) -> AnalyticsResult {
|
||||||
|
match metric {
|
||||||
|
"mrr" => self.compute_mrr(tenant_id, period).await,
|
||||||
|
"arr" => self.compute_arr(tenant_id, period).await,
|
||||||
|
"churn" => self.compute_churn(tenant_id, period).await,
|
||||||
|
"ltv" => self.compute_ltv(tenant_id, period).await,
|
||||||
|
"cac" => self.compute_cac(tenant_id, period).await,
|
||||||
|
"pipeline_value" => self.compute_pipeline(tenant_id, period).await,
|
||||||
|
"conversion_rate" => self.compute_conversion(tenant_id, period).await,
|
||||||
|
_ => AnalyticsResult {
|
||||||
|
value: 0.0,
|
||||||
|
trend: 0.0,
|
||||||
|
breakdown: vec![],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_mrr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {
|
||||||
|
// TODO: Query from database
|
||||||
|
// For now, return demo data
|
||||||
|
AnalyticsResult {
|
||||||
|
value: 53333.0,
|
||||||
|
trend: 0.05,
|
||||||
|
breakdown: vec![
|
||||||
|
("Subscriptions".to_string(), 45000.0),
|
||||||
|
("Add-ons".to_string(), 8333.0),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_arr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {
|
||||||
|
AnalyticsResult {
|
||||||
|
value: 640000.0,
|
||||||
|
trend: 0.12,
|
||||||
|
breakdown: vec![
|
||||||
|
("Enterprise".to_string(), 400000.0),
|
||||||
|
("Professional".to_string(), 180000.0),
|
||||||
|
("Basic".to_string(), 60000.0),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_churn(&self, tenant_id: &str, period: &str) -> AnalyticsResult {
|
||||||
|
AnalyticsResult {
|
||||||
|
value: 0.02,
|
||||||
|
trend: -0.005,
|
||||||
|
breakdown: vec![
|
||||||
|
("Voluntary".to_string(), 0.012),
|
||||||
|
("Involuntary".to_string(), 0.008),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_ltv(&self, tenant_id: &str, period: &str) -> AnalyticsResult {
|
||||||
|
AnalyticsResult {
|
||||||
|
value: 125000.0,
|
||||||
|
trend: 0.08,
|
||||||
|
breakdown: vec![
|
||||||
|
("Enterprise".to_string(), 250000.0),
|
||||||
|
("Professional".to_string(), 100000.0),
|
||||||
|
("Basic".to_string(), 25000.0),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_cac(&self, tenant_id: &str, period: &str) -> AnalyticsResult {
|
||||||
|
AnalyticsResult {
|
||||||
|
value: 15000.0,
|
||||||
|
trend: -0.03,
|
||||||
|
breakdown: vec![
|
||||||
|
("Marketing".to_string(), 8000.0),
|
||||||
|
("Sales".to_string(), 5000.0),
|
||||||
|
("Partners".to_string(), 2000.0),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_pipeline(&self, tenant_id: &str, period: &str) -> AnalyticsResult {
|
||||||
|
AnalyticsResult {
|
||||||
|
value: 850000.0,
|
||||||
|
trend: 0.15,
|
||||||
|
breakdown: vec![
|
||||||
|
("Prospect".to_string(), 200000.0),
|
||||||
|
("Qualified".to_string(), 300000.0),
|
||||||
|
("Proposal".to_string(), 250000.0),
|
||||||
|
("Negotiation".to_string(), 100000.0),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_conversion(&self, tenant_id: &str, period: &str) -> AnalyticsResult {
|
||||||
|
AnalyticsResult {
|
||||||
|
value: 0.25,
|
||||||
|
trend: 0.02,
|
||||||
|
breakdown: vec![
|
||||||
|
("Lead→Qualified".to_string(), 0.45),
|
||||||
|
("Qualified→Proposal".to_string(), 0.60),
|
||||||
|
("Proposal→Closed".to_string(), 0.35),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch process multiple metrics in parallel using Rayon
|
||||||
|
pub fn batch_compute(&self, tenant_id: &str, metrics: &[(&str, &str)]) -> Vec<AnalyticsResult> {
|
||||||
|
metrics
|
||||||
|
.par_iter()
|
||||||
|
.map(|(metric, period)| {
|
||||||
|
// Use tokio runtime to execute async code in parallel
|
||||||
|
let rt = tokio::runtime::Handle::try_current()
|
||||||
|
.unwrap_or_else(|_| tokio::runtime::Runtime::new().unwrap().handle().clone());
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
self.query(tenant_id, metric, period).await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for AnalyticsResult {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
value: self.value,
|
||||||
|
trend: self.trend,
|
||||||
|
breakdown: self.breakdown.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// IPC module for Go-Rust communication via shared memory
|
||||||
|
// Placeholder for C FFI integration
|
||||||
|
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::os::raw::{c_char, c_int, c_void};
|
||||||
|
|
||||||
|
/// Initialize shared memory channel
|
||||||
|
pub fn init_channel(name: &str) -> Result<(), String> {
|
||||||
|
// TODO: Implement C FFI calls to libboc_ipc.so
|
||||||
|
println!("Initializing IPC channel: {}", name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send message via shared memory
|
||||||
|
pub fn send_message(channel: &str, data: &[u8]) -> Result<(), String> {
|
||||||
|
// TODO: Implement C FFI calls
|
||||||
|
println!("Sending {} bytes on channel: {}", data.len(), channel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive message from shared memory
|
||||||
|
pub fn receive_message(channel: &str, timeout_ms: i32) -> Result<Vec<u8>, String> {
|
||||||
|
// TODO: Implement C FFI calls
|
||||||
|
println!("Receiving on channel: {} (timeout: {}ms)", channel, timeout_ms);
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C FFI wrapper for Go integration
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn boc_ipc_send(channel: *const c_char, data: *const c_void, len: c_int) -> c_int {
|
||||||
|
if channel.is_null() || data.is_null() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel_name = unsafe { CStr::from_ptr(channel).to_string_lossy() };
|
||||||
|
let data_slice = unsafe { std::slice::from_raw_parts(data as *const u8, len as usize) };
|
||||||
|
|
||||||
|
match send_message(&channel_name, data_slice) {
|
||||||
|
Ok(_) => 0,
|
||||||
|
Err(_) => -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn boc_ipc_recv(channel: *const c_char, buf: *mut c_void, max_len: c_int, timeout_ms: c_int) -> c_int {
|
||||||
|
if channel.is_null() || buf.is_null() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel_name = unsafe { CStr::from_ptr(channel).to_string_lossy() };
|
||||||
|
|
||||||
|
match receive_message(&channel_name, timeout_ms) {
|
||||||
|
Ok(data) => {
|
||||||
|
let len = std::cmp::min(data.len(), max_len as usize);
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(data.as_ptr(), buf as *mut u8, len);
|
||||||
|
}
|
||||||
|
len as c_int
|
||||||
|
}
|
||||||
|
Err(_) => -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
Json,
|
||||||
|
extract::State,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{info, error};
|
||||||
|
|
||||||
|
mod analytics;
|
||||||
|
mod reports;
|
||||||
|
mod ipc;
|
||||||
|
|
||||||
|
use analytics::AnalyticsEngine;
|
||||||
|
use reports::ReportGenerator;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
analytics: Arc<RwLock<AnalyticsEngine>>,
|
||||||
|
reports: Arc<RwLock<ReportGenerator>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
status: String,
|
||||||
|
service: String,
|
||||||
|
version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReportRequest {
|
||||||
|
tenant_id: String,
|
||||||
|
report_type: String,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ReportResponse {
|
||||||
|
report_id: String,
|
||||||
|
status: String,
|
||||||
|
data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnalyticsRequest {
|
||||||
|
tenant_id: String,
|
||||||
|
metric: String,
|
||||||
|
period: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AnalyticsResponse {
|
||||||
|
metric: String,
|
||||||
|
value: f64,
|
||||||
|
trend: f64,
|
||||||
|
breakdown: Vec<BreakdownItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct BreakdownItem {
|
||||||
|
label: String,
|
||||||
|
value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter("boc_rust_service=info")
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("BOC Rust Service starting...");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
analytics: Arc::new(RwLock::new(AnalyticsEngine::new())),
|
||||||
|
reports: Arc::new(RwLock::new(ReportGenerator::new())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(health_handler))
|
||||||
|
.route("/api/v1/reports/generate", post(generate_report))
|
||||||
|
.route("/api/v1/analytics/query", post(query_analytics))
|
||||||
|
.route("/api/v1/analytics/batch", post(batch_analytics))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:9093")
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind port 9093");
|
||||||
|
|
||||||
|
info!("BOC Rust Service listening on 0.0.0.0:9093");
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("Server failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_handler() -> Json<HealthResponse> {
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
service: "boc-rust-service".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_report(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ReportRequest>,
|
||||||
|
) -> Json<ReportResponse> {
|
||||||
|
info!("Generating report: {} for tenant: {}", req.report_type, req.tenant_id);
|
||||||
|
|
||||||
|
let reports = state.reports.read().await;
|
||||||
|
match reports.generate(&req.tenant_id, &req.report_type, &req.parameters).await {
|
||||||
|
Ok(data) => Json(ReportResponse {
|
||||||
|
report_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
data: Some(data),
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Report generation failed: {}", e);
|
||||||
|
Json(ReportResponse {
|
||||||
|
report_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
status: "failed".to_string(),
|
||||||
|
data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_analytics(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<AnalyticsRequest>,
|
||||||
|
) -> Json<AnalyticsResponse> {
|
||||||
|
let analytics = state.analytics.read().await;
|
||||||
|
let result = analytics.query(&req.tenant_id, &req.metric, &req.period).await;
|
||||||
|
|
||||||
|
Json(AnalyticsResponse {
|
||||||
|
metric: req.metric,
|
||||||
|
value: result.value,
|
||||||
|
trend: result.trend,
|
||||||
|
breakdown: result.breakdown.into_iter()
|
||||||
|
.map(|(label, value)| BreakdownItem { label, value })
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_analytics(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(reqs): Json<Vec<AnalyticsRequest>>,
|
||||||
|
) -> Json<Vec<AnalyticsResponse>> {
|
||||||
|
let analytics = state.analytics.read().await;
|
||||||
|
|
||||||
|
let mut responses = Vec::with_capacity(reqs.len());
|
||||||
|
for req in reqs {
|
||||||
|
let result = analytics.query(&req.tenant_id, &req.metric, &req.period).await;
|
||||||
|
responses.push(AnalyticsResponse {
|
||||||
|
metric: req.metric.clone(),
|
||||||
|
value: result.value,
|
||||||
|
trend: result.trend,
|
||||||
|
breakdown: result.breakdown.into_iter()
|
||||||
|
.map(|(label, value)| BreakdownItem { label, value })
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(responses)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
Json,
|
||||||
|
extract::State,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{info, error};
|
||||||
|
|
||||||
|
mod analytics;
|
||||||
|
mod reports;
|
||||||
|
mod ipc;
|
||||||
|
|
||||||
|
use analytics::AnalyticsEngine;
|
||||||
|
use reports::ReportGenerator;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
analytics: Arc<RwLock<AnalyticsEngine>>,
|
||||||
|
reports: Arc<RwLock<ReportGenerator>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
status: String,
|
||||||
|
service: String,
|
||||||
|
version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReportRequest {
|
||||||
|
tenant_id: String,
|
||||||
|
report_type: String,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ReportResponse {
|
||||||
|
report_id: String,
|
||||||
|
status: String,
|
||||||
|
data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnalyticsRequest {
|
||||||
|
tenant_id: String,
|
||||||
|
metric: String,
|
||||||
|
period: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AnalyticsResponse {
|
||||||
|
metric: String,
|
||||||
|
value: f64,
|
||||||
|
trend: f64,
|
||||||
|
breakdown: Vec<BreakdownItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct BreakdownItem {
|
||||||
|
label: String,
|
||||||
|
value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter("boc_rust_service=info")
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("BOC Rust Service starting...");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
analytics: Arc::new(RwLock::new(AnalyticsEngine::new())),
|
||||||
|
reports: Arc::new(RwLock::new(ReportGenerator::new())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(health_handler))
|
||||||
|
.route("/api/v1/reports/generate", post(generate_report))
|
||||||
|
.route("/api/v1/analytics/query", post(query_analytics))
|
||||||
|
.route("/api/v1/analytics/batch", post(batch_analytics))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:9093")
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind port 9093");
|
||||||
|
|
||||||
|
info!("BOC Rust Service listening on 0.0.0.0:9093");
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("Server failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_handler() -> Json<HealthResponse> {
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
service: "boc-rust-service".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_report(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ReportRequest>,
|
||||||
|
) -> Json<ReportResponse> {
|
||||||
|
info!("Generating report: {} for tenant: {}", req.report_type, req.tenant_id);
|
||||||
|
|
||||||
|
let reports = state.reports.read().await;
|
||||||
|
match reports.generate(&req.tenant_id, &req.report_type, &req.parameters).await {
|
||||||
|
Ok(data) => Json(ReportResponse {
|
||||||
|
report_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
data: Some(data),
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Report generation failed: {}", e);
|
||||||
|
Json(ReportResponse {
|
||||||
|
report_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
status: "failed".to_string(),
|
||||||
|
data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_analytics(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<AnalyticsRequest>,
|
||||||
|
) -> Json<AnalyticsResponse> {
|
||||||
|
let analytics = state.analytics.read().await;
|
||||||
|
let result = analytics.query(&req.tenant_id, &req.metric, &req.period).await;
|
||||||
|
|
||||||
|
Json(AnalyticsResponse {
|
||||||
|
metric: req.metric,
|
||||||
|
value: result.value,
|
||||||
|
trend: result.trend,
|
||||||
|
breakdown: result.breakdown.into_iter()
|
||||||
|
.map(|(label, value)| BreakdownItem { label, value })
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_analytics(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(reqs): Json<Vec<AnalyticsRequest>>,
|
||||||
|
) -> Json<Vec<AnalyticsResponse>> {
|
||||||
|
let analytics = state.analytics.read().await;
|
||||||
|
|
||||||
|
let mut responses = Vec::with_capacity(reqs.len());
|
||||||
|
for req in reqs {
|
||||||
|
let result = analytics.query(&req.tenant_id, &req.metric, &req.period).await;
|
||||||
|
responses.push(AnalyticsResponse {
|
||||||
|
metric: req.metric.clone(),
|
||||||
|
value: result.value,
|
||||||
|
trend: result.trend,
|
||||||
|
breakdown: result.breakdown.into_iter()
|
||||||
|
.map(|(label, value)| BreakdownItem { label, value })
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(responses)
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct ReportGenerator {
|
||||||
|
templates: HashMap<String, ReportTemplate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReportTemplate {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
required_params: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportGenerator {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut templates = HashMap::new();
|
||||||
|
|
||||||
|
templates.insert("financial_summary".to_string(), ReportTemplate {
|
||||||
|
name: "Financial Summary".to_string(),
|
||||||
|
description: "Overview of financial performance".to_string(),
|
||||||
|
required_params: vec!["period".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
templates.insert("sales_pipeline".to_string(), ReportTemplate {
|
||||||
|
name: "Sales Pipeline".to_string(),
|
||||||
|
description: "Current sales pipeline analysis".to_string(),
|
||||||
|
required_params: vec!["period".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
templates.insert("customer_analytics".to_string(), ReportTemplate {
|
||||||
|
name: "Customer Analytics".to_string(),
|
||||||
|
description: "Customer metrics and trends".to_string(),
|
||||||
|
required_params: vec!["period".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
templates.insert("revenue_forecast".to_string(), ReportTemplate {
|
||||||
|
name: "Revenue Forecast".to_string(),
|
||||||
|
description: "Projected revenue based on pipeline".to_string(),
|
||||||
|
required_params: vec!["period".to_string(), "method".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
templates.insert("expense_breakdown".to_string(), ReportTemplate {
|
||||||
|
name: "Expense Breakdown".to_string(),
|
||||||
|
description: "Detailed expense analysis".to_string(),
|
||||||
|
required_params: vec!["period".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
templates.insert("cashflow_projection".to_string(), ReportTemplate {
|
||||||
|
name: "Cashflow Projection".to_string(),
|
||||||
|
description: "Projected cashflow for upcoming periods".to_string(),
|
||||||
|
required_params: vec!["periods".to_string()],
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { templates }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate(
|
||||||
|
&self,
|
||||||
|
tenant_id: &str,
|
||||||
|
report_type: &str,
|
||||||
|
parameters: &Value,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let template = self.templates.get(report_type)
|
||||||
|
.ok_or_else(|| format!("Unknown report type: {}", report_type))?;
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
for param in &template.required_params {
|
||||||
|
if parameters.get(param).is_none() {
|
||||||
|
return Err(format!("Missing required parameter: {}", param));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match report_type {
|
||||||
|
"financial_summary" => self.generate_financial_summary(tenant_id, parameters).await,
|
||||||
|
"sales_pipeline" => self.generate_sales_pipeline(tenant_id, parameters).await,
|
||||||
|
"customer_analytics" => self.generate_customer_analytics(tenant_id, parameters).await,
|
||||||
|
"revenue_forecast" => self.generate_revenue_forecast(tenant_id, parameters).await,
|
||||||
|
"expense_breakdown" => self.generate_expense_breakdown(tenant_id, parameters).await,
|
||||||
|
"cashflow_projection" => self.generate_cashflow_projection(tenant_id, parameters).await,
|
||||||
|
_ => Err(format!("Report type not implemented: {}", report_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_financial_summary(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {
|
||||||
|
let period = params.get("period").and_then(|v| v.as_str()).unwrap_or("current_month");
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"report_type": "financial_summary",
|
||||||
|
"period": period,
|
||||||
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
|
"summary": {
|
||||||
|
"total_revenue": 125000.00,
|
||||||
|
"total_expenses": 87500.00,
|
||||||
|
"net_income": 37500.00,
|
||||||
|
"profit_margin": 0.30,
|
||||||
|
"mrr": 53333.00,
|
||||||
|
"arr": 640000.00,
|
||||||
|
"cash_on_hand": 180000.00,
|
||||||
|
"burn_rate": 45000.00,
|
||||||
|
"runway_months": 4.0
|
||||||
|
},
|
||||||
|
"revenue_breakdown": [
|
||||||
|
{"category": "Subscriptions", "amount": 95000.00, "percentage": 0.76},
|
||||||
|
{"category": "Services", "amount": 20000.00, "percentage": 0.16},
|
||||||
|
{"category": "Other", "amount": 10000.00, "percentage": 0.08}
|
||||||
|
],
|
||||||
|
"expense_breakdown": [
|
||||||
|
{"category": "Personnel", "amount": 50000.00, "percentage": 0.57},
|
||||||
|
{"category": "Infrastructure", "amount": 15000.00, "percentage": 0.17},
|
||||||
|
{"category": "Marketing", "amount": 12500.00, "percentage": 0.14},
|
||||||
|
{"category": "Other", "amount": 10000.00, "percentage": 0.12}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_sales_pipeline(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {
|
||||||
|
let period = params.get("period").and_then(|v| v.as_str()).unwrap_or("current");
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"report_type": "sales_pipeline",
|
||||||
|
"period": period,
|
||||||
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
|
"pipeline": {
|
||||||
|
"total_value": 850000.00,
|
||||||
|
"total_deals": 24,
|
||||||
|
"weighted_value": 425000.00,
|
||||||
|
"avg_deal_size": 35417.00,
|
||||||
|
"avg_sales_cycle_days": 45
|
||||||
|
},
|
||||||
|
"by_stage": [
|
||||||
|
{"stage": "Prospect", "count": 8, "value": 200000.00, "probability": 0.10},
|
||||||
|
{"stage": "Qualified", "count": 6, "value": 300000.00, "probability": 0.30},
|
||||||
|
{"stage": "Proposal", "count": 5, "value": 250000.00, "probability": 0.60},
|
||||||
|
{"stage": "Negotiation", "count": 3, "value": 100000.00, "probability": 0.80},
|
||||||
|
{"stage": "Closed Won", "count": 2, "value": 75000.00, "probability": 1.00}
|
||||||
|
],
|
||||||
|
"trends": {
|
||||||
|
"new_deals_this_month": 5,
|
||||||
|
"deals_moved_forward": 3,
|
||||||
|
"deals_stalled": 2,
|
||||||
|
"deals_lost": 1,
|
||||||
|
"win_rate": 0.67
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_customer_analytics(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {
|
||||||
|
let period = params.get("period").and_then(|v| v.as_str()).unwrap_or("current_month");
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"report_type": "customer_analytics",
|
||||||
|
"period": period,
|
||||||
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
|
"overview": {
|
||||||
|
"total_customers": 42,
|
||||||
|
"new_customers": 5,
|
||||||
|
"churned_customers": 1,
|
||||||
|
"active_customers": 38,
|
||||||
|
"net_revenue_retention": 1.08,
|
||||||
|
"gross_revenue_retention": 0.95
|
||||||
|
},
|
||||||
|
"segments": [
|
||||||
|
{"segment": "Enterprise", "count": 3, "mrr": 25000.00, "ltv": 250000.00},
|
||||||
|
{"segment": "Professional", "count": 12, "mrr": 18000.00, "ltv": 100000.00},
|
||||||
|
{"segment": "Basic", "count": 27, "mrr": 10333.00, "ltv": 25000.00}
|
||||||
|
],
|
||||||
|
"health": {
|
||||||
|
"at_risk": 2,
|
||||||
|
"expanding": 5,
|
||||||
|
"stable": 31,
|
||||||
|
"new": 5
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_revenue_forecast(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {
|
||||||
|
let period = params.get("period").and_then(|v| v.as_str()).unwrap_or("next_quarter");
|
||||||
|
let method = params.get("method").and_then(|v| v.as_str()).unwrap_or("weighted_pipeline");
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"report_type": "revenue_forecast",
|
||||||
|
"period": period,
|
||||||
|
"method": method,
|
||||||
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
|
"forecast": {
|
||||||
|
"conservative": 180000.00,
|
||||||
|
"expected": 250000.00,
|
||||||
|
"optimistic": 350000.00
|
||||||
|
},
|
||||||
|
"monthly_breakdown": [
|
||||||
|
{"month": "Month 1", "conservative": 55000.00, "expected": 75000.00, "optimistic": 100000.00},
|
||||||
|
{"month": "Month 2", "conservative": 60000.00, "expected": 85000.00, "optimistic": 120000.00},
|
||||||
|
{"month": "Month 3", "conservative": 65000.00, "expected": 90000.00, "optimistic": 130000.00}
|
||||||
|
],
|
||||||
|
"assumptions": [
|
||||||
|
"Current pipeline velocity maintained",
|
||||||
|
"No significant churn increase",
|
||||||
|
"Marketing spend constant"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_expense_breakdown(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {
|
||||||
|
let period = params.get("period").and_then(|v| v.as_str()).unwrap_or("current_month");
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"report_type": "expense_breakdown",
|
||||||
|
"period": period,
|
||||||
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
|
"total_expenses": 87500.00,
|
||||||
|
"by_category": [
|
||||||
|
{"category": "Personnel", "amount": 50000.00, "percentage": 0.57, "trend": 0.02},
|
||||||
|
{"category": "Infrastructure", "amount": 15000.00, "percentage": 0.17, "trend": -0.05},
|
||||||
|
{"category": "Marketing", "amount": 12500.00, "percentage": 0.14, "trend": 0.10},
|
||||||
|
{"category": "Software", "amount": 6000.00, "percentage": 0.07, "trend": 0.0},
|
||||||
|
{"category": "Other", "amount": 4000.00, "percentage": 0.05, "trend": -0.02}
|
||||||
|
],
|
||||||
|
"recurring_vs_one_time": {
|
||||||
|
"recurring": 75000.00,
|
||||||
|
"one_time": 12500.00
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_cashflow_projection(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {
|
||||||
|
let periods = params.get("periods").and_then(|v| v.as_i64()).unwrap_or(6);
|
||||||
|
|
||||||
|
let mut projections = Vec::new();
|
||||||
|
let mut current_cash = 180000.00;
|
||||||
|
|
||||||
|
for i in 1..=periods {
|
||||||
|
let inflow = 120000.00 + (i as f64 * 5000.00);
|
||||||
|
let outflow = 87500.00 + (i as f64 * 2000.00);
|
||||||
|
let net = inflow - outflow;
|
||||||
|
current_cash += net;
|
||||||
|
|
||||||
|
projections.push(json!({
|
||||||
|
"period": format!("Month {}", i),
|
||||||
|
"inflow": inflow,
|
||||||
|
"outflow": outflow,
|
||||||
|
"net": net,
|
||||||
|
"ending_cash": current_cash
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"report_type": "cashflow_projection",
|
||||||
|
"periods": periods,
|
||||||
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
|
"starting_cash": 180000.00,
|
||||||
|
"projections": projections,
|
||||||
|
"summary": {
|
||||||
|
"total_inflow": projections.iter().map(|p| p.get("inflow").unwrap().as_f64().unwrap()).sum::<f64>(),
|
||||||
|
"total_outflow": projections.iter().map(|p| p.get("outflow").unwrap().as_f64().unwrap()).sum::<f64>(),
|
||||||
|
"ending_cash": current_cash,
|
||||||
|
"min_cash": projections.iter().map(|p| p.get("ending_cash").unwrap().as_f64().unwrap()).fold(f64::INFINITY, f64::min)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc_fingerprint":8170899571127002677,"outputs":{"12203715465990969353":{"success":true,"status":"","code":0,"stdout":"rustc 1.96.0 (ac68faa20 2026-05-25)\nbinary: rustc\ncommit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96\ncommit-date: 2026-05-25\nhost: aarch64-unknown-linux-gnu\nrelease: 1.96.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/bernt/.rustup/toolchains/stable-aarch64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"neon\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Signature: 8a477f597d28d172789f06886806bc55
|
||||||
|
# This file is a cache directory tag created by cargo.
|
||||||
|
# For information about cache directory tags see https://bford.info/cachedir/
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
d12295fa64d6c7e8
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[]","target":5116616278641129243,"profile":1369601567987815722,"path":2895544698071783192,"deps":[[1108254298283712113,"quote",false,12346198818348946717],[4289358735036141001,"proc_macro2",false,2466923278285998130],[14607138199358211871,"syn",false,16091993304001859324]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/async-trait-e3b4366307923e69/dep-lib-async_trait","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
bccba2364a977929
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":2040997289075261528,"path":1915199519464942613,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/atomic-waker-e92db70727ec2e75/dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
28f83eb7c4603b33
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":1369601567987815722,"path":14691547496011824260,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/autocfg-cfdc11b3d5fe0685/dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
eec7fdd3e41f9a8f
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"form\", \"http1\", \"json\", \"matched-path\", \"original-uri\", \"query\", \"tokio\", \"tower-log\", \"tracing\"]","declared_features":"[\"__private_docs\", \"default\", \"form\", \"http1\", \"http2\", \"json\", \"macros\", \"matched-path\", \"multipart\", \"original-uri\", \"query\", \"tokio\", \"tower-log\", \"tracing\", \"ws\"]","target":13920321295547257648,"profile":2040997289075261528,"path":2777920595211535039,"deps":[[784494742817713399,"tower_service",false,13268437010750965648],[2251399859588827949,"pin_project_lite",false,4988885227890412228],[2517136641825875337,"sync_wrapper",false,1687334814780530710],[3035134586790830808,"hyper",false,2449154796991432763],[3632162862999675140,"tower",false,13737076900094726326],[4359148418957042248,"axum_core",false,16477098965219525408],[5532778797167691009,"itoa",false,9619087413982131351],[5898568623609459682,"futures_util",false,18018312848256853116],[6803352382179706244,"percent_encoding",false,4348505878887178983],[7712452662827335977,"tower_layer",false,16973315690600901494],[8578586876803397814,"serde_json",false,4628570253085338962],[9394460649638301237,"tokio",false,16850047951336516385],[9678799920983747518,"matchit",false,11820823362860415402],[10229185211513642314,"mime",false,14363291638445985122],[11926622812581095017,"bytes",false,16904972052078141223],[11976082518617474977,"hyper_util",false,5052674406737212029],[12613788554453945248,"memchr",false,17793951435395408554],[13548984313718623784,"serde",false,8151733579501977522],[14084095096285906100,"http_body",false,3294172187499036643],[14757622794040968908,"tracing",false,10698368088514759261],[14814583949208169760,"serde_path_to_error",false,8068421446706276709],[16542808166767769916,"serde_urlencoded",false,4143663369910590965],[16611674984963787466,"async_trait",false,16773611066353853137],[16900715236047033623,"http_body_util",false,13625222387583398835],[16991438365634268121,"rustversion",false,9804801277437893544],[17371538545939333701,"http",false,6263282853657891499]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/axum-844fb32df64c81a5/dep-lib-axum","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20c3dd9e356aaae4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"tracing\"]","declared_features":"[\"__private_docs\", \"tracing\"]","target":2565713999752801252,"profile":2040997289075261528,"path":15292076425892459978,"deps":[[784494742817713399,"tower_service",false,13268437010750965648],[2251399859588827949,"pin_project_lite",false,4988885227890412228],[2517136641825875337,"sync_wrapper",false,1687334814780530710],[5898568623609459682,"futures_util",false,18018312848256853116],[7712452662827335977,"tower_layer",false,16973315690600901494],[10229185211513642314,"mime",false,14363291638445985122],[11926622812581095017,"bytes",false,16904972052078141223],[14084095096285906100,"http_body",false,3294172187499036643],[14757622794040968908,"tracing",false,10698368088514759261],[16611674984963787466,"async_trait",false,16773611066353853137],[16900715236047033623,"http_body_util",false,13625222387583398835],[16991438365634268121,"rustversion",false,9804801277437893544],[17371538545939333701,"http",false,6263282853657891499]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/axum-core-c3e65a8ede5c372c/dep-lib-axum_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
4f2d01fbf668be02
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":2040997289075261528,"path":7660686688554485333,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/base64-bffeb343559b9b16/dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
04265406d8dfdc86
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"arbitrary\", \"bytemuck\", \"example_generated\", \"serde\", \"serde_core\", \"std\"]","target":7691312148208718491,"profile":2040997289075261528,"path":13133951523946053133,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/bitflags-765f98ff397854cf/dep-lib-bitflags","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
a0c789804c70a3d8
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"zeroize\"]","target":6057344034650883969,"profile":15005971894838546436,"path":9516285012683048963,"deps":[[3173661117269759064,"hybrid_array",false,18059322650750644876]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/block-buffer-a4cfcc9869f4006d/dep-lib-block_buffer","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
188a1892fb9a226c
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[]","target":8149518468556730110,"profile":2040997289075261528,"path":10763286916239946207,"deps":[[466357198569416633,"uuid",false,6844483093539163181],[3601586811267292532,"tower",false,17710280762048293091],[4891297352905791595,"axum",false,10347618161506764782],[5364813825765636762,"dashmap",false,8198242637287196554],[5380358770761950913,"tracing_subscriber",false,17654345397525367048],[7098700569944897890,"libc",false,10358284356093817449],[8578586876803397814,"serde_json",false,4628570253085338962],[9394460649638301237,"tokio",false,16850047951336516385],[11641236027685285524,"tokio_postgres",false,4637541574078043107],[11910974697091955563,"rayon",false,9459993863794084889],[13548984313718623784,"serde",false,8151733579501977522],[14435908599267459652,"tower_http",false,4694176071756235637],[14757622794040968908,"tracing",false,10698368088514759261],[16117757646811882223,"chrono",false,13901919752554136555]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/boc-rust-service-8090414e8153436b/dep-lib-boc_rust","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
+44
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
823b90f0f83b9d33
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[]","target":11100866145103567484,"profile":2040997289075261528,"path":4942398508502643691,"deps":[[466357198569416633,"uuid",false,6844483093539163181],[3601586811267292532,"tower",false,17710280762048293091],[4891297352905791595,"axum",false,10347618161506764782],[5364813825765636762,"dashmap",false,8198242637287196554],[5380358770761950913,"tracing_subscriber",false,17654345397525367048],[6098513438495592181,"boc_rust",false,7791960710582929944],[7098700569944897890,"libc",false,10358284356093817449],[8578586876803397814,"serde_json",false,4628570253085338962],[9394460649638301237,"tokio",false,16850047951336516385],[11641236027685285524,"tokio_postgres",false,4637541574078043107],[11910974697091955563,"rayon",false,9459993863794084889],[13548984313718623784,"serde",false,8151733579501977522],[14435908599267459652,"tower_http",false,4694176071756235637],[14757622794040968908,"tracing",false,10698368088514759261],[16117757646811882223,"chrono",false,13901919752554136555]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/boc-rust-service-bcff98372ac6b716/dep-bin-boc-rust-service","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{"$message_type":"diagnostic","message":"unused import: `serde_json::Value`","code":{"code":"unused_imports","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":49,"byte_end":66,"line_start":3,"line_end":3,"column_start":5,"column_end":22,"is_primary":true,"text":[{"text":"use serde_json::Value;","highlight_start":5,"highlight_end":22}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"`#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null},{"message":"remove the whole `use` item","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":45,"byte_end":68,"line_start":3,"line_end":4,"column_start":1,"column_end":1,"is_primary":true,"text":[{"text":"use serde_json::Value;","highlight_start":1,"highlight_end":23},{"text":"use std::collections::HashMap;","highlight_start":1,"highlight_end":1}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused import: `serde_json::Value`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:3:5\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m3\u001b[0m \u001b[1m\u001b[94m|\u001b[0m use serde_json::Value;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^^\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused import: `std::collections::HashMap`","code":{"code":"unused_imports","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":72,"byte_end":97,"line_start":4,"line_end":4,"column_start":5,"column_end":30,"is_primary":true,"text":[{"text":"use std::collections::HashMap;","highlight_start":5,"highlight_end":30}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"remove the whole `use` item","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":68,"byte_end":99,"line_start":4,"line_end":5,"column_start":1,"column_end":1,"is_primary":true,"text":[{"text":"use std::collections::HashMap;","highlight_start":1,"highlight_end":31},{"text":"","highlight_start":1,"highlight_end":1}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused import: `std::collections::HashMap`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:4:5\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m4\u001b[0m \u001b[1m\u001b[94m|\u001b[0m use std::collections::HashMap;\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^^^^^^^^^^^^^\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused import: `DateTime`","code":{"code":"unused_imports","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":13,"byte_end":21,"line_start":1,"line_end":1,"column_start":14,"column_end":22,"is_primary":true,"text":[{"text":"use chrono::{DateTime, Utc};","highlight_start":14,"highlight_end":22}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"remove the unused import","code":null,"level":"help","spans":[{"file_name":"src/reports.rs","byte_start":13,"byte_end":23,"line_start":1,"line_end":1,"column_start":14,"column_end":24,"is_primary":true,"text":[{"text":"use chrono::{DateTime, Utc};","highlight_start":14,"highlight_end":24}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null},{"file_name":"src/reports.rs","byte_start":12,"byte_end":13,"line_start":1,"line_end":1,"column_start":13,"column_end":14,"is_primary":true,"text":[{"text":"use chrono::{DateTime, Utc};","highlight_start":13,"highlight_end":14}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null},{"file_name":"src/reports.rs","byte_start":26,"byte_end":27,"line_start":1,"line_end":1,"column_start":27,"column_end":28,"is_primary":true,"text":[{"text":"use chrono::{DateTime, Utc};","highlight_start":27,"highlight_end":28}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused import: `DateTime`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:1:14\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m1\u001b[0m \u001b[1m\u001b[94m|\u001b[0m use chrono::{DateTime, Utc};\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused import: `CString`","code":{"code":"unused_imports","explanation":null},"level":"warning","spans":[{"file_name":"src/ipc.rs","byte_start":117,"byte_end":124,"line_start":4,"line_end":4,"column_start":22,"column_end":29,"is_primary":true,"text":[{"text":"use std::ffi::{CStr, CString};","highlight_start":22,"highlight_end":29}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"remove the unused import","code":null,"level":"help","spans":[{"file_name":"src/ipc.rs","byte_start":115,"byte_end":124,"line_start":4,"line_end":4,"column_start":20,"column_end":29,"is_primary":true,"text":[{"text":"use std::ffi::{CStr, CString};","highlight_start":20,"highlight_end":29}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null},{"file_name":"src/ipc.rs","byte_start":110,"byte_end":111,"line_start":4,"line_end":4,"column_start":15,"column_end":16,"is_primary":true,"text":[{"text":"use std::ffi::{CStr, CString};","highlight_start":15,"highlight_end":16}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null},{"file_name":"src/ipc.rs","byte_start":124,"byte_end":125,"line_start":4,"line_end":4,"column_start":29,"column_end":30,"is_primary":true,"text":[{"text":"use std::ffi::{CStr, CString};","highlight_start":29,"highlight_end":30}],"label":null,"suggested_replacement":"","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused import: `CString`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/ipc.rs:4:22\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m4\u001b[0m \u001b[1m\u001b[94m|\u001b[0m use std::ffi::{CStr, CString};\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":2103,"byte_end":2112,"line_start":63,"line_end":63,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_mrr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"`#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null},{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":2103,"byte_end":2112,"line_start":63,"line_end":63,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_mrr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:63:33\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m63\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_mrr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `period`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":2120,"byte_end":2126,"line_start":63,"line_end":63,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_mrr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":2120,"byte_end":2126,"line_start":63,"line_end":63,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_mrr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":"_period","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `period`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:63:50\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m63\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_mrr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_period`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":2506,"byte_end":2515,"line_start":76,"line_end":76,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_arr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":2506,"byte_end":2515,"line_start":76,"line_end":76,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_arr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:76:33\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m76\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_arr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `period`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":2523,"byte_end":2529,"line_start":76,"line_end":76,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_arr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":2523,"byte_end":2529,"line_start":76,"line_end":76,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_arr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":"_period","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `period`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:76:50\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m76\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_arr(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_period`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":2891,"byte_end":2900,"line_start":88,"line_end":88,"column_start":35,"column_end":44,"is_primary":true,"text":[{"text":" async fn compute_churn(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":35,"highlight_end":44}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":2891,"byte_end":2900,"line_start":88,"line_end":88,"column_start":35,"column_end":44,"is_primary":true,"text":[{"text":" async fn compute_churn(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":35,"highlight_end":44}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:88:35\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m88\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_churn(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `period`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":2908,"byte_end":2914,"line_start":88,"line_end":88,"column_start":52,"column_end":58,"is_primary":true,"text":[{"text":" async fn compute_churn(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":52,"highlight_end":58}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":2908,"byte_end":2914,"line_start":88,"line_end":88,"column_start":52,"column_end":58,"is_primary":true,"text":[{"text":" async fn compute_churn(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":52,"highlight_end":58}],"label":null,"suggested_replacement":"_period","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `period`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:88:52\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m88\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_churn(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_period`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":3216,"byte_end":3225,"line_start":99,"line_end":99,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_ltv(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":3216,"byte_end":3225,"line_start":99,"line_end":99,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_ltv(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:99:33\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m99\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_ltv(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `period`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":3233,"byte_end":3239,"line_start":99,"line_end":99,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_ltv(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":3233,"byte_end":3239,"line_start":99,"line_end":99,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_ltv(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":"_period","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `period`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:99:50\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m99\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_ltv(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_period`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":3599,"byte_end":3608,"line_start":111,"line_end":111,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_cac(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":3599,"byte_end":3608,"line_start":111,"line_end":111,"column_start":33,"column_end":42,"is_primary":true,"text":[{"text":" async fn compute_cac(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":33,"highlight_end":42}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:111:33\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m111\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_cac(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `period`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":3616,"byte_end":3622,"line_start":111,"line_end":111,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_cac(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":3616,"byte_end":3622,"line_start":111,"line_end":111,"column_start":50,"column_end":56,"is_primary":true,"text":[{"text":" async fn compute_cac(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":50,"highlight_end":56}],"label":null,"suggested_replacement":"_period","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `period`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:111:50\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m111\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_cac(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_period`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":3977,"byte_end":3986,"line_start":123,"line_end":123,"column_start":38,"column_end":47,"is_primary":true,"text":[{"text":" async fn compute_pipeline(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":38,"highlight_end":47}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":3977,"byte_end":3986,"line_start":123,"line_end":123,"column_start":38,"column_end":47,"is_primary":true,"text":[{"text":" async fn compute_pipeline(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":38,"highlight_end":47}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:123:38\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m123\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_pipeline(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `period`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":3994,"byte_end":4000,"line_start":123,"line_end":123,"column_start":55,"column_end":61,"is_primary":true,"text":[{"text":" async fn compute_pipeline(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":55,"highlight_end":61}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":3994,"byte_end":4000,"line_start":123,"line_end":123,"column_start":55,"column_end":61,"is_primary":true,"text":[{"text":" async fn compute_pipeline(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":55,"highlight_end":61}],"label":null,"suggested_replacement":"_period","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `period`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:123:55\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m123\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_pipeline(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_period`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":4421,"byte_end":4430,"line_start":136,"line_end":136,"column_start":40,"column_end":49,"is_primary":true,"text":[{"text":" async fn compute_conversion(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":40,"highlight_end":49}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":4421,"byte_end":4430,"line_start":136,"line_end":136,"column_start":40,"column_end":49,"is_primary":true,"text":[{"text":" async fn compute_conversion(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":40,"highlight_end":49}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:136:40\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m136\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_conversion(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `period`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":4438,"byte_end":4444,"line_start":136,"line_end":136,"column_start":57,"column_end":63,"is_primary":true,"text":[{"text":" async fn compute_conversion(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":57,"highlight_end":63}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/analytics.rs","byte_start":4438,"byte_end":4444,"line_start":136,"line_end":136,"column_start":57,"column_end":63,"is_primary":true,"text":[{"text":" async fn compute_conversion(&self, tenant_id: &str, period: &str) -> AnalyticsResult {","highlight_start":57,"highlight_end":63}],"label":null,"suggested_replacement":"_period","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `period`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:136:57\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m136\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn compute_conversion(&self, tenant_id: &str, period: &str) -> AnalyticsResult {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_period`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":3367,"byte_end":3376,"line_start":85,"line_end":85,"column_start":48,"column_end":57,"is_primary":true,"text":[{"text":" async fn generate_financial_summary(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":48,"highlight_end":57}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/reports.rs","byte_start":3367,"byte_end":3376,"line_start":85,"line_end":85,"column_start":48,"column_end":57,"is_primary":true,"text":[{"text":" async fn generate_financial_summary(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":48,"highlight_end":57}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:85:48\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m85\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn generate_financial_summary(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":4815,"byte_end":4824,"line_start":117,"line_end":117,"column_start":45,"column_end":54,"is_primary":true,"text":[{"text":" async fn generate_sales_pipeline(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":45,"highlight_end":54}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/reports.rs","byte_start":4815,"byte_end":4824,"line_start":117,"line_end":117,"column_start":45,"column_end":54,"is_primary":true,"text":[{"text":" async fn generate_sales_pipeline(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":45,"highlight_end":54}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:117:45\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m117\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn generate_sales_pipeline(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":6166,"byte_end":6175,"line_start":148,"line_end":148,"column_start":49,"column_end":58,"is_primary":true,"text":[{"text":" async fn generate_customer_analytics(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":49,"highlight_end":58}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/reports.rs","byte_start":6166,"byte_end":6175,"line_start":148,"line_end":148,"column_start":49,"column_end":58,"is_primary":true,"text":[{"text":" async fn generate_customer_analytics(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":49,"highlight_end":58}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:148:49\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m148\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn generate_customer_analytics(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":7300,"byte_end":7309,"line_start":177,"line_end":177,"column_start":47,"column_end":56,"is_primary":true,"text":[{"text":" async fn generate_revenue_forecast(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":47,"highlight_end":56}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/reports.rs","byte_start":7300,"byte_end":7309,"line_start":177,"line_end":177,"column_start":47,"column_end":56,"is_primary":true,"text":[{"text":" async fn generate_revenue_forecast(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":47,"highlight_end":56}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:177:47\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m177\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn generate_revenue_forecast(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":8543,"byte_end":8552,"line_start":204,"line_end":204,"column_start":48,"column_end":57,"is_primary":true,"text":[{"text":" async fn generate_expense_breakdown(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":48,"highlight_end":57}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/reports.rs","byte_start":8543,"byte_end":8552,"line_start":204,"line_end":204,"column_start":48,"column_end":57,"is_primary":true,"text":[{"text":" async fn generate_expense_breakdown(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":48,"highlight_end":57}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:204:48\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m204\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn generate_expense_breakdown(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"unused variable: `tenant_id`","code":{"code":"unused_variables","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":9626,"byte_end":9635,"line_start":226,"line_end":226,"column_start":50,"column_end":59,"is_primary":true,"text":[{"text":" async fn generate_cashflow_projection(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":50,"highlight_end":59}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"if this is intentional, prefix it with an underscore","code":null,"level":"help","spans":[{"file_name":"src/reports.rs","byte_start":9626,"byte_end":9635,"line_start":226,"line_end":226,"column_start":50,"column_end":59,"is_primary":true,"text":[{"text":" async fn generate_cashflow_projection(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {","highlight_start":50,"highlight_end":59}],"label":null,"suggested_replacement":"_tenant_id","suggestion_applicability":"MachineApplicable","expansion":null}],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: unused variable: `tenant_id`\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:226:50\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m226\u001b[0m \u001b[1m\u001b[94m|\u001b[0m async fn generate_cashflow_projection(&self, tenant_id: &str, params: &Value) -> Result<Value, String> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^\u001b[0m \u001b[1m\u001b[33mhelp: if this is intentional, prefix it with an underscore: `_tenant_id`\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"method `batch_compute` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/analytics.rs","byte_start":310,"byte_end":330,"line_start":16,"line_end":16,"column_start":1,"column_end":21,"is_primary":false,"text":[{"text":"impl AnalyticsEngine {","highlight_start":1,"highlight_end":21}],"label":"method in this implementation","suggested_replacement":null,"suggestion_applicability":null,"expansion":null},{"file_name":"src/analytics.rs","byte_start":4857,"byte_end":4870,"line_start":149,"line_end":149,"column_start":12,"column_end":25,"is_primary":true,"text":[{"text":" pub fn batch_compute(&self, tenant_id: &str, metrics: &[(&str, &str)]) -> Vec<AnalyticsResult> {","highlight_start":12,"highlight_end":25}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"`#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: method `batch_compute` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/analytics.rs:149:12\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m 16\u001b[0m \u001b[1m\u001b[94m|\u001b[0m impl AnalyticsEngine {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[94m--------------------\u001b[0m \u001b[1m\u001b[94mmethod in this implementation\u001b[0m\n\u001b[1m\u001b[94m...\u001b[0m\n\u001b[1m\u001b[94m149\u001b[0m \u001b[1m\u001b[94m|\u001b[0m pub fn batch_compute(&self, tenant_id: &str, metrics: &[(&str, &str)]) -> Vec<AnalyticsResult> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^^\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"fields `name` and `description` are never read","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/reports.rs","byte_start":179,"byte_end":193,"line_start":9,"line_end":9,"column_start":8,"column_end":22,"is_primary":false,"text":[{"text":"struct ReportTemplate {","highlight_start":8,"highlight_end":22}],"label":"fields in this struct","suggested_replacement":null,"suggestion_applicability":null,"expansion":null},{"file_name":"src/reports.rs","byte_start":200,"byte_end":204,"line_start":10,"line_end":10,"column_start":5,"column_end":9,"is_primary":true,"text":[{"text":" name: String,","highlight_start":5,"highlight_end":9}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null},{"file_name":"src/reports.rs","byte_start":218,"byte_end":229,"line_start":11,"line_end":11,"column_start":5,"column_end":16,"is_primary":true,"text":[{"text":" description: String,","highlight_start":5,"highlight_end":16}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: fields `name` and `description` are never read\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/reports.rs:10:5\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m 9\u001b[0m \u001b[1m\u001b[94m|\u001b[0m struct ReportTemplate {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[94m--------------\u001b[0m \u001b[1m\u001b[94mfields in this struct\u001b[0m\n\u001b[1m\u001b[94m10\u001b[0m \u001b[1m\u001b[94m|\u001b[0m name: String,\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^\u001b[0m\n\u001b[1m\u001b[94m11\u001b[0m \u001b[1m\u001b[94m|\u001b[0m description: String,\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"function `init_channel` is never used","code":{"code":"dead_code","explanation":null},"level":"warning","spans":[{"file_name":"src/ipc.rs","byte_start":215,"byte_end":227,"line_start":8,"line_end":8,"column_start":8,"column_end":20,"is_primary":true,"text":[{"text":"pub fn init_channel(name: &str) -> Result<(), String> {","highlight_start":8,"highlight_end":20}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: function `init_channel` is never used\u001b[0m\n \u001b[1m\u001b[94m--> \u001b[0msrc/ipc.rs:8:8\n \u001b[1m\u001b[94m|\u001b[0m\n\u001b[1m\u001b[94m8\u001b[0m \u001b[1m\u001b[94m|\u001b[0m pub fn init_channel(name: &str) -> Result<(), String> {\n \u001b[1m\u001b[94m|\u001b[0m \u001b[1m\u001b[33m^^^^^^^^^^^^\u001b[0m\n\n"}
|
||||||
|
{"$message_type":"diagnostic","message":"27 warnings emitted","code":null,"level":"warning","spans":[],"children":[],"rendered":"\u001b[1m\u001b[33mwarning\u001b[0m\u001b[1m: 27 warnings emitted\u001b[0m\n\n"}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
f1d40d367008c068
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"i128\", \"std\"]","target":8344828840634961491,"profile":2040997289075261528,"path":17947950383692024843,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/byteorder-6e3a8fd85d179480/dep-lib-byteorder","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
271f06d683869aea
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":3654867079619179846,"path":16980282986469236506,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/bytes-7dc9380364cd2a34/dep-lib-bytes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
c36d3712b8e31701
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":2040997289075261528,"path":9433148093347736929,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/cfg-if-c28393f1568b0153/dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
e1a2eee0808e7cc4
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"rng\"]","declared_features":"[\"cipher\", \"default\", \"legacy\", \"rng\", \"xchacha\", \"zeroize\"]","target":5186012452570817782,"profile":18050733770209708702,"path":11687202474411020191,"deps":[[7667230146095136825,"cfg_if",false,78781898221383107],[18359178603293420568,"rand_core",false,2843246380870605194]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/chacha20-d22823a185c48e25/dep-lib-chacha20","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ebc3f9025c8aedc0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"alloc\", \"clock\", \"default\", \"iana-time-zone\", \"js-sys\", \"now\", \"oldtime\", \"serde\", \"std\", \"wasm-bindgen\", \"wasmbind\", \"winapi\", \"windows-link\"]","declared_features":"[\"__internal_bench\", \"alloc\", \"arbitrary\", \"clock\", \"core-error\", \"default\", \"defmt\", \"iana-time-zone\", \"js-sys\", \"libc\", \"now\", \"oldtime\", \"pure-rust-locales\", \"rkyv\", \"rkyv-16\", \"rkyv-32\", \"rkyv-64\", \"rkyv-validation\", \"serde\", \"std\", \"unstable-locales\", \"wasm-bindgen\", \"wasmbind\", \"winapi\", \"windows-link\"]","target":15315924755136109342,"profile":2040997289075261528,"path":17780376413348889854,"deps":[[5157631553186200874,"num_traits",false,14119788579692270633],[13548984313718623784,"serde",false,8151733579501977522],[16619627449254928351,"iana_time_zone",false,9712474382341530848]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/chrono-cff241d94d764653/dep-lib-chrono","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1f3cafca33d2299d
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[]","target":7432811800008246249,"profile":15005971894838546436,"path":14113878691217321502,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/cmov-e22e852cf3fa7ce1/dep-lib-cmov","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
33eb5f50f822b777
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"arbitrary\", \"db\"]","target":15839317715723132186,"profile":2040997289075261528,"path":13813461199962814925,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/const-oid-fbddf381ae2612a4/dep-lib-const_oid","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
7e0dcda19b442a97
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[]","target":7407970971831147067,"profile":15005971894838546436,"path":1508821112638578062,"deps":[[7098700569944897890,"libc",false,10358284356093817449]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/cpufeatures-02fa6de1448eed7c/dep-lib-cpufeatures","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
8e2c706e35f3fe26
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[10684107345137278605,"build_script_build",false,405385505304535536]],"local":[{"RerunIfChanged":{"output":"release/build/crossbeam-deque-24893788c9d354ae/output","paths":["build.rs"]}}],"rustflags":[],"config":0,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
5a25749722a17c7f
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":15353977948366730291,"profile":14791228037615401302,"path":10580037843469392998,"deps":[[10684107345137278605,"build_script_build",false,2809950628337429646],[10951058209291271410,"crossbeam_utils",false,18382006368912275469],[13869114390706723416,"crossbeam_epoch",false,9559836231838556057]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/crossbeam-deque-56cc075aabc81ce0/dep-lib-crossbeam_deque","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
f0e1c420f837a005
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":5408242616063297496,"profile":1419616050453328851,"path":3163335187747278573,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/crossbeam-deque-dd9c30f432e6c432/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
995fa515675cab84
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"alloc\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"loom\", \"loom-crate\", \"nightly\", \"std\"]","target":16242420667881341737,"profile":14791228037615401302,"path":11685426848944331124,"deps":[[10951058209291271410,"crossbeam_utils",false,18382006368912275469],[13869114390706723416,"build_script_build",false,16122645801835036432]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/crossbeam-epoch-1a33398559ec6a44/dep-lib-crossbeam_epoch","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
10ffd171ef24bfdf
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[13869114390706723416,"build_script_build",false,10781594398831178862]],"local":[{"RerunIfChanged":{"output":"release/build/crossbeam-epoch-8deb7e5858c37858/output","paths":["build.rs"]}}],"rustflags":[],"config":0,"compile_kind":0}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
6e74ac7efbea9f95
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"alloc\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"loom\", \"loom-crate\", \"nightly\", \"std\"]","target":5408242616063297496,"profile":1419616050453328851,"path":4544127582614795669,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/crossbeam-epoch-9bb466ec27e00e7c/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
0df8fb8566011aff
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"loom\", \"nightly\", \"std\"]","target":9626079250877207070,"profile":14791228037615401302,"path":11436926997345565096,"deps":[[10951058209291271410,"build_script_build",false,14200670855589481638]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/crossbeam-utils-1eb06fd8fb643741/dep-lib-crossbeam_utils","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
a64ca79fe7ea12c5
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[10951058209291271410,"build_script_build",false,1770077806496551786]],"local":[{"RerunIfChanged":{"output":"release/build/crossbeam-utils-9e612733194446e8/output","paths":["no_atomic.rs"]}}],"rustflags":[],"config":0,"compile_kind":0}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
6a93070163949018
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"loom\", \"nightly\", \"std\"]","target":5408242616063297496,"profile":1419616050453328851,"path":2841676696308263227,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/crossbeam-utils-d404d0959e599037/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
6e06d2084f70a2d2
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"getrandom\", \"rand_core\", \"zeroize\"]","target":14002316677131120771,"profile":8917093484142751111,"path":14303971719399791221,"deps":[[3173661117269759064,"hybrid_array",false,18059322650750644876]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/crypto-common-b4193356d4abfbfb/dep-lib-crypto_common","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
e65dee2c44f10962
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"alloc\", \"subtle\"]","target":14735723286394368586,"profile":15005971894838546436,"path":7161778167789485399,"deps":[[14821918413341411223,"cmov",false,11324813857885469727]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/ctutils-10dd7512d97fb618/dep-lib-ctutils","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
8a07f7113e02c671
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"arbitrary\", \"inline\", \"raw-api\", \"rayon\", \"serde\"]","target":7646408341754254191,"profile":2040997289075261528,"path":8119509999883745608,"deps":[[2555121257709722468,"lock_api",false,14023348759600241536],[5855319743879205494,"once_cell",false,13708832399288578193],[6545091685033313457,"parking_lot_core",false,17317473899768983122],[7667230146095136825,"cfg_if",false,78781898221383107],[13018563866916002725,"hashbrown",false,5717521732401734504]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/dashmap-b73f9415bc74b734/dep-lib-dashmap","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a40906cee3085c65
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"alloc\", \"block-api\", \"default\", \"mac\", \"oid\"]","declared_features":"[\"alloc\", \"blobby\", \"block-api\", \"default\", \"dev\", \"getrandom\", \"mac\", \"oid\", \"rand_core\", \"zeroize\"]","target":10850736035647688105,"profile":8917093484142751111,"path":8971369010967965637,"deps":[[2589336589600319205,"const_oid",false,8626402061147171635],[6101016705997077623,"common",false,15177817178944702062],[9917320985600281521,"ctutils",false,7064442765621222886],[18141537268335717567,"block_buffer",false,15610444207272609696]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/digest-6127948cdbc8b6c9/dep-lib-digest","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
e9ada86fc238175d
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[]","declared_features":"[\"default\", \"serde\", \"std\", \"use_std\"]","target":17124342308084364240,"profile":2040997289075261528,"path":15294895676438055135,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/either-c2268b586ceb397c/dep-lib-either","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
b5741924258248ab
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":3697274117413853022,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":17743456753391690785,"profile":8944999695620513791,"path":3360262050279122850,"deps":[[7098700569944897890,"libc",false,10358284356093817449]],"local":[{"CheckDepInfo":{"dep_info":"release/.fingerprint/errno-06a2f72be8b8e935/dep-lib-errno","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
cdc5af4885cdbce1
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user