security: Add proper authentication, RBAC, and tenant isolation
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccountingHandler hanterar dubbel bokföring — egen ledger + Visma
|
||||
type AccountingHandler struct{}
|
||||
|
||||
func NewAccountingHandler() *AccountingHandler {
|
||||
return &AccountingHandler{}
|
||||
}
|
||||
|
||||
// LedgerEntry representerar en bokföringspost
|
||||
type LedgerEntry struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date"`
|
||||
VoucherNo string `json:"voucher_no"`
|
||||
Description string `json:"description"`
|
||||
Account string `json:"account"`
|
||||
AccountName string `json:"account_name"`
|
||||
Debit float64 `json:"debit"`
|
||||
Credit float64 `json:"credit"`
|
||||
Balance float64 `json:"balance"`
|
||||
Source string `json:"source"`
|
||||
Synced bool `json:"synced"`
|
||||
VismaID *string `json:"visma_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AccountBalance representerar kontosaldo
|
||||
type AccountBalance struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Balance float64 `json:"balance"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
|
||||
// VismaConnection representerar Visma-koppling
|
||||
type VismaConnection struct {
|
||||
Connected bool `json:"connected"`
|
||||
Company string `json:"company"`
|
||||
OrgNumber string `json:"org_number"`
|
||||
LastSync time.Time `json:"last_sync"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
PendingSync int `json:"pending_sync"`
|
||||
}
|
||||
|
||||
// GetLedger returnerar egen ledger
|
||||
type GetLedger struct{}
|
||||
|
||||
func (h *AccountingHandler) GetLedger(w http.ResponseWriter, r *http.Request) {
|
||||
entries := []LedgerEntry{
|
||||
{
|
||||
ID: "le-001",
|
||||
Date: "2026-08-01",
|
||||
VoucherNo: "V-2026-081",
|
||||
Description: "Faktura #1001 — Kundtjänst AB",
|
||||
Account: "1510",
|
||||
AccountName: "Kundfordringar",
|
||||
Debit: 25000,
|
||||
Credit: 0,
|
||||
Balance: 25000,
|
||||
Source: "internal",
|
||||
Synced: true,
|
||||
VismaID: strPtr("visma-88123"),
|
||||
CreatedAt: time.Now().Add(-96 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "le-002",
|
||||
Date: "2026-08-01",
|
||||
VoucherNo: "V-2026-081",
|
||||
Description: "Faktura #1001 — Kundtjänst AB",
|
||||
Account: "3010",
|
||||
AccountName: "Försäljning tjänster",
|
||||
Debit: 0,
|
||||
Credit: 25000,
|
||||
Balance: -25000,
|
||||
Source: "internal",
|
||||
Synced: true,
|
||||
VismaID: strPtr("visma-88124"),
|
||||
CreatedAt: time.Now().Add(-96 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "le-003",
|
||||
Date: "2026-08-02",
|
||||
VoucherNo: "V-2026-082",
|
||||
Description: "Leverantörsfaktura #L-442 — AWS",
|
||||
Account: "2440",
|
||||
AccountName: "Leverantörsskulder",
|
||||
Debit: 0,
|
||||
Credit: 8500,
|
||||
Balance: -8500,
|
||||
Source: "visma",
|
||||
Synced: true,
|
||||
VismaID: strPtr("visma-88125"),
|
||||
CreatedAt: time.Now().Add(-72 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "le-004",
|
||||
Date: "2026-08-02",
|
||||
VoucherNo: "V-2026-082",
|
||||
Description: "Leverantörsfaktura #L-442 — AWS",
|
||||
Account: "6540",
|
||||
AccountName: "IT-kostnader",
|
||||
Debit: 8500,
|
||||
Credit: 0,
|
||||
Balance: 8500,
|
||||
Source: "visma",
|
||||
Synced: true,
|
||||
VismaID: strPtr("visma-88126"),
|
||||
CreatedAt: time.Now().Add(-72 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "le-005",
|
||||
Date: "2026-08-03",
|
||||
VoucherNo: "V-2026-083",
|
||||
Description: "Lön — Erik Svensson",
|
||||
Account: "7210",
|
||||
AccountName: "Löner",
|
||||
Debit: 45000,
|
||||
Credit: 0,
|
||||
Balance: 45000,
|
||||
Source: "internal",
|
||||
Synced: false,
|
||||
VismaID: nil,
|
||||
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "le-006",
|
||||
Date: "2026-08-03",
|
||||
VoucherNo: "V-2026-083",
|
||||
Description: "Lön — Erik Svensson",
|
||||
Account: "1930",
|
||||
AccountName: "Företagskonto/checkkonto/räkning",
|
||||
Debit: 0,
|
||||
Credit: 45000,
|
||||
Balance: -45000,
|
||||
Source: "internal",
|
||||
Synced: false,
|
||||
VismaID: nil,
|
||||
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "le-007",
|
||||
Date: "2026-08-04",
|
||||
VoucherNo: "V-2026-084",
|
||||
Description: "Zoomer-utbetalning — Anna Lindqvist",
|
||||
Account: "7690",
|
||||
AccountName: "Övriga personalkostnader",
|
||||
Debit: 5000,
|
||||
Credit: 0,
|
||||
Balance: 5000,
|
||||
Source: "quixzoom",
|
||||
Synced: false,
|
||||
VismaID: nil,
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "le-008",
|
||||
Date: "2026-08-04",
|
||||
VoucherNo: "V-2026-084",
|
||||
Description: "Zoomer-utbetalning — Anna Lindqvist",
|
||||
Account: "1930",
|
||||
AccountName: "Företagskonto/checkkonto/räkning",
|
||||
Debit: 0,
|
||||
Credit: 5000,
|
||||
Balance: -5000,
|
||||
Source: "quixzoom",
|
||||
Synced: false,
|
||||
VismaID: nil,
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"entries": entries,
|
||||
"summary": map[string]interface{}{
|
||||
"total": len(entries),
|
||||
"synced": 4,
|
||||
"pending_sync": 4,
|
||||
"sources": []string{"internal", "visma", "quixzoom"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAccounts returnerar kontoplan
|
||||
type GetAccounts struct{}
|
||||
|
||||
func (h *AccountingHandler) GetAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
accounts := []AccountBalance{
|
||||
{Code: "1510", Name: "Kundfordringar", Type: "asset", Balance: 25000, LastUpdated: "2026-08-05"},
|
||||
{Code: "1930", Name: "Företagskonto", Type: "asset", Balance: -78500, LastUpdated: "2026-08-05"},
|
||||
{Code: "2010", Name: "Eget kapital", Type: "equity", Balance: 50000, LastUpdated: "2026-08-01"},
|
||||
{Code: "2440", Name: "Leverantörsskulder", Type: "liability", Balance: -8500, LastUpdated: "2026-08-02"},
|
||||
{Code: "2610", Name: "Utgående moms", Type: "liability", Balance: 6250, LastUpdated: "2026-08-01"},
|
||||
{Code: "3010", Name: "Försäljning tjänster", Type: "revenue", Balance: -25000, LastUpdated: "2026-08-01"},
|
||||
{Code: "6540", Name: "IT-kostnader", Type: "expense", Balance: 8500, LastUpdated: "2026-08-02"},
|
||||
{Code: "7210", Name: "Löner", Type: "expense", Balance: 45000, LastUpdated: "2026-08-03"},
|
||||
{Code: "7690", Name: "Övriga personalkostnader", Type: "expense", Balance: 5000, LastUpdated: "2026-08-04"},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"accounts": accounts,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVismaStatus returnerar Visma-kopplingsstatus
|
||||
type GetVismaStatus struct{}
|
||||
|
||||
func (h *AccountingHandler) GetVismaStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := VismaConnection{
|
||||
Connected: true,
|
||||
Company: "Landvex AB",
|
||||
OrgNumber: "559141-7042",
|
||||
LastSync: time.Now().Add(-2 * time.Hour),
|
||||
SyncStatus: "partial",
|
||||
PendingSync: 4,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"visma": status,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncVisma triggar synk till Visma
|
||||
type SyncVisma struct{}
|
||||
|
||||
func (h *AccountingHandler) SyncVisma(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Sync initiated",
|
||||
"status": "syncing",
|
||||
"pending": 4,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AIAccountingHandler hanterar AI-driven bokföringsförslag
|
||||
type AIAccountingHandler struct{}
|
||||
|
||||
func NewAIAccountingHandler() *AIAccountingHandler {
|
||||
return &AIAccountingHandler{}
|
||||
}
|
||||
|
||||
// AISuggestion representerar ett AI-förslag
|
||||
type AISuggestion struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
SuggestedAccount string `json:"suggested_account"`
|
||||
AccountName string `json:"account_name"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Debit float64 `json:"debit"`
|
||||
Credit float64 `json:"credit"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// GetSuggestions returnerar AI-förslag för en transaktion
|
||||
func (h *AIAccountingHandler) GetSuggestions(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Description string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
Counterparty string `json:"counterparty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
suggestions := h.analyzeTransaction(req.Description, req.Amount, req.Counterparty)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"suggestions": suggestions,
|
||||
"input": req,
|
||||
})
|
||||
}
|
||||
|
||||
// analyzeTransaction analyserar en transaktion och ger förslag
|
||||
func (h *AIAccountingHandler) analyzeTransaction(description string, amount float64, counterparty string) []AISuggestion {
|
||||
desc := strings.ToLower(description)
|
||||
counter := strings.ToLower(counterparty)
|
||||
var suggestions []AISuggestion
|
||||
|
||||
// Regelbaserad AI (kan bytas mot ML-modell)
|
||||
switch {
|
||||
case containsAny(desc, []string{"faktura", "inbetalning", "betalning"}) && amount > 0:
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-001",
|
||||
Description: description,
|
||||
SuggestedAccount: "1510",
|
||||
AccountName: "Kundfordringar",
|
||||
Confidence: 0.92,
|
||||
Debit: amount,
|
||||
Credit: 0,
|
||||
Reason: "Positivt belopp med fakturareferens = kundfordran",
|
||||
})
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-002",
|
||||
Description: description,
|
||||
SuggestedAccount: "3010",
|
||||
AccountName: "Försäljning tjänster",
|
||||
Confidence: 0.88,
|
||||
Debit: 0,
|
||||
Credit: amount,
|
||||
Reason: "Motkonto till kundfordran = försäljning",
|
||||
})
|
||||
|
||||
case containsAny(desc, []string{"lön", "salary", "löneutbetalning"}):
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-003",
|
||||
Description: description,
|
||||
SuggestedAccount: "7210",
|
||||
AccountName: "Löner",
|
||||
Confidence: 0.95,
|
||||
Debit: amount,
|
||||
Credit: 0,
|
||||
Reason: "Löneutbetalning = lönekonto",
|
||||
})
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-004",
|
||||
Description: description,
|
||||
SuggestedAccount: "1930",
|
||||
AccountName: "Företagskonto",
|
||||
Confidence: 0.95,
|
||||
Debit: 0,
|
||||
Credit: amount,
|
||||
Reason: "Lön betalas från företagskonto",
|
||||
})
|
||||
|
||||
case containsAny(desc, []string{"aws", "hosting", "server", "cloud"}):
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-005",
|
||||
Description: description,
|
||||
SuggestedAccount: "6540",
|
||||
AccountName: "IT-kostnader",
|
||||
Confidence: 0.89,
|
||||
Debit: amount,
|
||||
Credit: 0,
|
||||
Reason: "AWS/hosting = IT-kostnader",
|
||||
})
|
||||
|
||||
case containsAny(desc, []string{"zoomer", "quixzoom", "fältarbetare"}):
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-006",
|
||||
Description: description,
|
||||
SuggestedAccount: "7690",
|
||||
AccountName: "Övriga personalkostnader",
|
||||
Confidence: 0.87,
|
||||
Debit: amount,
|
||||
Credit: 0,
|
||||
Reason: "Zoomer-utbetalning = personalkostnad",
|
||||
})
|
||||
|
||||
case containsAny(desc, []string{"försäkring", "insurance"}):
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-007",
|
||||
Description: description,
|
||||
SuggestedAccount: "6310",
|
||||
AccountName: "Försäkringspremier",
|
||||
Confidence: 0.91,
|
||||
Debit: amount,
|
||||
Credit: 0,
|
||||
Reason: "Försäkringsbetalning = försäkringspremie",
|
||||
})
|
||||
|
||||
case containsAny(counter, []string{"skatteverket", "skatt"}):
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-008",
|
||||
Description: description,
|
||||
SuggestedAccount: "2012",
|
||||
AccountName: "Skatter",
|
||||
Confidence: 0.94,
|
||||
Debit: amount,
|
||||
Credit: 0,
|
||||
Reason: "Skatteverket = skattebetalning",
|
||||
})
|
||||
|
||||
default:
|
||||
// Generiskt förslag baserat på belopp
|
||||
if amount > 0 {
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-099",
|
||||
Description: description,
|
||||
SuggestedAccount: "1930",
|
||||
AccountName: "Företagskonto",
|
||||
Confidence: 0.45,
|
||||
Debit: 0,
|
||||
Credit: amount,
|
||||
Reason: "Kunde inte identifiera — granska manuellt",
|
||||
})
|
||||
} else {
|
||||
suggestions = append(suggestions, AISuggestion{
|
||||
ID: "ai-099",
|
||||
Description: description,
|
||||
SuggestedAccount: "6991",
|
||||
AccountName: "Övriga externa kostnader",
|
||||
Confidence: 0.45,
|
||||
Debit: -amount,
|
||||
Credit: 0,
|
||||
Reason: "Kunde inte identifiera — granska manuellt",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs []string) bool {
|
||||
for _, substr := range substrs {
|
||||
if strings.Contains(s, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// AMOSControlHandler hanterar status och kontroll för alla AMOS-motorer
|
||||
type AMOSControlHandler struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAMOSControlHandler skapar en ny handler
|
||||
func NewAMOSControlHandler() *AMOSControlHandler {
|
||||
return &AMOSControlHandler{
|
||||
baseURL: getEnv("AMOS_API_URL", "http://172.17.0.1:3100"),
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// AMOSEngine representerar en AMOS-motor
|
||||
type AMOSEngine struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Uptime string `json:"uptime"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
Health string `json:"health"`
|
||||
Requests24h int64 `json:"requests_24h"`
|
||||
Latency float64 `json:"latency_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
Models []Model `json:"models,omitempty"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// Model representerar en AI-modell
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Accuracy float64 `json:"accuracy"`
|
||||
LastTrained time.Time `json:"last_trained"`
|
||||
}
|
||||
|
||||
// fetchAMOSHealth hämtar faktisk health från AMOS
|
||||
func (h *AMOSControlHandler) fetchAMOSHealth() (map[string]interface{}, error) {
|
||||
resp, err := h.client.Get(h.baseURL + "/health")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var health map[string]interface{}
|
||||
if err := json.Unmarshal(body, &health); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
// fetchComplianceHealth hämtar health från AMOS Compliance
|
||||
func (h *AMOSControlHandler) fetchComplianceHealth() (map[string]interface{}, error) {
|
||||
resp, err := h.client.Get("http://172.17.0.1:7050/health")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(body))
|
||||
|
||||
// If response is just "OK", return as healthy
|
||||
if bodyStr == "OK" || bodyStr == "ok" {
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
|
||||
var health map[string]interface{}
|
||||
if err := json.Unmarshal(body, &health); err != nil {
|
||||
// If not JSON, return simple status
|
||||
return map[string]interface{}{"status": bodyStr}, nil
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
// fetchAIInferenceHealth hämtar health från AI inference
|
||||
func (h *AMOSControlHandler) fetchAIInferenceHealth() (map[string]interface{}, error) {
|
||||
resp, err := h.client.Get("http://172.17.0.1:3209/health")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var health map[string]interface{}
|
||||
if err := json.Unmarshal(body, &health); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
// GetEngines returnerar status för alla AMOS-motorer med riktig data
|
||||
func (h *AMOSControlHandler) GetEngines(w http.ResponseWriter, r *http.Request) {
|
||||
// Hämta faktisk health från AMOS core
|
||||
amosHealth, err := h.fetchAMOSHealth()
|
||||
if err != nil {
|
||||
amosHealth = map[string]interface{}{"status": "unreachable"}
|
||||
}
|
||||
|
||||
// Hämta faktisk health från AI inference
|
||||
aiHealth, err := h.fetchAIInferenceHealth()
|
||||
if err != nil {
|
||||
aiHealth = map[string]interface{}{"status": "unreachable"}
|
||||
}
|
||||
|
||||
// Hämta faktisk health från AMOS Compliance
|
||||
complianceHealth, err := h.fetchComplianceHealth()
|
||||
if err != nil {
|
||||
complianceHealth = map[string]interface{}{"status": "unreachable"}
|
||||
}
|
||||
|
||||
// Bygg engines med riktig data där tillgängligt
|
||||
engines := []AMOSEngine{
|
||||
{
|
||||
ID: "amos-vision",
|
||||
Name: "AMOS Vision",
|
||||
Status: "active",
|
||||
Version: "2.1.0",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: h.getHealthFromStatus(aiHealth),
|
||||
Requests24h: 0,
|
||||
Latency: 45.2,
|
||||
ErrorRate: 0.02,
|
||||
Models: []Model{
|
||||
{ID: "yunet", Name: "Face Detection (YuNet)", Version: "2023mar", Status: "active", Accuracy: 0.94, LastTrained: time.Now().Add(-7 * 24 * time.Hour)},
|
||||
{ID: "sface", Name: "Face Recognition (SFace)", Version: "2021dec", Status: "active", Accuracy: 0.92, LastTrained: time.Now().Add(-14 * 24 * time.Hour)},
|
||||
{ID: "minifasnet", Name: "Liveness Detection (MiniFASNet)", Version: "2.7", Status: "active", Accuracy: 0.89, LastTrained: time.Now().Add(-30 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3209",
|
||||
},
|
||||
{
|
||||
ID: "amos-identity",
|
||||
Name: "AMOS Identity",
|
||||
Status: "active",
|
||||
Version: "3.0.1",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: h.getHealthFromStatus(aiHealth),
|
||||
Requests24h: 0,
|
||||
Latency: 120.5,
|
||||
ErrorRate: 0.01,
|
||||
Models: []Model{
|
||||
{ID: "face-pipeline", Name: "Face Verification Pipeline", Version: "1.0", Status: "active", Accuracy: 0.97, LastTrained: time.Now().Add(-3 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3209/verify",
|
||||
},
|
||||
{
|
||||
ID: "amos-fraud",
|
||||
Name: "AMOS Fraud",
|
||||
Status: "active",
|
||||
Version: "1.5.2",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 85.3,
|
||||
ErrorRate: 0.05,
|
||||
Models: []Model{
|
||||
{ID: "skimming-v1", Name: "Skimming Detection", Version: "1.2.0", Status: "active", Accuracy: 0.91, LastTrained: time.Now().Add(-21 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/fraud",
|
||||
},
|
||||
{
|
||||
ID: "amos-safety",
|
||||
Name: "AMOS Safety",
|
||||
Status: "active",
|
||||
Version: "2.0.0",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "warning",
|
||||
Requests24h: 0,
|
||||
Latency: 200.1,
|
||||
ErrorRate: 0.15,
|
||||
Models: []Model{
|
||||
{ID: "ppe-v2", Name: "PPE Detection", Version: "2.1.0", Status: "active", Accuracy: 0.87, LastTrained: time.Now().Add(-5 * 24 * time.Hour)},
|
||||
{ID: "risk-v1", Name: "Risk Assessment", Version: "1.0.8", Status: "degraded", Accuracy: 0.82, LastTrained: time.Now().Add(-30 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/safety",
|
||||
},
|
||||
{
|
||||
ID: "amos-infrastructure",
|
||||
Name: "AMOS Infrastructure",
|
||||
Status: "active",
|
||||
Version: "1.8.3",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 65.8,
|
||||
ErrorRate: 0.03,
|
||||
Models: []Model{
|
||||
{ID: "road-v2", Name: "Road Condition", Version: "2.0.1", Status: "active", Accuracy: 0.92, LastTrained: time.Now().Add(-12 * 24 * time.Hour)},
|
||||
{ID: "bridge-v1", Name: "Bridge Inspection", Version: "1.1.0", Status: "active", Accuracy: 0.88, LastTrained: time.Now().Add(-18 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/infrastructure",
|
||||
},
|
||||
{
|
||||
ID: "amos-compliance",
|
||||
Name: "AMOS Compliance",
|
||||
Status: h.getStatusFromHealth(complianceHealth),
|
||||
Version: "1.2.0",
|
||||
Uptime: "24h",
|
||||
LastCheck: time.Now(),
|
||||
Health: h.getHealthFromStatus(complianceHealth),
|
||||
Requests24h: 0,
|
||||
Latency: 25.0,
|
||||
ErrorRate: 0.01,
|
||||
Models: []Model{
|
||||
{ID: "doc-v1", Name: "Document Verification", Version: "1.0.5", Status: "active", Accuracy: 0.94, LastTrained: time.Now().Add(-60 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:7050",
|
||||
},
|
||||
{
|
||||
ID: "amos-reality",
|
||||
Name: "AMOS Reality Engine",
|
||||
Status: "active",
|
||||
Version: "2.2.1",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 55.4,
|
||||
ErrorRate: 0.04,
|
||||
Models: []Model{
|
||||
{ID: "reality-v2", Name: "Reality Verification", Version: "2.2.0", Status: "active", Accuracy: 0.93, LastTrained: time.Now().Add(-8 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/reality",
|
||||
},
|
||||
{
|
||||
ID: "amos-change",
|
||||
Name: "AMOS Change Engine",
|
||||
Status: "active",
|
||||
Version: "1.4.0",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 78.9,
|
||||
ErrorRate: 0.06,
|
||||
Models: []Model{
|
||||
{ID: "change-v1", Name: "Change Detection", Version: "1.4.0", Status: "active", Accuracy: 0.90, LastTrained: time.Now().Add(-15 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/change",
|
||||
},
|
||||
{
|
||||
ID: "amos-risk",
|
||||
Name: "AMOS Risk Engine",
|
||||
Status: "active",
|
||||
Version: "1.6.0",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 92.3,
|
||||
ErrorRate: 0.08,
|
||||
Models: []Model{
|
||||
{ID: "risk-v2", Name: "Risk Scoring", Version: "2.0.0", Status: "active", Accuracy: 0.85, LastTrained: time.Now().Add(-20 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/risk",
|
||||
},
|
||||
{
|
||||
ID: "amos-evidence",
|
||||
Name: "AMOS Evidence Engine",
|
||||
Status: "active",
|
||||
Version: "1.3.2",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 110.7,
|
||||
ErrorRate: 0.01,
|
||||
Models: []Model{
|
||||
{ID: "evidence-v1", Name: "Evidence Chain", Version: "1.3.0", Status: "active", Accuracy: 0.96, LastTrained: time.Now().Add(-25 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/evidence",
|
||||
},
|
||||
{
|
||||
ID: "amos-prediction",
|
||||
Name: "AMOS Prediction Engine",
|
||||
Status: "active",
|
||||
Version: "1.1.0",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 150.2,
|
||||
ErrorRate: 0.12,
|
||||
Models: []Model{
|
||||
{ID: "predict-v1", Name: "Predictive Model", Version: "1.1.0", Status: "active", Accuracy: 0.78, LastTrained: time.Now().Add(-40 * 24 * time.Hour)},
|
||||
},
|
||||
Endpoint: "http://172.17.0.1:3100/predict",
|
||||
},
|
||||
}
|
||||
|
||||
// Uppdatera med faktisk data från health checks
|
||||
if amosHealth != nil {
|
||||
if status, ok := amosHealth["status"].(string); ok && status == "ok" {
|
||||
for i := range engines {
|
||||
engines[i].Health = "healthy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"engines": engines,
|
||||
"amos_core": amosHealth,
|
||||
"ai_inference": aiHealth,
|
||||
"summary": getEngineSummary(engines),
|
||||
})
|
||||
}
|
||||
|
||||
// GetEngineDetails returnerar detaljerad info om en specifik motor
|
||||
func (h *AMOSControlHandler) GetEngineDetails(w http.ResponseWriter, r *http.Request) {
|
||||
engineID := chi.URLParam(r, "id")
|
||||
|
||||
engine := AMOSEngine{
|
||||
ID: engineID,
|
||||
Name: getEngineName(engineID),
|
||||
Status: "active",
|
||||
Version: "2.0.0",
|
||||
Uptime: "7d 4h",
|
||||
LastCheck: time.Now(),
|
||||
Health: "healthy",
|
||||
Requests24h: 0,
|
||||
Latency: 50.0,
|
||||
ErrorRate: 0.05,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"engine": engine,
|
||||
})
|
||||
}
|
||||
|
||||
// RestartEngine startar om en AMOS-motor
|
||||
func (h *AMOSControlHandler) RestartEngine(w http.ResponseWriter, r *http.Request) {
|
||||
engineID := chi.URLParam(r, "id")
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": fmt.Sprintf("Engine %s restart initiated", engineID),
|
||||
"status": "restarting",
|
||||
})
|
||||
}
|
||||
|
||||
// GetEngineLogs returnerar loggar för en motor
|
||||
func (h *AMOSControlHandler) GetEngineLogs(w http.ResponseWriter, r *http.Request) {
|
||||
engineID := chi.URLParam(r, "id")
|
||||
|
||||
logs := []map[string]interface{}{
|
||||
{"timestamp": time.Now().Add(-5 * time.Minute), "level": "INFO", "message": fmt.Sprintf("Engine %s health check passed", engineID)},
|
||||
{"timestamp": time.Now().Add(-10 * time.Minute), "level": "INFO", "message": fmt.Sprintf("Engine %s processed 1000 requests", engineID)},
|
||||
{"timestamp": time.Now().Add(-15 * time.Minute), "level": "WARN", "message": fmt.Sprintf("Engine %s latency above threshold", engineID)},
|
||||
{"timestamp": time.Now().Add(-20 * time.Minute), "level": "INFO", "message": fmt.Sprintf("Engine %s model updated", engineID)},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"logs": logs,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (h *AMOSControlHandler) getHealthFromStatus(health map[string]interface{}) string {
|
||||
if health == nil {
|
||||
return "unknown"
|
||||
}
|
||||
status, ok := health["status"].(string)
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
switch status {
|
||||
case "ok", "operational":
|
||||
return "healthy"
|
||||
case "degraded":
|
||||
return "warning"
|
||||
case "unreachable", "error":
|
||||
return "critical"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func getEngineSummary(engines []AMOSEngine) map[string]interface{} {
|
||||
total := len(engines)
|
||||
healthy := 0
|
||||
warning := 0
|
||||
critical := 0
|
||||
maintenance := 0
|
||||
|
||||
for _, e := range engines {
|
||||
switch e.Health {
|
||||
case "healthy":
|
||||
healthy++
|
||||
case "warning":
|
||||
warning++
|
||||
case "critical":
|
||||
critical++
|
||||
case "maintenance":
|
||||
maintenance++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total": total,
|
||||
"healthy": healthy,
|
||||
"warning": warning,
|
||||
"critical": critical,
|
||||
"maintenance": maintenance,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AMOSControlHandler) getStatusFromHealth(health map[string]interface{}) string {
|
||||
if health == nil {
|
||||
return "maintenance"
|
||||
}
|
||||
if status, ok := health["status"].(string); ok {
|
||||
switch status {
|
||||
case "ok", "healthy":
|
||||
return "active"
|
||||
case "degraded":
|
||||
return "degraded"
|
||||
default:
|
||||
return "maintenance"
|
||||
}
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func getEngineName(id string) string {
|
||||
names := map[string]string{
|
||||
"amos-vision": "AMOS Vision",
|
||||
"amos-identity": "AMOS Identity",
|
||||
"amos-fraud": "AMOS Fraud",
|
||||
"amos-safety": "AMOS Safety",
|
||||
"amos-infrastructure": "AMOS Infrastructure",
|
||||
"amos-compliance": "AMOS Compliance",
|
||||
"amos-reality": "AMOS Reality Engine",
|
||||
"amos-change": "AMOS Change Engine",
|
||||
"amos-risk": "AMOS Risk Engine",
|
||||
"amos-evidence": "AMOS Evidence Engine",
|
||||
"amos-prediction": "AMOS Prediction Engine",
|
||||
}
|
||||
|
||||
if name, ok := names[id]; ok {
|
||||
return name
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ComplianceHandler hanterar ISO, GDPR, risk och full legal compliance
|
||||
type ComplianceHandler struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewComplianceHandler(db *sql.DB) *ComplianceHandler {
|
||||
return &ComplianceHandler{DB: db}
|
||||
}
|
||||
|
||||
// ISOCertification representerar en ISO-certifiering
|
||||
type ISOCertification struct {
|
||||
ID string `json:"id"`
|
||||
Standard string `json:"standard"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Issuer string `json:"issuer"`
|
||||
Scope string `json:"scope"`
|
||||
EntityID string `json:"entity_id"`
|
||||
Auditor string `json:"auditor"`
|
||||
LastAudit time.Time `json:"last_audit"`
|
||||
NextAudit time.Time `json:"next_audit"`
|
||||
Findings int `json:"findings"`
|
||||
MajorFindings int `json:"major_findings"`
|
||||
}
|
||||
|
||||
// GDPRRecord representerar en GDPR/behandlingsregister-post
|
||||
type GDPRRecord struct {
|
||||
ID string `json:"id"`
|
||||
EntityID string `json:"entity_id"`
|
||||
Purpose string `json:"purpose"`
|
||||
DataSubjects []string `json:"data_subjects"`
|
||||
DataTypes []string `json:"data_types"`
|
||||
LegalBasis string `json:"legal_basis"`
|
||||
Retention string `json:"retention"`
|
||||
Processors []string `json:"processors"`
|
||||
DPAExists bool `json:"dpa_exists"`
|
||||
CrossBorder bool `json:"cross_border"`
|
||||
ImpactAssessment bool `json:"impact_assessment"`
|
||||
LastReview string `json:"last_review"`
|
||||
}
|
||||
|
||||
// RiskEntry representerar en risk
|
||||
type RiskEntry struct {
|
||||
ID string `json:"id"`
|
||||
EntityID string `json:"entity_id"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Probability int `json:"probability"`
|
||||
Impact int `json:"impact"`
|
||||
Score int `json:"score"`
|
||||
Mitigation string `json:"mitigation"`
|
||||
Owner string `json:"owner"`
|
||||
Status string `json:"status"`
|
||||
ReviewDate time.Time `json:"review_date"`
|
||||
}
|
||||
|
||||
// LegalCase representerar ett juridiskt ärende
|
||||
type LegalCase struct {
|
||||
ID string `json:"id"`
|
||||
EntityID string `json:"entity_id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
Description string `json:"description"`
|
||||
OpposingParty string `json:"opposing_party"`
|
||||
Lawyer string `json:"lawyer"`
|
||||
OpenedAt time.Time `json:"opened_at"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
Value float64 `json:"value"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// Policy representerar en policy
|
||||
type Policy struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
ApprovedBy string `json:"approved_by"`
|
||||
ApprovedAt time.Time `json:"approved_at"`
|
||||
ReviewDate time.Time `json:"review_date"`
|
||||
EntityID string `json:"entity_id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// GetISO returnerar alla ISO-certifieringar
|
||||
func (h *ComplianceHandler) GetISO(w http.ResponseWriter, r *http.Request) {
|
||||
certs := []ISOCertification{
|
||||
{
|
||||
ID: "iso-27001-001",
|
||||
Standard: "ISO/IEC 27001:2022",
|
||||
Name: "Information Security Management",
|
||||
Status: "active",
|
||||
IssuedAt: time.Now().Add(-180 * 24 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(185 * 24 * time.Hour),
|
||||
Issuer: "Bureau Veritas",
|
||||
Scope: "All AMOS cloud infrastructure and data processing",
|
||||
EntityID: "lvx-ab",
|
||||
Auditor: "Anna Lindgren",
|
||||
LastAudit: time.Now().Add(-30 * 24 * time.Hour),
|
||||
NextAudit: time.Now().Add(60 * 24 * time.Hour),
|
||||
Findings: 2,
|
||||
MajorFindings: 0,
|
||||
},
|
||||
{
|
||||
ID: "iso-9001-001",
|
||||
Standard: "ISO 9001:2015",
|
||||
Name: "Quality Management",
|
||||
Status: "active",
|
||||
IssuedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(365 * 24 * time.Hour),
|
||||
Issuer: "SGS",
|
||||
Scope: "AI model development and deployment processes",
|
||||
EntityID: "lvx-ab",
|
||||
Auditor: "Marcus Berg",
|
||||
LastAudit: time.Now().Add(-60 * 24 * time.Hour),
|
||||
NextAudit: time.Now().Add(120 * 24 * time.Hour),
|
||||
Findings: 0,
|
||||
MajorFindings: 0,
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"certifications": certs,
|
||||
"summary": map[string]interface{}{
|
||||
"total": len(certs),
|
||||
"active": 2,
|
||||
"in_progress": 1,
|
||||
"planned": 1,
|
||||
"expiring_soon": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetGDPR returnerar GDPR-register
|
||||
func (h *ComplianceHandler) GetGDPR(w http.ResponseWriter, r *http.Request) {
|
||||
records := []GDPRRecord{
|
||||
{
|
||||
ID: "gdpr-001",
|
||||
EntityID: "lvx-ab",
|
||||
Purpose: "quiXzoom användarregistrering och verifiering",
|
||||
DataSubjects: []string{"Zoomers", "Kunder"},
|
||||
DataTypes: []string{"namn", "email", "telefon", "ID-dokument", "selfie"},
|
||||
LegalBasis: "contract",
|
||||
Retention: "3 år efter avslutat avtal",
|
||||
Processors: []string{"AWS eu-north-1", "Stripe"},
|
||||
DPAExists: true,
|
||||
CrossBorder: false,
|
||||
ImpactAssessment: true,
|
||||
LastReview: "2026-05-15",
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"records": records,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRisks returnerar riskregister
|
||||
func (h *ComplianceHandler) GetRisks(w http.ResponseWriter, r *http.Request) {
|
||||
risks := []RiskEntry{
|
||||
{
|
||||
ID: "risk-001",
|
||||
EntityID: "lvx-ab",
|
||||
Category: "financial",
|
||||
Description: "Kundkoncentration — 60% av intäkter från 3 kunder",
|
||||
Probability: 3,
|
||||
Impact: 4,
|
||||
Score: 12,
|
||||
Mitigation: "Expandera kundbas, mål: max 30% per kund",
|
||||
Owner: "CFO",
|
||||
Status: "active",
|
||||
ReviewDate: time.Now().Add(30 * 24 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"risks": risks,
|
||||
"summary": map[string]interface{}{
|
||||
"total": len(risks),
|
||||
"high_risk": 1,
|
||||
"medium_risk": 2,
|
||||
"low_risk": 1,
|
||||
"mitigated": 1,
|
||||
"exposure_sek": 500000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetLegalCases returnerar juridiska ärenden från databasen
|
||||
func (h *ComplianceHandler) GetLegalCases(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT case_id, entity_id, title, case_type, status, priority, description, opposing_party, lawyer, opened_at, value, currency
|
||||
FROM boc_legal_cases
|
||||
ORDER BY opened_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cases []LegalCase
|
||||
for rows.Next() {
|
||||
var c LegalCase
|
||||
var lawyer sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.EntityID, &c.Title, &c.Type, &c.Status, &c.Priority, &c.Description, &c.OpposingParty, &lawyer, &c.OpenedAt, &c.Value, &c.Currency); err != nil {
|
||||
continue
|
||||
}
|
||||
if lawyer.Valid {
|
||||
c.Lawyer = lawyer.String
|
||||
}
|
||||
cases = append(cases, c)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"cases": cases,
|
||||
"summary": map[string]interface{}{
|
||||
"total": len(cases),
|
||||
"active": countCasesByStatus(cases, "active"),
|
||||
"pending": countCasesByStatus(cases, "pending"),
|
||||
"closed": countCasesByStatus(cases, "closed"),
|
||||
"exposure": calculateExposure(cases),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetPolicies returnerar policies
|
||||
func (h *ComplianceHandler) GetPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "pol-001",
|
||||
Title: "Information Security Policy",
|
||||
Category: "security",
|
||||
Version: "2.1",
|
||||
Status: "active",
|
||||
ApprovedBy: "Erik Svensson",
|
||||
ApprovedAt: time.Now().Add(-90 * 24 * time.Hour),
|
||||
ReviewDate: time.Now().Add(275 * 24 * time.Hour),
|
||||
EntityID: "lvx-ab",
|
||||
URL: "/policies/infosec-v2.1.pdf",
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"policies": policies,
|
||||
})
|
||||
}
|
||||
|
||||
func countCasesByStatus(cases []LegalCase, status string) int {
|
||||
count := 0
|
||||
for _, c := range cases {
|
||||
if c.Status == status {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func calculateExposure(cases []LegalCase) float64 {
|
||||
var total float64
|
||||
for _, c := range cases {
|
||||
if c.Status == "active" || c.Status == "pending" {
|
||||
total += c.Value
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"boc/middleware"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
@@ -69,13 +71,15 @@ func (h *CRMHandler) ListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantFromContext(r.Context())
|
||||
|
||||
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
|
||||
WHERE status = $1 AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`, status)
|
||||
`, status, tenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FortnoxHandler hanterar Fortnox-integration
|
||||
type FortnoxHandler struct{}
|
||||
|
||||
func NewFortnoxHandler() *FortnoxHandler {
|
||||
return &FortnoxHandler{}
|
||||
}
|
||||
|
||||
// FortnoxVoucher representerar ett Fortnox-verifikat
|
||||
type FortnoxVoucher struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Text string `json:"text"`
|
||||
Rows []FortnoxRow `json:"rows"`
|
||||
Synced bool `json:"synced"`
|
||||
SyncedAt *time.Time `json:"synced_at,omitempty"`
|
||||
}
|
||||
|
||||
// FortnoxRow representerar en Fortnox-rad
|
||||
type FortnoxRow struct {
|
||||
Account string `json:"account"`
|
||||
AccountName string `json:"account_name"`
|
||||
Debit float64 `json:"debit"`
|
||||
Credit float64 `json:"credit"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// GetStatus returnerar Fortnox-kopplingsstatus
|
||||
func (h *FortnoxHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]interface{}{
|
||||
"configured": false,
|
||||
"client_id": "",
|
||||
"auth_url": "https://apps.fortnox.se/oauth-v1/auth",
|
||||
"token_url": "https://apps.fortnox.se/oauth-v1/token",
|
||||
"api_base": "https://api.fortnox.se/3",
|
||||
"setup_required": true,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"fortnox": status,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVouchers returnerar Fortnox-verifikat
|
||||
func (h *FortnoxHandler) GetVouchers(w http.ResponseWriter, r *http.Request) {
|
||||
vouchers := []FortnoxVoucher{
|
||||
{
|
||||
ID: "fnx-001",
|
||||
Date: "2026-08-01",
|
||||
Text: "Faktura #1001",
|
||||
Rows: []FortnoxRow{
|
||||
{Account: "1510", AccountName: "Kundfordringar", Debit: 25000, Credit: 0, Description: "Faktura #1001"},
|
||||
{Account: "3010", AccountName: "Försäljning", Debit: 0, Credit: 25000, Description: "Faktura #1001"},
|
||||
},
|
||||
Synced: true,
|
||||
SyncedAt: timePtr(time.Now().Add(-48 * time.Hour)),
|
||||
},
|
||||
{
|
||||
ID: "fnx-002",
|
||||
Date: "2026-08-02",
|
||||
Text: "Leverantörsfaktura AWS",
|
||||
Rows: []FortnoxRow{
|
||||
{Account: "6540", AccountName: "IT-kostnader", Debit: 8500, Credit: 0, Description: "AWS hosting"},
|
||||
{Account: "2440", AccountName: "Leverantörsskulder", Debit: 0, Credit: 8500, Description: "AWS hosting"},
|
||||
},
|
||||
Synced: true,
|
||||
SyncedAt: timePtr(time.Now().Add(-24 * time.Hour)),
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"vouchers": vouchers,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// LandvexHandler hanterar Landvex bolagskontroll
|
||||
type LandvexHandler struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// NewLandvexHandler skapar en ny handler
|
||||
func NewLandvexHandler(db *sql.DB) *LandvexHandler {
|
||||
return &LandvexHandler{DB: db}
|
||||
}
|
||||
|
||||
// Entity representerar en juridisk enhet
|
||||
type Entity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
OrgNumber string `json:"org_number"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status"`
|
||||
FoundedAt time.Time `json:"founded_at"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Ownership float64 `json:"ownership_percent"`
|
||||
CEO string `json:"ceo"`
|
||||
BoardMembers []Person `json:"board_members"`
|
||||
Employees int `json:"employees"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
Currency string `json:"currency"`
|
||||
TaxStatus string `json:"tax_status"`
|
||||
ComplianceStatus string `json:"compliance_status"`
|
||||
}
|
||||
|
||||
// Person representerar en person
|
||||
type Person struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Nationality string `json:"nationality"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
|
||||
// Document representerar ett dokument
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
SignedBy []string `json:"signed_by"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ComplianceItem representerar ett compliance-krav
|
||||
type ComplianceItem struct {
|
||||
ID string `json:"id"`
|
||||
EntityID string `json:"entity_id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
DueDate time.Time `json:"due_date"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Responsible string `json:"responsible"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
// GetEntities returnerar alla Landvex-enheter från databasen
|
||||
func (h *LandvexHandler) GetEntities(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT entity_id, name, jurisdiction, entity_type, status
|
||||
FROM boc_landvex_entities
|
||||
WHERE status = 'active'
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entities []Entity
|
||||
for rows.Next() {
|
||||
var e Entity
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Country, &e.Type, &e.Status); err != nil {
|
||||
continue
|
||||
}
|
||||
entities = append(entities, e)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"entities": entities,
|
||||
})
|
||||
}
|
||||
|
||||
// GetEntity returnerar en specifik enhet
|
||||
func (h *LandvexHandler) GetEntity(w http.ResponseWriter, r *http.Request) {
|
||||
entityID := chi.URLParam(r, "id")
|
||||
|
||||
var e Entity
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT entity_id, name, jurisdiction, entity_type, status
|
||||
FROM boc_landvex_entities
|
||||
WHERE entity_id = $1
|
||||
`, entityID).Scan(&e.ID, &e.Name, &e.Country, &e.Type, &e.Status)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "entity not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"entity": e,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDocuments returnerar alla dokument
|
||||
func (h *LandvexHandler) GetDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
documents := []Document{
|
||||
{
|
||||
ID: "doc-001",
|
||||
Title: "Styrelseprotokoll 2026-01-15",
|
||||
Type: "board_minutes",
|
||||
EntityID: "lvx-ab",
|
||||
Status: "signed",
|
||||
CreatedAt: time.Now().Add(-180 * 24 * time.Hour),
|
||||
SignedBy: []string{"Erik Svensson", "Johan Berglund"},
|
||||
URL: "/docs/board-2026-01-15.pdf",
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"documents": documents,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCompliance returnerar compliance-krav från databasen
|
||||
func (h *LandvexHandler) GetCompliance(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT id, entity_id, category, title, status, due_date, completed_at, notes
|
||||
FROM boc_landvex_compliance
|
||||
ORDER BY due_date ASC
|
||||
`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ComplianceItem
|
||||
for rows.Next() {
|
||||
var c ComplianceItem
|
||||
var notes sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.EntityID, &c.Type, &c.Title, &c.Status, &c.DueDate, &c.CompletedAt, ¬es); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, c)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"compliance_items": items,
|
||||
"summary": map[string]interface{}{
|
||||
"total": len(items),
|
||||
"pending": countByStatus(items, "pending"),
|
||||
"overdue": countByStatus(items, "overdue"),
|
||||
"completed": countByStatus(items, "completed"),
|
||||
"this_quarter": len(items),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetOwnership returnerar ägarstruktur från databasen
|
||||
func (h *LandvexHandler) GetOwnership(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT e.entity_id, e.name, e.jurisdiction, e.entity_type,
|
||||
o.owner_name, o.ownership_percent, o.parent_entity_id
|
||||
FROM boc_landvex_entities e
|
||||
LEFT JOIN boc_landvex_ownership o ON e.entity_id = o.entity_id
|
||||
WHERE e.status = 'active'
|
||||
ORDER BY e.name
|
||||
`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entities []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var entityID, name, jurisdiction, entityType, ownerName string
|
||||
var ownership float64
|
||||
var parentID sql.NullString
|
||||
if err := rows.Scan(&entityID, &name, &jurisdiction, &entityType, &ownerName, &ownership, &parentID); err != nil {
|
||||
continue
|
||||
}
|
||||
entities = append(entities, map[string]interface{}{
|
||||
"id": entityID,
|
||||
"name": name,
|
||||
"jurisdiction": jurisdiction,
|
||||
"type": entityType,
|
||||
"owner": ownerName,
|
||||
"ownership": ownership,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"ownership": map[string]interface{}{
|
||||
"structure": "linear",
|
||||
"ultimate_beneficial_owner": map[string]interface{}{
|
||||
"name": "Erik Svensson",
|
||||
"nationality": "SE",
|
||||
"ownership": 100,
|
||||
},
|
||||
"entities": entities,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func countByStatus(items []ComplianceItem, status string) int {
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if item.Status == status {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// LandvexRealHandler hanterar riktig integration mot Landvex API
|
||||
type LandvexRealHandler struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewLandvexRealHandler skapar en ny handler
|
||||
func NewLandvexRealHandler() *LandvexRealHandler {
|
||||
return &LandvexRealHandler{
|
||||
baseURL: getEnv("LANDVEX_API_URL", "http://172.17.0.1:8081"),
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// LandvexObject representerar ett Landvex-objekt
|
||||
type LandvexObject struct {
|
||||
LvxID string `json:"lvx_id"`
|
||||
Slug string `json:"slug"`
|
||||
Namn map[string]string `json:"namn"`
|
||||
Beskrivning map[string]string `json:"beskrivning"`
|
||||
Kategorier []string `json:"kategorier"`
|
||||
Standarder []string `json:"standarder"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// LandvexSearchResult representerar sökresultat
|
||||
type LandvexSearchResult struct {
|
||||
LvxID string `json:"lvx_id"`
|
||||
Namn map[string]string `json:"namn"`
|
||||
Relevans float64 `json:"relevans"`
|
||||
Kategorier []string `json:"kategorier"`
|
||||
}
|
||||
|
||||
// fetchFromLandvex hämtar data från Landvex API
|
||||
func (h *LandvexRealHandler) fetchFromLandvex(endpoint string) (map[string]interface{}, error) {
|
||||
resp, err := h.client.Get(h.baseURL + endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetHealth hämtar health från Landvex
|
||||
func (h *LandvexRealHandler) GetHealth(w http.ResponseWriter, r *http.Request) {
|
||||
health, err := h.fetchFromLandvex("/health")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"landvex": health,
|
||||
})
|
||||
}
|
||||
|
||||
// GetObjects hämtar alla objekt
|
||||
func (h *LandvexRealHandler) GetObjects(w http.ResponseWriter, r *http.Request) {
|
||||
// Sök efter alla objekt (tom sökning)
|
||||
results, err := h.fetchFromLandvex("/v0/search?q=")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// GetObject hämtar ett specifikt objekt
|
||||
func (h *LandvexRealHandler) GetObject(w http.ResponseWriter, r *http.Request) {
|
||||
lvxID := chi.URLParam(r, "id")
|
||||
|
||||
obj, err := h.fetchFromLandvex("/v0/objects/" + lvxID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"object": obj,
|
||||
})
|
||||
}
|
||||
|
||||
// Search söker i Landvex
|
||||
func (h *LandvexRealHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "missing query parameter 'q'",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.client.Get(h.baseURL + "/v0/search?q=" + url.QueryEscape(query))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Landvex returnerar inte JSON med ok/error, utan direkt resultat
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"query": query,
|
||||
"raw": string(body),
|
||||
"parse_error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"query": query,
|
||||
"results": result,
|
||||
})
|
||||
}
|
||||
|
||||
// Identify identifierar ett objekt från bild/text
|
||||
func (h *LandvexRealHandler) Identify(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vidarebefordra till Landvex identify
|
||||
landvexReq := map[string]interface{}{
|
||||
"image_url": req.ImageURL,
|
||||
"description": req.Description,
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(landvexReq)
|
||||
resp, err := h.client.Post(h.baseURL+"/v0/identify", "application/json", strings.NewReader(string(reqBody)))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"boc/email"
|
||||
)
|
||||
|
||||
var (
|
||||
mailConfig *MailConfig
|
||||
mailConfigMu sync.RWMutex
|
||||
defaultIMAP *email.IMAPClient
|
||||
)
|
||||
|
||||
// MailConfig stores IMAP configuration
|
||||
type MailConfig struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Server string `json:"server"`
|
||||
Port int `json:"port"`
|
||||
UseTLS bool `json:"useTLS"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Try to initialize from environment
|
||||
imapURL := os.Getenv("IMAP_URL")
|
||||
if imapURL != "" {
|
||||
var err error
|
||||
defaultIMAP, err = email.ParseIMAPURL(imapURL)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to parse IMAP_URL:", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getIMAPClient() *email.IMAPClient {
|
||||
mailConfigMu.RLock()
|
||||
defer mailConfigMu.RUnlock()
|
||||
|
||||
if mailConfig != nil && mailConfig.Email != "" {
|
||||
return email.NewIMAPClient(mailConfig.Server, mailConfig.Port, mailConfig.Email, mailConfig.Password)
|
||||
}
|
||||
|
||||
return defaultIMAP
|
||||
}
|
||||
|
||||
// GetMailInbox returns emails from inbox
|
||||
func GetMailInbox(w http.ResponseWriter, r *http.Request) {
|
||||
client := getIMAPClient()
|
||||
if client == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "IMAP not configured. Set IMAP_URL environment variable or configure via /api/v1/mail/config",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := client.ListMessages(limit)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"messages": messages,
|
||||
"total": len(messages),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMailMessage returns a single email
|
||||
func GetMailMessage(w http.ResponseWriter, r *http.Request) {
|
||||
client := getIMAPClient()
|
||||
if client == nil {
|
||||
http.Error(w, `{"error":"IMAP not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := client.GetMessage(uint32(uid))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkMailAsRead marks an email as read
|
||||
func MarkMailAsRead(w http.ResponseWriter, r *http.Request) {
|
||||
client := getIMAPClient()
|
||||
if client == nil {
|
||||
http.Error(w, `{"error":"IMAP not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.MarkAsRead(uint32(uid)); err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMailUnreadCount returns unread message count
|
||||
func GetMailUnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||
client := getIMAPClient()
|
||||
if client == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "IMAP not configured. Set IMAP_URL environment variable or configure via /api/v1/mail/config",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
count, err := client.GetUnreadCount()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveMailConfig saves mail configuration
|
||||
func SaveMailConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var config MailConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mailConfigMu.Lock()
|
||||
mailConfig = &config
|
||||
mailConfigMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
})
|
||||
}
|
||||
|
||||
// TestMailConnection tests IMAP connection
|
||||
func TestMailConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var config MailConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client := email.NewIMAPClient(config.Server, config.Port, config.Email, config.Password)
|
||||
|
||||
count, err := client.GetUnreadCount()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"messageCount": count,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"boc/email"
|
||||
)
|
||||
|
||||
// MailComposeHandler handles composing and sending emails
|
||||
type MailComposeHandler struct {
|
||||
client *email.Client
|
||||
}
|
||||
|
||||
// NewMailComposeHandler creates a new compose handler
|
||||
func NewMailComposeHandler() *MailComposeHandler {
|
||||
apiKey := os.Getenv("RESEND_API_KEY")
|
||||
fromEmail := os.Getenv("RESEND_FROM_EMAIL")
|
||||
if fromEmail == "" {
|
||||
fromEmail = "noreply@landvex.com"
|
||||
}
|
||||
|
||||
var client *email.Client
|
||||
if apiKey != "" && !strings.Contains(apiKey, "xxx") && !strings.Contains(apiKey, "placeholder") {
|
||||
client = email.NewClient(apiKey, fromEmail, "LandveX")
|
||||
}
|
||||
|
||||
return &MailComposeHandler{client: client}
|
||||
}
|
||||
|
||||
// IsConfigured returns true if email sending is configured
|
||||
func (h *MailComposeHandler) IsConfigured() bool {
|
||||
return h.client != nil
|
||||
}
|
||||
|
||||
// SendRequest represents an email to send
|
||||
type SendRequest struct {
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
InReplyTo string `json:"in_reply_to,omitempty"`
|
||||
Attachments []AttachmentUpload `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// AttachmentUpload represents an uploaded attachment
|
||||
type AttachmentUpload struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"` // base64 encoded
|
||||
MIMEType string `json:"mime_type"`
|
||||
}
|
||||
|
||||
// SendResponse represents the send response
|
||||
type SendResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// SendEmail handles sending an email
|
||||
func (h *MailComposeHandler) SendEmail(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.IsConfigured() {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Email sending not configured (set RESEND_API_KEY)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req SendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate
|
||||
if len(req.To) == 0 || req.Subject == "" || req.Body == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Missing required fields: to, subject, body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate HTML if not provided
|
||||
html := req.HTML
|
||||
if html == "" {
|
||||
html = fmt.Sprintf("<html><body><pre style=\"font-family: sans-serif; white-space: pre-wrap;\">%s</pre></body></html>",
|
||||
escapeHTML(req.Body))
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
var attachments []email.Attachment
|
||||
for _, att := range req.Attachments {
|
||||
data, err := decodeBase64(att.Content)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("Invalid attachment %s: %v", att.Filename, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
attachments = append(attachments, email.Attachment{
|
||||
Filename: att.Filename,
|
||||
Content: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Send
|
||||
var err error
|
||||
if len(attachments) > 0 {
|
||||
err = h.client.SendEmailWithAttachment(req.To, req.Subject, html, req.Body, attachments)
|
||||
} else {
|
||||
err = h.client.SendEmail(req.To, req.Subject, html, req.Body)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"status": "sent",
|
||||
"message": fmt.Sprintf("Email sent to %s", strings.Join(req.To, ", ")),
|
||||
})
|
||||
}
|
||||
|
||||
// GetStatus returns email sending status
|
||||
func (h *MailComposeHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"configured": h.IsConfigured(),
|
||||
"from": os.Getenv("RESEND_FROM_EMAIL"),
|
||||
})
|
||||
}
|
||||
|
||||
// AIAssistRequest represents a request for AI writing assistance
|
||||
type AIAssistRequest struct {
|
||||
Context string `json:"context"`
|
||||
Tone string `json:"tone,omitempty"` // professional, friendly, formal
|
||||
Language string `json:"language,omitempty"` // sv, en
|
||||
MaxLength int `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
// AIAssistResponse represents AI suggestions
|
||||
type AIAssistResponse struct {
|
||||
Suggestions []string `json:"suggestions"`
|
||||
Improved string `json:"improved,omitempty"`
|
||||
Grammar []string `json:"grammar_issues,omitempty"`
|
||||
}
|
||||
|
||||
// AIAssist provides AI writing assistance
|
||||
func (h *MailComposeHandler) AIAssist(w http.ResponseWriter, r *http.Request) {
|
||||
var req AIAssistRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Invalid request",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Simple rule-based suggestions (placeholder for real AI integration)
|
||||
suggestions := generateSuggestions(req.Context, req.Tone, req.Language)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"suggestions": suggestions,
|
||||
"improved": improveText(req.Context, req.Tone, req.Language),
|
||||
})
|
||||
}
|
||||
|
||||
// generateSuggestions generates simple writing suggestions
|
||||
func generateSuggestions(text, tone, language string) []string {
|
||||
var suggestions []string
|
||||
|
||||
if language == "sv" || language == "" {
|
||||
// Swedish suggestions
|
||||
if strings.Contains(text, "Hej") && !strings.Contains(text, ",") {
|
||||
suggestions = append(suggestions, "Lägg till kommatecken efter hälsningen: 'Hej,'")
|
||||
}
|
||||
if strings.Contains(text, "mvh") || strings.Contains(text, "Mvh") {
|
||||
suggestions = append(suggestions, "Använd 'Med vänliga hälsningar' istället för 'Mvh' i formella sammanhang")
|
||||
}
|
||||
if !strings.Contains(text, "?") && strings.Contains(text, "fråga") {
|
||||
suggestions = append(suggestions, "Ställ din fråga tydligt med ett frågetecken")
|
||||
}
|
||||
} else {
|
||||
// English suggestions
|
||||
if strings.Contains(text, "Hi") && !strings.Contains(text, ",") {
|
||||
suggestions = append(suggestions, "Add a comma after the greeting: 'Hi,'")
|
||||
}
|
||||
if strings.Contains(text, "pls") || strings.Contains(text, "plz") {
|
||||
suggestions = append(suggestions, "Use 'please' instead of 'pls/plz' in professional emails")
|
||||
}
|
||||
}
|
||||
|
||||
// Tone-specific suggestions
|
||||
if tone == "professional" {
|
||||
suggestions = append(suggestions, "Use formal language and avoid contractions")
|
||||
} else if tone == "friendly" {
|
||||
suggestions = append(suggestions, "A warm opening helps build rapport")
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// improveText improves the given text
|
||||
func improveText(text, tone, language string) string {
|
||||
// Simple improvements
|
||||
improved := text
|
||||
|
||||
if language == "sv" || language == "" {
|
||||
improved = strings.ReplaceAll(improved, "mvh", "Med vänliga hälsningar")
|
||||
improved = strings.ReplaceAll(improved, "Mvh", "Med vänliga hälsningar")
|
||||
improved = strings.ReplaceAll(improved, "Hej", "Hej,")
|
||||
} else {
|
||||
improved = strings.ReplaceAll(improved, "pls", "please")
|
||||
improved = strings.ReplaceAll(improved, "plz", "please")
|
||||
improved = strings.ReplaceAll(improved, "thx", "thank you")
|
||||
}
|
||||
|
||||
return improved
|
||||
}
|
||||
|
||||
// escapeHTML escapes HTML special characters
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
return s
|
||||
}
|
||||
|
||||
// decodeBase64 decodes base64 string
|
||||
func decodeBase64(s string) ([]byte, error) {
|
||||
// Simple base64 decode - in production use encoding/base64
|
||||
// This is a placeholder
|
||||
return []byte(s), nil
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MaildirMessage represents an email read directly from Maildir
|
||||
type MaildirMessage struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Date string `json:"date"`
|
||||
Body string `json:"body"`
|
||||
Preview string `json:"preview"`
|
||||
Read bool `json:"read"`
|
||||
Attachments int `json:"attachments"`
|
||||
}
|
||||
|
||||
// getMaildirPath returns the path to the Maildir for a user
|
||||
func getMaildirPath(email string) string {
|
||||
// Try docker container path first (when running inside container)
|
||||
basePath := "/mail"
|
||||
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
||||
// Fallback to host path
|
||||
basePath = "/opt/mailu/mail"
|
||||
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
||||
// Try to find mail via docker volume
|
||||
basePath = "/var/lib/docker/volumes"
|
||||
}
|
||||
}
|
||||
return filepath.Join(basePath, email)
|
||||
}
|
||||
|
||||
// parseMaildirFile parses a single mail file
|
||||
func parseMaildirFile(path string) (*MaildirMessage, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
msg := &MaildirMessage{
|
||||
Read: strings.Contains(filepath.Base(path), ",S=") || !strings.Contains(path, "/new/"),
|
||||
Attachments: 0,
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
lines := strings.Split(content, "\n")
|
||||
inBody := false
|
||||
var bodyLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if !inBody {
|
||||
if line == "" {
|
||||
inBody = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Subject: ") {
|
||||
msg.Subject = strings.TrimPrefix(line, "Subject: ")
|
||||
} else if strings.HasPrefix(line, "From: ") {
|
||||
msg.From = strings.TrimPrefix(line, "From: ")
|
||||
} else if strings.HasPrefix(line, "To: ") {
|
||||
msg.To = append(msg.To, strings.TrimPrefix(line, "To: "))
|
||||
} else if strings.HasPrefix(line, "Date: ") {
|
||||
msg.Date = strings.TrimPrefix(line, "Date: ")
|
||||
}
|
||||
} else {
|
||||
bodyLines = append(bodyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.Join(bodyLines, "\n")
|
||||
msg.Body = body
|
||||
msg.Preview = truncateString(stripHTML(body), 200)
|
||||
|
||||
// Generate UID from filename
|
||||
filename := filepath.Base(path)
|
||||
msg.UID = hashString(filename)
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// listMaildirMessages lists all messages in a Maildir
|
||||
func listMaildirMessages(maildir string, limit int) ([]MaildirMessage, error) {
|
||||
var messages []MaildirMessage
|
||||
|
||||
// Read cur/ and new/ directories
|
||||
for _, subdir := range []string{"cur", "new"} {
|
||||
path := filepath.Join(maildir, subdir)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
continue // Directory may not exist
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := parseMaildirFile(filepath.Join(path, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
messages = append(messages, *msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Date > messages[j].Date
|
||||
})
|
||||
|
||||
if limit > 0 && len(messages) > limit {
|
||||
messages = messages[:limit]
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetMailInboxDirect returns emails directly from Maildir
|
||||
func GetMailInboxDirect(w http.ResponseWriter, r *http.Request) {
|
||||
// Get user from context or use default
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
|
||||
maildir := getMaildirPath(email)
|
||||
if _, err := os.Stat(maildir); os.IsNotExist(err) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Maildir not found for user: " + email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := listMaildirMessages(maildir, limit)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"messages": messages,
|
||||
"total": len(messages),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMailMessageDirect returns a single email from Maildir
|
||||
func GetMailMessageDirect(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
maildir := getMaildirPath(email)
|
||||
|
||||
// Search for message with matching UID
|
||||
for _, subdir := range []string{"cur", "new"} {
|
||||
path := filepath.Join(maildir, subdir)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := filepath.Join(path, entry.Name())
|
||||
if hashString(entry.Name()) == uint32(uid) {
|
||||
msg, err := parseMaildirFile(filename)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to read message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// MarkMailAsReadDirect marks a message as read
|
||||
func MarkMailAsReadDirect(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
maildir := getMaildirPath(email)
|
||||
|
||||
// Move from new/ to cur/
|
||||
for _, entry := range []string{"new", "cur"} {
|
||||
path := filepath.Join(maildir, entry)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if hashString(e.Name()) == uint32(uid) {
|
||||
oldPath := filepath.Join(path, e.Name())
|
||||
newPath := filepath.Join(maildir, "cur", e.Name())
|
||||
|
||||
if entry == "new" {
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
http.Error(w, `{"error":"failed to mark as read"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
func stripHTML(s string) string {
|
||||
// Simple HTML stripping
|
||||
result := strings.ReplaceAll(s, "<br>", "\n")
|
||||
result = strings.ReplaceAll(result, "<br/>", "\n")
|
||||
result = strings.ReplaceAll(result, "<p>", "\n")
|
||||
result = strings.ReplaceAll(result, "</p>", "")
|
||||
|
||||
// Remove tags
|
||||
for {
|
||||
start := strings.Index(result, "<")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(result[start:], ">")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
result = result[:start] + result[start+end+1:]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func hashString(s string) uint32 {
|
||||
var h uint32 = 5381
|
||||
for i := 0; i < len(s); i++ {
|
||||
h = ((h << 5) + h) + uint32(s[i])
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// GetMailUnreadCountDirect returns unread count
|
||||
func GetMailUnreadCountDirect(w http.ResponseWriter, r *http.Request) {
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
maildir := getMaildirPath(email)
|
||||
|
||||
count := 0
|
||||
newPath := filepath.Join(maildir, "new")
|
||||
entries, err := os.ReadDir(newPath)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// DockerMailMessage represents an email read via docker exec
|
||||
type DockerMailMessage struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Date string `json:"date"`
|
||||
Body string `json:"body"`
|
||||
Preview string `json:"preview"`
|
||||
Read bool `json:"read"`
|
||||
Attachments int `json:"attachments"`
|
||||
}
|
||||
|
||||
// getMaildirViaDocker returns the maildir path inside the container
|
||||
func getMaildirViaDocker(email string) string {
|
||||
return fmt.Sprintf("/mail/%s", email)
|
||||
}
|
||||
|
||||
// listMaildirViaDocker lists messages using docker exec
|
||||
func listMaildirViaDocker(email string, limit int) ([]DockerMailMessage, error) {
|
||||
maildir := getMaildirViaDocker(email)
|
||||
|
||||
// List all files in cur/ and new/
|
||||
cmd := exec.Command("sh", "-c", fmt.Sprintf("docker exec mailu-imap-1 find %s/cur %s/new -type f 2>/dev/null || true", maildir, maildir))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list maildir: %w", err)
|
||||
}
|
||||
|
||||
files := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
var messages []DockerMailMessage
|
||||
|
||||
for _, file := range files {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := readMailFileViaDocker(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
messages = append(messages, *msg)
|
||||
}
|
||||
|
||||
// Sort by date (newest first) - simplified
|
||||
// In real implementation, parse dates properly
|
||||
|
||||
if limit > 0 && len(messages) > limit {
|
||||
messages = messages[:limit]
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// readMailFileViaDocker reads a single mail file via docker exec
|
||||
func readMailFileViaDocker(path string) (*DockerMailMessage, error) {
|
||||
cmd := exec.Command("docker", "exec", "mailu-imap-1", "cat", path)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use proper MIME parser
|
||||
parsed, err := ParseEmail(output)
|
||||
if err != nil {
|
||||
// Fallback to simple parsing
|
||||
parsed = parseSimple(output)
|
||||
}
|
||||
|
||||
msg := &DockerMailMessage{
|
||||
Read: strings.Contains(path, "/cur/"),
|
||||
Attachments: parsed.Attachments,
|
||||
Subject: parsed.Subject,
|
||||
From: parsed.From,
|
||||
To: parsed.To,
|
||||
Date: parsed.Date,
|
||||
Body: parsed.Body,
|
||||
Preview: parsed.Preview,
|
||||
}
|
||||
|
||||
// Generate UID from filename
|
||||
filename := path[strings.LastIndex(path, "/")+1:]
|
||||
msg.UID = hashString(filename)
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// CEO mailboxes (Erik Svensson)
|
||||
var ceoMailboxes = []string{
|
||||
"erik@landvex.com",
|
||||
"erik@aamos.systems",
|
||||
"erik@hypbit.com",
|
||||
"info@landvex.com",
|
||||
"invoice@landvex.com",
|
||||
"hello@quixzoom.com",
|
||||
"finance@quixzoom.com",
|
||||
"cfo@aamos.systems",
|
||||
}
|
||||
|
||||
// CTO mailboxes (Johan Berglund)
|
||||
var ctoMailboxes = []string{
|
||||
"johan@landvex.com",
|
||||
"johan@hypbit.com",
|
||||
"cto@aamos.systems",
|
||||
"info@aamos.systems",
|
||||
"dev@hypbit.com",
|
||||
}
|
||||
|
||||
// Shared company mailboxes
|
||||
var sharedMailboxes = []string{
|
||||
"recovery@landvex.com",
|
||||
"social@landvex.com",
|
||||
"no-reply@quixzoom.com",
|
||||
"recovery@quixzoom.com",
|
||||
"social@quixzoom.com",
|
||||
"recovery@aamos.ai",
|
||||
"recovery@apifly.com",
|
||||
"recovery@corpfitt.com",
|
||||
"recovery@vyra.gg",
|
||||
"social@aamos.ai",
|
||||
"social@apifly.com",
|
||||
"social@corpfitt.com",
|
||||
"social@vyra.gg",
|
||||
}
|
||||
|
||||
// allMailboxes combines all active mailboxes
|
||||
var allMailboxes = append(append(ceoMailboxes, ctoMailboxes...), sharedMailboxes...)
|
||||
|
||||
// getAllMessages reads messages from all mailboxes using a single docker exec
|
||||
func getAllMessages(limit int) ([]DockerMailMessage, error) {
|
||||
// Build find command for all mailboxes at once
|
||||
var paths []string
|
||||
for _, email := range allMailboxes {
|
||||
maildir := getMaildirViaDocker(email)
|
||||
paths = append(paths, maildir+"/cur", maildir+"/new")
|
||||
}
|
||||
|
||||
args := append([]string{"exec", "mailu-imap-1", "find"}, paths...)
|
||||
args = append(args, "-type", "f")
|
||||
cmd := exec.Command("docker", args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list all maildirs: %w", err)
|
||||
}
|
||||
|
||||
files := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
var messages []DockerMailMessage
|
||||
|
||||
for _, file := range files {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := readMailFileViaDocker(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
messages = append(messages, *msg)
|
||||
if limit > 0 && len(messages) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetMailInboxDocker returns emails from all mailboxes
|
||||
func GetMailInboxDocker(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := getAllMessages(limit)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"messages": messages,
|
||||
"total": len(messages),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMailMessageDocker returns a single email via docker exec
|
||||
func GetMailMessageDocker(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Search all mailboxes for message with matching UID
|
||||
for _, email := range allMailboxes {
|
||||
maildir := getMaildirViaDocker(email)
|
||||
cmd := exec.Command("docker", "exec", "mailu-imap-1", "find", maildir+"/cur", maildir+"/new", "-type", "f")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
files := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, file := range files {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := file[strings.LastIndex(file, "/")+1:]
|
||||
if hashString(filename) == uint32(uid) {
|
||||
msg, err := readMailFileViaDocker(file)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to read message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// MarkMailAsReadDocker marks a message as read via docker exec
|
||||
func MarkMailAsReadDocker(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Search all mailboxes
|
||||
for _, email := range allMailboxes {
|
||||
maildir := getMaildirViaDocker(email)
|
||||
cmd := exec.Command("docker", "exec", "mailu-imap-1", "find", maildir+"/new", maildir+"/cur", "-type", "f")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
files := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, file := range files {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := file[strings.LastIndex(file, "/")+1:]
|
||||
if hashString(filename) == uint32(uid) {
|
||||
if strings.Contains(file, "/new/") {
|
||||
newPath := file
|
||||
curPath := maildir + "/cur/" + filename
|
||||
|
||||
moveCmd := exec.Command("docker", "exec", "mailu-imap-1", "mv", newPath, curPath)
|
||||
if err := moveCmd.Run(); err != nil {
|
||||
http.Error(w, `{"error":"failed to mark as read"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// GetMailUnreadCountDocker returns unread count from all mailboxes
|
||||
func GetMailUnreadCountDocker(w http.ResponseWriter, r *http.Request) {
|
||||
totalCount := 0
|
||||
for _, email := range allMailboxes {
|
||||
maildir := getMaildirViaDocker(email)
|
||||
cmd := exec.Command("docker", "exec", "mailu-imap-1", "find", maildir+"/new", "-type", "f", "2>/dev/null")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
files := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, f := range files {
|
||||
if f != "" {
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": totalCount,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMailboxes returns all configured mailboxes
|
||||
func GetMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"mailboxes": allMailboxes,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsedEmail represents a fully parsed email with decoded body
|
||||
type ParsedEmail struct {
|
||||
Subject string
|
||||
From string
|
||||
To []string
|
||||
Date string
|
||||
Body string
|
||||
Preview string
|
||||
HTML string
|
||||
Attachments int
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// ParseEmail parses raw email content and decodes body
|
||||
func ParseEmail(raw []byte) (*ParsedEmail, error) {
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
// Fallback: simple header parsing
|
||||
return parseSimple(raw), nil
|
||||
}
|
||||
|
||||
result := &ParsedEmail{
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
result.Subject = decodeHeader(msg.Header.Get("Subject"))
|
||||
result.From = decodeHeader(msg.Header.Get("From"))
|
||||
result.Date = msg.Header.Get("Date")
|
||||
|
||||
// Parse To
|
||||
if to := msg.Header.Get("To"); to != "" {
|
||||
result.To = parseAddressList(decodeHeader(to))
|
||||
}
|
||||
|
||||
// Parse Cc
|
||||
if cc := msg.Header.Get("Cc"); cc != "" {
|
||||
result.To = append(result.To, parseAddressList(decodeHeader(cc))...)
|
||||
}
|
||||
|
||||
// Get content type
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = "text/plain"
|
||||
}
|
||||
|
||||
// Read body
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
// Handle multipart messages
|
||||
result.parseMultipart(body, params["boundary"])
|
||||
} else {
|
||||
// Single part
|
||||
result.Body = decodeBody(body, msg.Header.Get("Content-Transfer-Encoding"), params["charset"])
|
||||
result.HTML = ""
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
result.Preview = generatePreview(result.Body)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseSimple is a fallback for malformed emails
|
||||
func parseSimple(raw []byte) *ParsedEmail {
|
||||
result := &ParsedEmail{
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
inBody := false
|
||||
var bodyLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if !inBody {
|
||||
if line == "" {
|
||||
inBody = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Subject: ") {
|
||||
result.Subject = decodeHeader(strings.TrimPrefix(line, "Subject: "))
|
||||
} else if strings.HasPrefix(line, "From: ") {
|
||||
result.From = decodeHeader(strings.TrimPrefix(line, "From: "))
|
||||
} else if strings.HasPrefix(line, "To: ") {
|
||||
result.To = append(result.To, decodeHeader(strings.TrimPrefix(line, "To: ")))
|
||||
} else if strings.HasPrefix(line, "Date: ") {
|
||||
result.Date = strings.TrimPrefix(line, "Date: ")
|
||||
}
|
||||
} else {
|
||||
bodyLines = append(bodyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.Join(bodyLines, "\n")
|
||||
result.Body = decodeQuotedPrintable(body)
|
||||
result.Preview = generatePreview(result.Body)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseMultipart handles multipart MIME messages
|
||||
func (e *ParsedEmail) parseMultipart(body []byte, boundary string) {
|
||||
if boundary == "" {
|
||||
e.Body = string(body)
|
||||
return
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(strings.NewReader(string(body)), boundary)
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
partType := part.Header.Get("Content-Type")
|
||||
if partType == "" {
|
||||
partType = "text/plain"
|
||||
}
|
||||
|
||||
mediaType, params, _ := mime.ParseMediaType(partType)
|
||||
partBody, _ := io.ReadAll(part)
|
||||
|
||||
transferEncoding := part.Header.Get("Content-Transfer-Encoding")
|
||||
decoded := decodeBody(partBody, transferEncoding, params["charset"])
|
||||
|
||||
if strings.HasPrefix(mediaType, "text/plain") && e.Body == "" {
|
||||
e.Body = decoded
|
||||
} else if strings.HasPrefix(mediaType, "text/html") && e.HTML == "" {
|
||||
e.HTML = decoded
|
||||
} else if isAttachment(part) {
|
||||
e.Attachments++
|
||||
}
|
||||
}
|
||||
|
||||
// If no plain text found, try to extract from HTML
|
||||
if e.Body == "" && e.HTML != "" {
|
||||
e.Body = stripHTML(e.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeHeader decodes MIME encoded-word headers
|
||||
func decodeHeader(header string) string {
|
||||
// Use mail.AddressParser for proper decoding
|
||||
addr, err := mail.ParseAddress(header)
|
||||
if err == nil && addr.Name != "" {
|
||||
return addr.Name + " <" + addr.Address + ">"
|
||||
}
|
||||
|
||||
// Fallback: try to decode manually
|
||||
decoded := header
|
||||
// Remove =?charset?encoding?text?= patterns
|
||||
for {
|
||||
start := strings.Index(decoded, "=?")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(decoded[start:], "?=")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
end += start + 2
|
||||
|
||||
encoded := decoded[start:end]
|
||||
parts := strings.Split(encoded, "?")
|
||||
if len(parts) >= 4 {
|
||||
encoding := strings.ToUpper(parts[2])
|
||||
encodedText := parts[3]
|
||||
|
||||
var decodedText string
|
||||
if encoding == "B" {
|
||||
// Base64
|
||||
if b, err := base64.StdEncoding.DecodeString(encodedText); err == nil {
|
||||
decodedText = string(b)
|
||||
}
|
||||
} else if encoding == "Q" {
|
||||
// Quoted-printable
|
||||
decodedText = decodeQuotedPrintable(encodedText)
|
||||
}
|
||||
|
||||
if decodedText != "" {
|
||||
decoded = decoded[:start] + decodedText + decoded[end:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// decodeBody decodes body based on transfer encoding
|
||||
func decodeBody(body []byte, encoding string, charset string) string {
|
||||
var decoded []byte
|
||||
|
||||
switch strings.ToLower(encoding) {
|
||||
case "base64":
|
||||
decoded, _ = base64.StdEncoding.DecodeString(string(body))
|
||||
case "quoted-printable":
|
||||
decoded = []byte(decodeQuotedPrintable(string(body)))
|
||||
default:
|
||||
decoded = body
|
||||
}
|
||||
|
||||
// Handle charset (simplified - assumes UTF-8 or Latin-1)
|
||||
result := string(decoded)
|
||||
|
||||
// Try to convert common charsets
|
||||
if charset != "" && !strings.EqualFold(charset, "utf-8") {
|
||||
// For now, just return as-is. In production, use golang.org/x/text/encoding
|
||||
_ = charset
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// decodeQuotedPrintable decodes quoted-printable encoded text
|
||||
func decodeQuotedPrintable(input string) string {
|
||||
var result strings.Builder
|
||||
lines := strings.Split(input, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
// Remove soft line breaks (= at end of line)
|
||||
line = strings.TrimSuffix(line, "=")
|
||||
|
||||
// Decode hex sequences
|
||||
for i := 0; i < len(line); i++ {
|
||||
if i+2 < len(line) && line[i] == '=' {
|
||||
hex := line[i+1 : i+3]
|
||||
if b, err := parseHex(hex); err == nil {
|
||||
result.WriteByte(b)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.WriteByte(line[i])
|
||||
}
|
||||
result.WriteByte('\n')
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// parseHex parses a 2-character hex string
|
||||
func parseHex(s string) (byte, error) {
|
||||
if len(s) != 2 {
|
||||
return 0, fmt.Errorf("invalid hex length")
|
||||
}
|
||||
|
||||
var result byte
|
||||
for i := 0; i < 2; i++ {
|
||||
c := s[i]
|
||||
var val byte
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
val = c - '0'
|
||||
case c >= 'A' && c <= 'F':
|
||||
val = c - 'A' + 10
|
||||
case c >= 'a' && c <= 'f':
|
||||
val = c - 'a' + 10
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid hex character")
|
||||
}
|
||||
result = result<<4 | val
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isAttachment checks if a MIME part is an attachment
|
||||
func isAttachment(part *multipart.Part) bool {
|
||||
disposition := part.Header.Get("Content-Disposition")
|
||||
return strings.Contains(disposition, "attachment") ||
|
||||
part.FileName() != ""
|
||||
}
|
||||
|
||||
// parseAddressList parses a comma-separated list of email addresses
|
||||
func parseAddressList(addresses string) []string {
|
||||
var result []string
|
||||
for _, addr := range strings.Split(addresses, ",") {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr != "" {
|
||||
result = append(result, addr)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// generatePreview generates a preview from body text
|
||||
func generatePreview(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
lines := strings.Split(body, "\n")
|
||||
var preview []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
preview = append(preview, line)
|
||||
if len(preview) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(preview, " ")
|
||||
if len(result) > 120 {
|
||||
result = result[:120] + "..."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// QuixzoomHandler hanterar quiXzoom-integration
|
||||
type QuixzoomHandler struct {
|
||||
baseURL string
|
||||
token string
|
||||
}
|
||||
|
||||
// NewQuixzoomHandler skapar en ny handler
|
||||
func NewQuixzoomHandler() *QuixzoomHandler {
|
||||
return &QuixzoomHandler{
|
||||
baseURL: getEnv("QUIXZOOM_API_URL", ""),
|
||||
token: getEnv("QUIXZOOM_API_TOKEN", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *QuixzoomHandler) isConfigured() bool {
|
||||
return h.baseURL != "" && h.token != ""
|
||||
}
|
||||
|
||||
func (h *QuixzoomHandler) apiGet(path string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", h.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+h.token)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
// Zoomer representerar en quiXzoom-användare (fältarbetare)
|
||||
type Zoomer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Status string `json:"status"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
LastActive time.Time `json:"last_active"`
|
||||
TotalTasks int `json:"total_tasks"`
|
||||
CompletedTasks int `json:"completed_tasks"`
|
||||
Rating float64 `json:"rating"`
|
||||
Earnings float64 `json:"earnings"`
|
||||
PayoutMethod string `json:"payout_method"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// FieldData representerar insamlad fältdata
|
||||
type FieldData struct {
|
||||
ID string `json:"id"`
|
||||
ZoomerID string `json:"zoomer_id"`
|
||||
ZoomerName string `json:"zoomer_name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Location Location `json:"location"`
|
||||
Images []Image `json:"images"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ProcessedAt *time.Time `json:"processed_at,omitempty"`
|
||||
AIResult *AIResult `json:"ai_result,omitempty"`
|
||||
}
|
||||
|
||||
// Location representerar en geografisk plats
|
||||
type Location struct {
|
||||
Latitude float64 `json:"lat"`
|
||||
Longitude float64 `json:"lng"`
|
||||
Address string `json:"address"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// Image representerar en bild
|
||||
type Image struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AIResult representerar AI-analysresultat
|
||||
type AIResult struct {
|
||||
Engine string `json:"engine"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Detections []Detection `json:"detections"`
|
||||
ProcessedAt time.Time `json:"processed_at"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// Detection representerar en AI-detektering
|
||||
type Detection struct {
|
||||
Label string `json:"label"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BoundingBox struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
} `json:"bounding_box"`
|
||||
}
|
||||
|
||||
// Payout representerar en utbetalning
|
||||
type Payout struct {
|
||||
ID string `json:"id"`
|
||||
ZoomerID string `json:"zoomer_id"`
|
||||
ZoomerName string `json:"zoomer_name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Method string `json:"method"`
|
||||
Period string `json:"period"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ProcessedAt *time.Time `json:"processed_at,omitempty"`
|
||||
Tax float64 `json:"tax"`
|
||||
Fee float64 `json:"fee"`
|
||||
NetAmount float64 `json:"net_amount"`
|
||||
}
|
||||
|
||||
// GetZoomers returnerar alla zoomers
|
||||
func (h *QuixzoomHandler) GetZoomers(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isConfigured() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "quiXzoom API not configured. Set QUIXZOOM_API_URL and QUIXZOOM_API_TOKEN environment variables.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.apiGet("/api/v1/zoomers")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API returned %d", resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Failed to decode quiXzoom response",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// GetZoomer returnerar en specifik zoomer
|
||||
func (h *QuixzoomHandler) GetZoomer(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isConfigured() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "quiXzoom API not configured. Set QUIXZOOM_API_URL and QUIXZOOM_API_TOKEN environment variables.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
zoomerID := chi.URLParam(r, "id")
|
||||
resp, err := h.apiGet("/api/v1/zoomers/" + zoomerID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API returned %d", resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Failed to decode quiXzoom response",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// GetFieldData returnerar all fältdata
|
||||
func (h *QuixzoomHandler) GetFieldData(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isConfigured() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "quiXzoom API not configured. Set QUIXZOOM_API_URL and QUIXZOOM_API_TOKEN environment variables.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.apiGet("/api/v1/field-data")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API returned %d", resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Failed to decode quiXzoom response",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// GetPayouts returnerar alla utbetalningar
|
||||
func (h *QuixzoomHandler) GetPayouts(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isConfigured() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "quiXzoom API not configured. Set QUIXZOOM_API_URL and QUIXZOOM_API_TOKEN environment variables.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.apiGet("/api/v1/payouts")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API returned %d", resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Failed to decode quiXzoom response",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// GetPayoutStats returnerar utbetalningsstatistik
|
||||
func (h *QuixzoomHandler) GetPayoutStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isConfigured() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "quiXzoom API not configured. Set QUIXZOOM_API_URL and QUIXZOOM_API_TOKEN environment variables.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.apiGet("/api/v1/payouts/stats")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API returned %d", resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Failed to decode quiXzoom response",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// GetInsights returnerar quiXzoom-insikter (Urban Intelligence Index)
|
||||
func (h *QuixzoomHandler) GetInsights(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isConfigured() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "quiXzoom API not configured. Set QUIXZOOM_API_URL and QUIXZOOM_API_TOKEN environment variables.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.apiGet("/api/v1/insights")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("quiXzoom API returned %d", resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Failed to decode quiXzoom response",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SIE4Handler hanterar SIE4-import/export
|
||||
type SIE4Handler struct{}
|
||||
|
||||
func NewSIE4Handler() *SIE4Handler {
|
||||
return &SIE4Handler{}
|
||||
}
|
||||
|
||||
// SIE4Entry representerar en SIE4-post
|
||||
type SIE4Entry struct {
|
||||
Date string `json:"date"`
|
||||
VoucherNo string `json:"voucher_no"`
|
||||
Account string `json:"account"`
|
||||
Description string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
Dimension string `json:"dimension,omitempty"`
|
||||
}
|
||||
|
||||
// ParseSIE4 parsar SIE4-fil
|
||||
func (h *SIE4Handler) ParseSIE4(w http.ResponseWriter, r *http.Request) {
|
||||
var reader io.Reader
|
||||
|
||||
// Kolla om det är multipart (filuppladdning) eller JSON med content
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
// Läs uppladdad fil
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "missing file",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
reader = file
|
||||
} else {
|
||||
// Läs JSON med content
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "invalid request: expected multipart file or JSON with content field",
|
||||
})
|
||||
return
|
||||
}
|
||||
reader = strings.NewReader(req.Content)
|
||||
}
|
||||
|
||||
entries, parseErr := h.parseSIE4File(reader)
|
||||
if parseErr != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": parseErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"entries": entries,
|
||||
"count": len(entries),
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateSIE4 genererar SIE4-fil
|
||||
func (h *SIE4Handler) GenerateSIE4(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Company string `json:"company"`
|
||||
OrgNumber string `json:"org_number"`
|
||||
FiscalYear string `json:"fiscal_year"`
|
||||
Entries []SIE4Entry `json:"entries"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sie4Content := h.generateSIE4Content(req.Company, req.OrgNumber, req.FiscalYear, req.Entries)
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=ISO-8859-1")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s_%s.SI", req.OrgNumber, req.FiscalYear))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(sie4Content))
|
||||
}
|
||||
|
||||
// parseSIE4File parsar en SIE4-fil
|
||||
func (h *SIE4Handler) parseSIE4File(reader io.Reader) ([]SIE4Entry, error) {
|
||||
var entries []SIE4Entry
|
||||
scanner := bufio.NewScanner(reader)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Hoppa över tomma rader och kommentarer
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parsa #VER (verifikat)
|
||||
if strings.HasPrefix(line, "#VER") {
|
||||
// Format: #VER "serie" "voucherno" "date" "description" "date_created"
|
||||
parts := h.parseSIELine(line)
|
||||
if len(parts) >= 4 {
|
||||
voucherNo := h.unquote(parts[2])
|
||||
date := h.unquote(parts[3])
|
||||
description := ""
|
||||
if len(parts) >= 5 {
|
||||
description = h.unquote(parts[4])
|
||||
}
|
||||
|
||||
// Läs tillhörande rader
|
||||
for scanner.Scan() {
|
||||
rowLine := scanner.Text()
|
||||
rowLine = strings.TrimSpace(rowLine)
|
||||
|
||||
if rowLine == "}" {
|
||||
break
|
||||
}
|
||||
|
||||
if strings.HasPrefix(rowLine, "#TRANS") {
|
||||
// Format: #TRANS account { amount "date" "description" }
|
||||
rowParts := h.parseSIELine(rowLine)
|
||||
if len(rowParts) >= 4 {
|
||||
account := h.unquote(rowParts[1])
|
||||
amountStr := rowParts[3]
|
||||
amount, _ := strconv.ParseFloat(amountStr, 64)
|
||||
|
||||
rowDesc := description
|
||||
if len(rowParts) >= 6 {
|
||||
rowDesc = h.unquote(rowParts[5])
|
||||
}
|
||||
|
||||
entries = append(entries, SIE4Entry{
|
||||
Date: date,
|
||||
VoucherNo: voucherNo,
|
||||
Account: account,
|
||||
Description: rowDesc,
|
||||
Amount: amount,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// generateSIE4Content genererar SIE4-innehåll
|
||||
func (h *SIE4Handler) generateSIE4Content(company, orgNumber, fiscalYear string, entries []SIE4Entry) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// SIE4 header
|
||||
sb.WriteString("#FLAGGA 0\n")
|
||||
sb.WriteString(fmt.Sprintf("#FORMAT PC8\n"))
|
||||
sb.WriteString(fmt.Sprintf("#SIETYP 4\n"))
|
||||
sb.WriteString("#PROGRAM \"AMOS BOC\" 1.0\n")
|
||||
sb.WriteString(fmt.Sprintf("#GEN %s\n", time.Now().Format("20060102")))
|
||||
sb.WriteString(fmt.Sprintf("#FNAMN \"%s\"\n", company))
|
||||
sb.WriteString(fmt.Sprintf("#ORGNR \"%s\"\n", orgNumber))
|
||||
sb.WriteString(fmt.Sprintf("#RAR 0 %s0101 %s1231\n", fiscalYear, fiscalYear))
|
||||
sb.WriteString("#KPTYP EUBAS97\n")
|
||||
|
||||
// Kontoplan (BAS-konton)
|
||||
accounts := h.getBASAccounts()
|
||||
for code, name := range accounts {
|
||||
sb.WriteString(fmt.Sprintf("#KONTO %s \"%s\"\n", code, name))
|
||||
}
|
||||
|
||||
// Verifikat
|
||||
vouchers := h.groupByVoucher(entries)
|
||||
for voucherNo, voucherEntries := range vouchers {
|
||||
if len(voucherEntries) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
first := voucherEntries[0]
|
||||
sb.WriteString(fmt.Sprintf("#VER \"\" \"%s\" %s \"%s\" %s\n",
|
||||
voucherNo,
|
||||
h.formatSIEDate(first.Date),
|
||||
first.Description,
|
||||
time.Now().Format("20060102")))
|
||||
sb.WriteString("{\n")
|
||||
|
||||
for _, entry := range voucherEntries {
|
||||
sb.WriteString(fmt.Sprintf("#TRANS %s {} %s \"%s\" \"%s\"\n",
|
||||
entry.Account,
|
||||
h.formatSIEAmount(entry.Amount),
|
||||
h.formatSIEDate(entry.Date),
|
||||
entry.Description))
|
||||
}
|
||||
|
||||
sb.WriteString("}\n")
|
||||
}
|
||||
|
||||
// Slut
|
||||
sb.WriteString("#SLUT\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Helper functions för SIE4
|
||||
|
||||
func (h *SIE4Handler) parseSIELine(line string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
inQuotes := false
|
||||
|
||||
for _, ch := range line {
|
||||
switch ch {
|
||||
case '"':
|
||||
if inQuotes {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
inQuotes = !inQuotes
|
||||
case ' ', '\t':
|
||||
if !inQuotes && current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
} else if inQuotes {
|
||||
current.WriteRune(ch)
|
||||
}
|
||||
default:
|
||||
current.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
func (h *SIE4Handler) unquote(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *SIE4Handler) formatSIEDate(date string) string {
|
||||
// Konvertera YYYY-MM-DD till YYYYMMDD
|
||||
return strings.ReplaceAll(date, "-", "")
|
||||
}
|
||||
|
||||
func (h *SIE4Handler) formatSIEAmount(amount float64) string {
|
||||
// SIE4 använder punkt som decimaltecken
|
||||
return fmt.Sprintf("%.2f", amount)
|
||||
}
|
||||
|
||||
func (h *SIE4Handler) groupByVoucher(entries []SIE4Entry) map[string][]SIE4Entry {
|
||||
groups := make(map[string][]SIE4Entry)
|
||||
for _, entry := range entries {
|
||||
groups[entry.VoucherNo] = append(groups[entry.VoucherNo], entry)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func (h *SIE4Handler) getBASAccounts() map[string]string {
|
||||
return map[string]string{
|
||||
"1510": "Kundfordringar",
|
||||
"1930": "Företagskonto",
|
||||
"2010": "Eget kapital",
|
||||
"2440": "Leverantörsskulder",
|
||||
"2610": "Utgående moms",
|
||||
"3010": "Försäljning tjänster",
|
||||
"6540": "IT-kostnader",
|
||||
"7210": "Löner",
|
||||
"7690": "Övriga personalkostnader",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SigningHandler hanterar digital signering (BankID, Scrive)
|
||||
type SigningHandler struct {
|
||||
bankIDURL string
|
||||
bankIDAPIKey string
|
||||
scriveAPIKey string
|
||||
docusignAPIKey string
|
||||
}
|
||||
|
||||
func NewSigningHandler() *SigningHandler {
|
||||
return &SigningHandler{
|
||||
bankIDURL: os.Getenv("BANKID_URL"),
|
||||
bankIDAPIKey: os.Getenv("BANKID_API_KEY"),
|
||||
scriveAPIKey: os.Getenv("SCRIVE_API_KEY"),
|
||||
docusignAPIKey: os.Getenv("DOCUSIGN_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// SigningRequest representerar en signeringsbegäran
|
||||
type SigningRequest struct {
|
||||
ID string `json:"id"`
|
||||
DocumentID string `json:"document_id"`
|
||||
DocumentTitle string `json:"document_title"`
|
||||
Signers []Signer `json:"signers"`
|
||||
Status string `json:"status"`
|
||||
Method string `json:"method"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
SignedAt *time.Time `json:"signed_at,omitempty"`
|
||||
}
|
||||
|
||||
// Signer representerar en undertecknare
|
||||
type Signer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
PersonalNumber string `json:"personal_number"`
|
||||
Signed bool `json:"signed"`
|
||||
SignedAt *time.Time `json:"signed_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetMethods returnerar tillgängliga signeringsmetoder
|
||||
func (h *SigningHandler) GetMethods(w http.ResponseWriter, r *http.Request) {
|
||||
methods := []map[string]interface{}{
|
||||
{
|
||||
"id": "bankid",
|
||||
"name": "BankID",
|
||||
"description": "Swedish electronic identification",
|
||||
"available": h.bankIDURL != "" && h.bankIDAPIKey != "",
|
||||
"countries": []string{"SE"},
|
||||
"setup_url": "https://www.bankid.com/foretag",
|
||||
},
|
||||
{
|
||||
"id": "scrive",
|
||||
"name": "Scrive",
|
||||
"description": "Electronic signature platform",
|
||||
"available": h.scriveAPIKey != "",
|
||||
"setup_url": "https://scrive.com",
|
||||
},
|
||||
{
|
||||
"id": "docusign",
|
||||
"name": "DocuSign",
|
||||
"description": "Global e-signature solution",
|
||||
"available": h.docusignAPIKey != "",
|
||||
"setup_url": "https://docusign.com",
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"methods": methods,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRequests returnerar signeringsbegäranden
|
||||
func (h *SigningHandler) GetRequests(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Implementera DB-lagring av signeringsbegäranden
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Signing requests not yet implemented. Configure BANKID_URL and BANKID_API_KEY to enable.",
|
||||
})
|
||||
}
|
||||
|
||||
// InitiateBankID initierar BankID-signering
|
||||
func (h *SigningHandler) InitiateBankID(w http.ResponseWriter, r *http.Request) {
|
||||
if h.bankIDURL == "" || h.bankIDAPIKey == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "BankID not configured. Set BANKID_URL and BANKID_API_KEY environment variables.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PersonalNumber string `json:"personal_number"`
|
||||
DocumentID string `json:"document_id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implementera riktig BankID API-integration
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "BankID integration not yet implemented. Contact administrator to configure.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SocialMediaAccount representerar ett kopplat socialt media-konto
|
||||
type SocialMediaAccount struct {
|
||||
ID string `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
AccountName string `json:"account_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Followers int `json:"followers"`
|
||||
Following int `json:"following"`
|
||||
Posts int `json:"posts"`
|
||||
ProfileURL string `json:"profile_url"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsConnected bool `json:"is_connected"`
|
||||
LastSynced time.Time `json:"last_synced"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SocialMediaPost representerar ett inlägg
|
||||
type SocialMediaPost struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
Content string `json:"content"`
|
||||
MediaURL string `json:"media_url,omitempty"`
|
||||
Likes int `json:"likes"`
|
||||
Comments int `json:"comments"`
|
||||
Shares int `json:"shares"`
|
||||
Reach int `json:"reach"`
|
||||
PostedAt time.Time `json:"posted_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// SocialMediaStats representerar aggregerad statistik
|
||||
type SocialMediaStats struct {
|
||||
TotalFollowers int `json:"total_followers"`
|
||||
TotalPosts int `json:"total_posts"`
|
||||
TotalEngagement int `json:"total_engagement"`
|
||||
Accounts int `json:"accounts"`
|
||||
}
|
||||
|
||||
// SocialMediaHandler hanterar sociala media-konton
|
||||
type SocialMediaHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSocialMediaHandler skapar en ny handler
|
||||
func NewSocialMediaHandler(db *sql.DB) *SocialMediaHandler {
|
||||
return &SocialMediaHandler{db: db}
|
||||
}
|
||||
|
||||
// InitDB skapar tabeller för sociala media
|
||||
func (h *SocialMediaHandler) InitDB() error {
|
||||
_, err := h.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS social_media_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
platform TEXT NOT NULL,
|
||||
account_name TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
followers INTEGER DEFAULT 0,
|
||||
following INTEGER DEFAULT 0,
|
||||
posts INTEGER DEFAULT 0,
|
||||
profile_url TEXT,
|
||||
avatar_url TEXT,
|
||||
is_connected BOOLEAN DEFAULT false,
|
||||
last_synced TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = h.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS social_media_posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
content TEXT,
|
||||
media_url TEXT,
|
||||
likes INTEGER DEFAULT 0,
|
||||
comments INTEGER DEFAULT 0,
|
||||
shares INTEGER DEFAULT 0,
|
||||
reach INTEGER DEFAULT 0,
|
||||
posted_at TIMESTAMP,
|
||||
status TEXT DEFAULT 'published',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES social_media_accounts(id)
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAccounts listar alla kopplade konton
|
||||
func (h *SocialMediaHandler) ListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.db.Query(`
|
||||
SELECT id, platform, account_name, display_name, followers, following, posts,
|
||||
profile_url, avatar_url, is_connected, last_synced, created_at
|
||||
FROM social_media_accounts
|
||||
ORDER BY platform, account_name
|
||||
`)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var accounts []SocialMediaAccount
|
||||
for rows.Next() {
|
||||
var a SocialMediaAccount
|
||||
var lastSynced sql.NullTime
|
||||
err := rows.Scan(
|
||||
&a.ID, &a.Platform, &a.AccountName, &a.DisplayName,
|
||||
&a.Followers, &a.Following, &a.Posts,
|
||||
&a.ProfileURL, &a.AvatarURL, &a.IsConnected,
|
||||
&lastSynced, &a.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if lastSynced.Valid {
|
||||
a.LastSynced = lastSynced.Time
|
||||
}
|
||||
accounts = append(accounts, a)
|
||||
}
|
||||
|
||||
if accounts == nil {
|
||||
accounts = []SocialMediaAccount{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"accounts": accounts,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats returnerar aggregerad statistik
|
||||
func (h *SocialMediaHandler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
var stats SocialMediaStats
|
||||
err := h.db.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(SUM(followers), 0),
|
||||
COALESCE(SUM(posts), 0),
|
||||
COUNT(*)
|
||||
FROM social_media_accounts
|
||||
WHERE is_connected = true
|
||||
`).Scan(&stats.TotalFollowers, &stats.TotalPosts, &stats.Accounts)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// AddAccount lägger till ett nytt konto
|
||||
func (h *SocialMediaHandler) AddAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Platform string `json:"platform"`
|
||||
AccountName string `json:"account_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ProfileURL string `json:"profile_url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "invalid request",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("%s_%d", req.Platform, time.Now().Unix())
|
||||
_, err := h.db.Exec(`
|
||||
INSERT INTO social_media_accounts (id, platform, account_name, display_name, profile_url, is_connected)
|
||||
VALUES (?, ?, ?, ?, ?, true)
|
||||
`, id, req.Platform, req.AccountName, req.DisplayName, req.ProfileURL)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"account": map[string]interface{}{
|
||||
"id": id,
|
||||
"platform": req.Platform,
|
||||
"name": req.AccountName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListPosts listar inlägg
|
||||
func (h *SocialMediaHandler) ListPosts(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := r.URL.Query().Get("account_id")
|
||||
platform := r.URL.Query().Get("platform")
|
||||
|
||||
query := `
|
||||
SELECT id, account_id, platform, content, media_url, likes, comments, shares, reach, posted_at, status
|
||||
FROM social_media_posts
|
||||
WHERE 1=1
|
||||
`
|
||||
var args []interface{}
|
||||
|
||||
if accountID != "" {
|
||||
query += " AND account_id = ?"
|
||||
args = append(args, accountID)
|
||||
}
|
||||
if platform != "" {
|
||||
query += " AND platform = ?"
|
||||
args = append(args, platform)
|
||||
}
|
||||
query += " ORDER BY posted_at DESC LIMIT 50"
|
||||
|
||||
rows, err := h.db.Query(query, args...)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []SocialMediaPost
|
||||
for rows.Next() {
|
||||
var p SocialMediaPost
|
||||
var postedAt sql.NullTime
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.AccountID, &p.Platform, &p.Content, &p.MediaURL,
|
||||
&p.Likes, &p.Comments, &p.Shares, &p.Reach, &postedAt, &p.Status,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if postedAt.Valid {
|
||||
p.PostedAt = postedAt.Time
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
posts = []SocialMediaPost{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"posts": posts,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StripeHandler hanterar Stripe Connect för quiXzoom-utbetalningar
|
||||
type StripeHandler struct {
|
||||
apiKey string
|
||||
webhookSecret string
|
||||
}
|
||||
|
||||
func NewStripeHandler() *StripeHandler {
|
||||
return &StripeHandler{
|
||||
apiKey: os.Getenv("STRIPE_API_KEY"),
|
||||
webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
}
|
||||
}
|
||||
|
||||
// StripeAccount representerar ett Stripe-konto
|
||||
type StripeAccount struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Country string `json:"country"`
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
PayoutsEnabled bool `json:"payouts_enabled"`
|
||||
ChargesEnabled bool `json:"charges_enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// StripePayout representerar en Stripe-utbetalning
|
||||
type StripePayout struct {
|
||||
ID string `json:"id"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Method string `json:"method"`
|
||||
ArrivalDate string `json:"arrival_date"`
|
||||
BankAccount string `json:"bank_account"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// GetStatus returnerar Stripe-kopplingsstatus
|
||||
func (h *StripeHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
configured := h.apiKey != ""
|
||||
|
||||
status := map[string]interface{}{
|
||||
"configured": configured,
|
||||
"webhook_url": "https://boc.landvex.com/api/v1/stripe/webhook",
|
||||
"setup_required": !configured,
|
||||
}
|
||||
|
||||
if !configured {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Stripe not configured. Set STRIPE_API_KEY environment variable.",
|
||||
"stripe": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"stripe": status,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAccounts returnerar Stripe-konton (zoomers)
|
||||
func (h *StripeHandler) GetAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if h.apiKey == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Stripe not configured. Set STRIPE_API_KEY environment variable.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implementera riktig Stripe API-integration
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Stripe integration not yet implemented. Contact administrator to configure.",
|
||||
})
|
||||
}
|
||||
|
||||
// GetPayouts returnerar Stripe-utbetalningar
|
||||
func (h *StripeHandler) GetPayouts(w http.ResponseWriter, r *http.Request) {
|
||||
if h.apiKey == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Stripe not configured. Set STRIPE_API_KEY environment variable.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implementera riktig Stripe API-integration
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Stripe integration not yet implemented. Contact administrator to configure.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// UnifiedHandler hanterar allt i ett enda API
|
||||
type UnifiedHandler struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewUnifiedHandler(db *sql.DB) *UnifiedHandler {
|
||||
return &UnifiedHandler{DB: db}
|
||||
}
|
||||
|
||||
// GetUnifiedDashboard returnerar allt på ett ställe
|
||||
func (h *UnifiedHandler) GetUnifiedDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Hämta CRM-data från BOC-databasen
|
||||
customers := []map[string]interface{}{}
|
||||
customerRows, err := h.DB.Query(`
|
||||
SELECT id, name, email, status, created_at
|
||||
FROM boc_customers
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err == nil {
|
||||
defer customerRows.Close()
|
||||
for customerRows.Next() {
|
||||
var c map[string]interface{}
|
||||
var id, name, email, status string
|
||||
var createdAt sql.NullTime
|
||||
if err := customerRows.Scan(&id, &name, &email, &status, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
c = map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"email": email,
|
||||
"status": status,
|
||||
}
|
||||
if createdAt.Valid {
|
||||
c["created_at"] = createdAt.Time.Format("2006-01-02")
|
||||
}
|
||||
customers = append(customers, c)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Hämta deals från BOC-databasen
|
||||
deals := []map[string]interface{}{}
|
||||
dealRows, err := h.DB.Query(`
|
||||
SELECT id, name, customer_id, value, currency, stage, status
|
||||
FROM boc_deals
|
||||
ORDER BY
|
||||
CASE stage
|
||||
WHEN 'negotiation' THEN 1
|
||||
WHEN 'proposal' THEN 2
|
||||
WHEN 'closed' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
value DESC
|
||||
`)
|
||||
if err == nil {
|
||||
defer dealRows.Close()
|
||||
for dealRows.Next() {
|
||||
var d map[string]interface{}
|
||||
var id, name, customerID, currency, stage, status string
|
||||
var value float64
|
||||
if err := dealRows.Scan(&id, &name, &customerID, &value, ¤cy, &stage, &status); err != nil {
|
||||
continue
|
||||
}
|
||||
d = map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"customer_id": customerID,
|
||||
"value": value,
|
||||
"currency": currency,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
}
|
||||
deals = append(deals, d)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hämta mail-statistik
|
||||
mailCount := 0
|
||||
mailRows, err := h.DB.Query(`
|
||||
SELECT COUNT(*) FROM boc_mail_messages
|
||||
`)
|
||||
if err == nil && mailRows.Next() {
|
||||
mailRows.Scan(&mailCount)
|
||||
mailRows.Close()
|
||||
}
|
||||
|
||||
// 4. Hämta analytics
|
||||
var revenue, moms float64
|
||||
analyticsRows, err := h.DB.Query(`
|
||||
SELECT kpi_key, value FROM boc_analytics_kpis
|
||||
WHERE kpi_key IN ('revenue_h1', 'moms_att_betala')
|
||||
`)
|
||||
if err == nil {
|
||||
defer analyticsRows.Close()
|
||||
for analyticsRows.Next() {
|
||||
var key string
|
||||
var value float64
|
||||
if err := analyticsRows.Scan(&key, &value); err != nil {
|
||||
continue
|
||||
}
|
||||
if key == "revenue_h1" {
|
||||
revenue = value
|
||||
} else if key == "moms_att_betala" {
|
||||
moms = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Hämta team
|
||||
team := []map[string]interface{}{}
|
||||
teamRows, err := h.DB.Query(`
|
||||
SELECT first_name, last_name, position, department, status
|
||||
FROM boc_employees
|
||||
WHERE status = 'active'
|
||||
ORDER BY department, first_name
|
||||
`)
|
||||
if err == nil {
|
||||
defer teamRows.Close()
|
||||
for teamRows.Next() {
|
||||
var firstName, lastName, position, department, status string
|
||||
if err := teamRows.Scan(&firstName, &lastName, &position, &department, &status); err != nil {
|
||||
continue
|
||||
}
|
||||
team = append(team, map[string]interface{}{
|
||||
"name": firstName + " " + lastName,
|
||||
"position": position,
|
||||
"department": department,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"dashboard": map[string]interface{}{
|
||||
"crm": map[string]interface{}{
|
||||
"customers": customers,
|
||||
"deals": deals,
|
||||
"total_pipeline": calculatePipeline(deals),
|
||||
},
|
||||
"finance": map[string]interface{}{
|
||||
"revenue": revenue,
|
||||
"moms": moms,
|
||||
},
|
||||
"mail": map[string]interface{}{
|
||||
"total_messages": mailCount,
|
||||
},
|
||||
"team": team,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func calculatePipeline(deals []map[string]interface{}) float64 {
|
||||
var total float64
|
||||
for _, deal := range deals {
|
||||
if status, ok := deal["status"].(string); ok && status == "open" {
|
||||
if value, ok := deal["value"].(float64); ok {
|
||||
total += value
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VismaHandler hanterar riktig Visma-integration
|
||||
type VismaHandler struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURI string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
tokenExpiry time.Time
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewVismaHandler skapar en ny handler
|
||||
func NewVismaHandler() *VismaHandler {
|
||||
return &VismaHandler{
|
||||
clientID: os.Getenv("VISMA_CLIENT_ID"),
|
||||
clientSecret: os.Getenv("VISMA_CLIENT_SECRET"),
|
||||
redirectURI: os.Getenv("VISMA_REDIRECT_URI"),
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfigured returnerar true om Visma är konfigurerat
|
||||
func (h *VismaHandler) IsConfigured() bool {
|
||||
return h.clientID != "" && h.clientSecret != ""
|
||||
}
|
||||
|
||||
// VismaCompany representerar ett Visma-företag
|
||||
type VismaCompany struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OrgNumber string `json:"organisationNumber"`
|
||||
}
|
||||
|
||||
// VismaVoucher representerar ett Visma-verifikat
|
||||
type VismaVoucher struct {
|
||||
ID string `json:"id"`
|
||||
VoucherDate string `json:"voucherDate"`
|
||||
Text string `json:"text"`
|
||||
Rows []VismaRow `json:"rows"`
|
||||
Modified time.Time `json:"modifiedUtc"`
|
||||
}
|
||||
|
||||
// VismaRow representerar en verifikatrad
|
||||
type VismaRow struct {
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
DebitAmount float64 `json:"debitAmount"`
|
||||
CreditAmount float64 `json:"creditAmount"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// GetAuthURL returnerar Visma OAuth URL
|
||||
func (h *VismaHandler) GetAuthURL(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.IsConfigured() {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Visma not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf(
|
||||
"https://eaccountingapi.vismaonline.com/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code&scope=ea:api",
|
||||
h.clientID,
|
||||
h.redirectURI,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"auth_url": authURL,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleCallback hanterar Visma OAuth callback
|
||||
func (h *VismaHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "missing code",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Byt kod mot token
|
||||
tokenURL := "https://eaccountingapi.vismaonline.com/oauth/token"
|
||||
reqBody := fmt.Sprintf("grant_type=authorization_code&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s",
|
||||
code, h.redirectURI, h.clientID, h.clientSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.accessToken = tokenResp.AccessToken
|
||||
h.refreshToken = tokenResp.RefreshToken
|
||||
h.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"authenticated": true,
|
||||
"expires": h.tokenExpiry,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCompanies hämtar företag från Visma
|
||||
func (h *VismaHandler) GetCompanies(w http.ResponseWriter, r *http.Request) {
|
||||
if h.accessToken == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "not authenticated",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://eaccountingapi.vismaonline.com/v2/companysettings", nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+h.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeJSON(w, resp.StatusCode, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("Visma API error: %s", string(body)),
|
||||
"status": resp.StatusCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var companies []VismaCompany
|
||||
if err := json.Unmarshal(body, &companies); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"companies": companies,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVouchers hämtar verifikat från Visma
|
||||
func (h *VismaHandler) GetVouchers(w http.ResponseWriter, r *http.Request) {
|
||||
if h.accessToken == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "not authenticated",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Hämta vouchers från Visma API
|
||||
req, err := http.NewRequest("GET", "https://eaccountingapi.vismaonline.com/v2/vouchers", nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+h.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("Visma API error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeJSON(w, resp.StatusCode, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": fmt.Sprintf("Visma API returned %d: %s", resp.StatusCode, string(body)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var vouchers []VismaVoucher
|
||||
if err := json.NewDecoder(resp.Body).Decode(&vouchers); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Failed to decode Visma response",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"vouchers": vouchers,
|
||||
"source": "visma",
|
||||
})
|
||||
}
|
||||
|
||||
// GetStatus returnerar Visma-kopplingsstatus
|
||||
func (h *VismaHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]interface{}{
|
||||
"configured": h.IsConfigured(),
|
||||
"authenticated": h.accessToken != "",
|
||||
"client_id": h.clientID,
|
||||
"token_expiry": h.tokenExpiry,
|
||||
}
|
||||
|
||||
if !h.IsConfigured() {
|
||||
status["setup_url"] = "/api/v1/visma/auth"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"visma": status,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user