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:
@@ -38,6 +38,16 @@ func (c Claims) Valid() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasRole kontrollerar om användaren har en specifik roll
|
||||
func (c Claims) HasRole(role string) bool {
|
||||
for _, r := range c.Roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Context Key ────────────────────────────────────────────────────────────
|
||||
type contextKey int
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword skapar en bcrypt hash av lösenordet
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// VerifyPassword kontrollerar att lösenordet matchar hashen
|
||||
func VerifyPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ValidatePasswordStrength kontrollerar lösenordsstyrka
|
||||
func ValidatePasswordStrength(password string) error {
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
|
||||
hasUpper := false
|
||||
hasLower := false
|
||||
hasNumber := false
|
||||
hasSpecial := false
|
||||
|
||||
for _, c := range password {
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
hasUpper = true
|
||||
case c >= 'a' && c <= 'z':
|
||||
hasLower = true
|
||||
case c >= '0' && c <= '9':
|
||||
hasNumber = true
|
||||
case c >= '!' && c <= '/' || c >= ':' && c <= '@' || c >= '[' && c <= '`' || c >= '{' && c <= '~':
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper {
|
||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||
}
|
||||
if !hasLower {
|
||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||
}
|
||||
if !hasNumber {
|
||||
return fmt.Errorf("password must contain at least one number")
|
||||
}
|
||||
if !hasSpecial {
|
||||
return fmt.Errorf("password must contain at least one special character")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// AuthService hanterar autentisering och auktorisation
|
||||
type AuthService struct {
|
||||
db *sql.DB
|
||||
jwtSecret string
|
||||
issuer string
|
||||
audience string
|
||||
}
|
||||
|
||||
// NewAuthService skapar en ny auth service
|
||||
func NewAuthService(db *sql.DB, jwtSecret, issuer, audience string) *AuthService {
|
||||
return &AuthService{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
}
|
||||
}
|
||||
|
||||
// User representerar en autentiserad användare
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
// LoginRequest innehåller login-uppgifter
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse innehåller token och användardata
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
// Login autentiserar en användare och returnerar JWT token
|
||||
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
// Hämta användare från databas
|
||||
var user User
|
||||
var passwordHash string
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, name, role, tenant_id, password_hash
|
||||
FROM boc_users
|
||||
WHERE email = $1 AND status = 'active'
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.TenantID, &passwordHash)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invalid email or password")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
// Verifiera lösenord
|
||||
if !VerifyPassword(req.Password, passwordHash) {
|
||||
return nil, fmt.Errorf("invalid email or password")
|
||||
}
|
||||
|
||||
// Generera JWT token
|
||||
token, err := s.GenerateToken(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
// Uppdatera last_login
|
||||
_, _ = s.db.ExecContext(ctx, `
|
||||
UPDATE boc_users SET last_login = NOW() WHERE id = $1
|
||||
`, user.ID)
|
||||
|
||||
return &LoginResponse{
|
||||
Token: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600, // 1 timme
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateToken skapar en JWT token för en användare
|
||||
func (s *AuthService) GenerateToken(user User) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"tenant_id": user.TenantID,
|
||||
"iss": s.issuer,
|
||||
"aud": s.audience,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(1 * time.Hour).Unix(), // 1 timme
|
||||
"jti": generateJTI(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.jwtSecret))
|
||||
}
|
||||
|
||||
// ValidateToken validerar en JWT token och returnerar användardata
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*User, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("token is invalid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid claims")
|
||||
}
|
||||
|
||||
user := &User{
|
||||
ID: getStringClaim(claims, "sub"),
|
||||
Email: getStringClaim(claims, "email"),
|
||||
Name: getStringClaim(claims, "name"),
|
||||
Role: getStringClaim(claims, "role"),
|
||||
TenantID: getStringClaim(claims, "tenant_id"),
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// HasRole kontrollerar om användaren har en specifik roll
|
||||
func (s *AuthService) HasRole(user *User, role string) bool {
|
||||
if user.Role == role {
|
||||
return true
|
||||
}
|
||||
for _, r := range user.Roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RequireRole middleware kontrollerar att användaren har en specifik roll
|
||||
func (s *AuthService) RequireRole(role string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := FromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user := &User{
|
||||
ID: claims.Sub,
|
||||
Email: claims.Email,
|
||||
Role: "",
|
||||
Roles: claims.Roles,
|
||||
}
|
||||
|
||||
if !s.HasRole(user, role) {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateJTI() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,162 @@
|
||||
-- Migration 006: Landvex, Compliance, Analytics tables
|
||||
-- Allt som tidigare var hårdkodat i Go flyttas till databas
|
||||
|
||||
-- Landvex: Entities (bolag)
|
||||
CREATE TABLE IF NOT EXISTS boc_landvex_entities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_id TEXT UNIQUE NOT NULL, -- t.ex. "lvx-ab", "lvx-inc"
|
||||
name TEXT NOT NULL,
|
||||
jurisdiction TEXT NOT NULL, -- SE, US, etc.
|
||||
entity_type TEXT NOT NULL, -- holding, operating
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Landvex: Ownership structure
|
||||
CREATE TABLE IF NOT EXISTS boc_landvex_ownership (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_id TEXT NOT NULL REFERENCES boc_landvex_entities(entity_id),
|
||||
owner_name TEXT NOT NULL,
|
||||
owner_email TEXT,
|
||||
ownership_percent NUMERIC(5,2) NOT NULL DEFAULT 100,
|
||||
parent_entity_id TEXT REFERENCES boc_landvex_entities(entity_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Landvex: Compliance items
|
||||
CREATE TABLE IF NOT EXISTS boc_landvex_compliance (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_id TEXT NOT NULL REFERENCES boc_landvex_entities(entity_id),
|
||||
category TEXT NOT NULL, -- tax, annual_report, audit, etc.
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, completed, overdue
|
||||
due_date DATE,
|
||||
completed_at TIMESTAMPTZ,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Compliance: Legal cases
|
||||
CREATE TABLE IF NOT EXISTS boc_legal_cases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id TEXT UNIQUE NOT NULL, -- t.ex. "case-001"
|
||||
entity_id TEXT NOT NULL REFERENCES boc_landvex_entities(entity_id),
|
||||
title TEXT NOT NULL,
|
||||
case_type TEXT NOT NULL, -- debt_collection, corporate, etc.
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active, pending, closed
|
||||
priority TEXT NOT NULL DEFAULT 'medium', -- low, medium, high
|
||||
description TEXT NOT NULL,
|
||||
opposing_party TEXT NOT NULL,
|
||||
lawyer TEXT,
|
||||
opened_at DATE NOT NULL,
|
||||
closed_at DATE,
|
||||
value NUMERIC(15,2) DEFAULT 0,
|
||||
currency TEXT DEFAULT 'SEK',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Analytics: Dashboard KPIs
|
||||
CREATE TABLE IF NOT EXISTS boc_analytics_kpis (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kpi_key TEXT UNIQUE NOT NULL, -- t.ex. "revenue_h1", "moms_att_betala"
|
||||
label TEXT NOT NULL,
|
||||
value NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
currency TEXT,
|
||||
trend NUMERIC(5,2), -- procent, t.ex. 0.15 för 15%
|
||||
period TEXT, -- t.ex. "H1 2026", "all"
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Analytics: Revenue trend (månadsvis)
|
||||
CREATE TABLE IF NOT EXISTS boc_analytics_revenue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
month TEXT NOT NULL, -- t.ex. "Jan", "Feb"
|
||||
year INTEGER NOT NULL,
|
||||
revenue NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
UNIQUE(year, month)
|
||||
);
|
||||
|
||||
-- Analytics: Expenses by category
|
||||
CREATE TABLE IF NOT EXISTS boc_analytics_expenses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category TEXT NOT NULL, -- t.ex. "IT/Molntjänster"
|
||||
amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
period TEXT DEFAULT 'all',
|
||||
UNIQUE(category, period)
|
||||
);
|
||||
|
||||
-- Analytics: Alerts
|
||||
CREATE TABLE IF NOT EXISTS boc_analytics_alerts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
alert_type TEXT NOT NULL, -- warning, info, danger
|
||||
message TEXT NOT NULL,
|
||||
due_date DATE,
|
||||
dismissed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert default Landvex data
|
||||
INSERT INTO boc_landvex_entities (entity_id, name, jurisdiction, entity_type, status) VALUES
|
||||
('lvx-ab', 'Landvex AB', 'SE', 'holding', 'active'),
|
||||
('lvx-inc', 'Landvex Inc.', 'US', 'operating', 'active')
|
||||
ON CONFLICT (entity_id) DO NOTHING;
|
||||
|
||||
-- Insert ownership
|
||||
INSERT INTO boc_landvex_ownership (entity_id, owner_name, ownership_percent) VALUES
|
||||
('lvx-ab', 'Erik Svensson', 100),
|
||||
('lvx-inc', 'Landvex AB', 100)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert compliance items
|
||||
INSERT INTO boc_landvex_compliance (entity_id, category, title, status, due_date) VALUES
|
||||
('lvx-ab', 'tax', 'Momsdeklaration H1 2026', 'pending', '2026-08-12'),
|
||||
('lvx-ab', 'annual_report', 'Årsredovisning 2025', 'overdue', '2026-07-31'),
|
||||
('lvx-ab', 'tax', 'Inkomstdeklaration 2025', 'overdue', '2026-05-02'),
|
||||
('lvx-ab', 'annual_report', 'Årsredovisning 2024', 'completed', '2025-07-31'),
|
||||
('lvx-ab', 'audit', 'Revisorns granskning 2025', 'pending', '2026-09-30'),
|
||||
('lvx-inc', 'tax', 'Federal Tax Return 2025', 'pending', '2026-04-15')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert legal cases
|
||||
INSERT INTO boc_legal_cases (case_id, entity_id, title, case_type, status, priority, description, opposing_party, lawyer, opened_at, value, currency) VALUES
|
||||
('case-001', 'lvx-ab', 'Leon Russo De Cerame — krav på återbetalning', 'debt_collection', 'active', 'high', 'Obehöriga uttag på företagskort, totalt 212 921,60 SEK', 'Leon Maurizio Russo De Cerame', 'Advokatfirman X', '2026-06-16', 212921.60, 'SEK')
|
||||
ON CONFLICT (case_id) DO NOTHING;
|
||||
|
||||
-- Insert analytics KPIs
|
||||
INSERT INTO boc_analytics_kpis (kpi_key, label, value, currency, trend, period) VALUES
|
||||
('revenue_h1', 'Revenue H1 2026', 1856469.00, 'SEK', 0.15, 'H1 2026'),
|
||||
('moms_att_betala', 'MOMS att betala', 440783.00, 'SEK', NULL, 'H1 2026'),
|
||||
('customers_total', 'Customers', 3, NULL, NULL, 'all'),
|
||||
('cash_on_hand', 'Cash on Hand', 45230.00, 'SEK', NULL, 'all')
|
||||
ON CONFLICT (kpi_key) DO NOTHING;
|
||||
|
||||
-- Insert revenue trend
|
||||
INSERT INTO boc_analytics_revenue (month, year, revenue) VALUES
|
||||
('Jan', 2026, 811147),
|
||||
('Feb', 2026, 0),
|
||||
('Mar', 2026, 0),
|
||||
('Apr', 2026, 955317),
|
||||
('May', 2026, 0),
|
||||
('Jun', 2026, 0)
|
||||
ON CONFLICT (year, month) DO NOTHING;
|
||||
|
||||
-- Insert expenses by category
|
||||
INSERT INTO boc_analytics_expenses (category, amount) VALUES
|
||||
('IT/Molntjänster', 449984),
|
||||
('Resekostnader', 276712),
|
||||
('Representation', 84742),
|
||||
('Externa tjänster', 152727),
|
||||
('Löner', 100047),
|
||||
('Övrigt', 45291)
|
||||
ON CONFLICT (category, period) DO NOTHING;
|
||||
|
||||
-- Insert alerts
|
||||
INSERT INTO boc_analytics_alerts (alert_type, message, due_date) VALUES
|
||||
('warning', 'Momsdeklaration H1 2026 deadline: 12 augusti', '2026-08-12'),
|
||||
('warning', 'Årsredovisning 2025 måste lämnas', '2026-07-31'),
|
||||
('info', 'Inkomstdeklaration 2025 försenad', '2026-05-02')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- Migration 007: Visma eEkonomi features
|
||||
-- Allt ett företag behöver
|
||||
|
||||
-- 1. LÖN (Payroll)
|
||||
CREATE TABLE IF NOT EXISTS boc_payroll (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
gross_salary NUMERIC(12,2) NOT NULL,
|
||||
tax_deduction NUMERIC(12,2) NOT NULL,
|
||||
employer_contribution NUMERIC(12,2) NOT NULL,
|
||||
net_salary NUMERIC(12,2) NOT NULL,
|
||||
payment_date DATE,
|
||||
status TEXT DEFAULT 'draft',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. TID (Time tracking)
|
||||
CREATE TABLE IF NOT EXISTS boc_time_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
hours NUMERIC(4,2) NOT NULL,
|
||||
project_id TEXT,
|
||||
description TEXT,
|
||||
billable BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 3. PROJEKT (redan skapad i 002, bara seed-data)
|
||||
-- CREATE TABLE IF NOT EXISTS boc_projects (...); -- redan finns
|
||||
|
||||
-- 4. LAGER (Inventory)
|
||||
CREATE TABLE IF NOT EXISTS boc_inventory (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sku TEXT UNIQUE,
|
||||
quantity INTEGER DEFAULT 0,
|
||||
unit_cost NUMERIC(12,2),
|
||||
unit_price NUMERIC(12,2),
|
||||
category TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5. LEVERANTÖRER (Suppliers)
|
||||
CREATE TABLE IF NOT EXISTS boc_suppliers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
org_number TEXT,
|
||||
address TEXT,
|
||||
payment_terms TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 6. INKÖP (Purchases)
|
||||
CREATE TABLE IF NOT EXISTS boc_purchases (
|
||||
id TEXT PRIMARY KEY,
|
||||
supplier_id TEXT,
|
||||
amount NUMERIC(12,2) NOT NULL,
|
||||
currency TEXT DEFAULT 'SEK',
|
||||
status TEXT DEFAULT 'draft',
|
||||
due_date DATE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 7. RAPPORTER (Reports)
|
||||
CREATE TABLE IF NOT EXISTS boc_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
period TEXT,
|
||||
data JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed data
|
||||
-- INSERT INTO boc_projects (...) -- redan finns data
|
||||
|
||||
-- INSERT INTO boc_inventory (...) -- redan finns data
|
||||
-- INSERT INTO boc_suppliers (...) -- redan finns data
|
||||
-- INSERT INTO boc_purchases (...) -- redan finns data
|
||||
@@ -0,0 +1,17 @@
|
||||
-- =====================================================
|
||||
-- Migration 008: Add password hash for secure authentication
|
||||
-- =====================================================
|
||||
|
||||
-- Lägg till password_hash för säker lösenordslagring
|
||||
ALTER TABLE boc_users ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||
|
||||
-- Skapa index för snabb login-lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON boc_users(email);
|
||||
|
||||
-- Uppdatera befintliga användare med default lösenord (byt omedelbart!)
|
||||
-- Default: 'changeme' - bcrypt hash
|
||||
UPDATE boc_users
|
||||
SET password_hash = '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiAYMyzJ/I2K'
|
||||
WHERE password_hash IS NULL;
|
||||
|
||||
-- Kommentar: Ovanstående hash är för 'changeme' - ALLA användare måste byta lösenord!
|
||||
@@ -0,0 +1,225 @@
|
||||
-- Seed contract templates for ISO 9001 and common business contracts
|
||||
|
||||
INSERT INTO boc_contract_templates (id, name, type, category, content, placeholders, created_at, updated_at) VALUES
|
||||
('tmpl-iso-9001-1', 'ISO 9001:2015 Kvalitetsledningssystem', 'iso', 'quality',
|
||||
E'KVALITETSPOLICY
|
||||
|
||||
{{company_name}} ska leverera produkter och tjänster som uppfyller kundernas krav och förväntningar samt tillämpliga lagkrav och andra krav.
|
||||
|
||||
KVALITETSLEDNINGSSYSTEM
|
||||
|
||||
{{company_name}} har upprättat, dokumenterat, implementerat och underhåller ett kvalitetsledningssystem i enlighet med kraven i ISO 9001:2015.
|
||||
|
||||
ANSVAR OCH MYNDIGHET
|
||||
|
||||
Kvalitetsansvarig: {{quality_manager}}
|
||||
Datum: {{date}}
|
||||
Giltig till: {{expiry_date}}
|
||||
|
||||
{{company_name}}
|
||||
Org.nr: {{org_number}}
|
||||
Adress: {{address}}
|
||||
|
||||
Underskrift: ___________________',
|
||||
'["company_name", "quality_manager", "date", "expiry_date", "org_number", "address"]',
|
||||
NOW(), NOW()),
|
||||
|
||||
('tmpl-iso-9001-2', 'ISO 9001:2015 Internrevision', 'iso', 'quality',
|
||||
E'INTERNREVISIONSPROGRAM
|
||||
|
||||
Företag: {{company_name}}
|
||||
Revisionsansvarig: {{auditor}}
|
||||
Datum: {{date}}
|
||||
|
||||
1. SYFTE
|
||||
Verifiera att kvalitetsledningssystemet:
|
||||
- Upfyller planerade arrangemang
|
||||
- Upfyller kraven i ISO 9001:2015
|
||||
- Är effektivt implementerat och underhållet
|
||||
|
||||
2. OMFATTNING
|
||||
{{scope}}
|
||||
|
||||
3. REFERENSER
|
||||
- ISO 9001:2015
|
||||
- Kvalitetshandbok
|
||||
- Tillämpliga procedurer
|
||||
|
||||
4. REVISIONSRESULTAT
|
||||
{{findings}}
|
||||
|
||||
5. ÅTGÄRDER
|
||||
{{actions}}
|
||||
|
||||
Godkänd av: {{approver}}
|
||||
Datum: {{approval_date}}',
|
||||
'["company_name", "auditor", "date", "scope", "findings", "actions", "approver", "approval_date"]',
|
||||
NOW(), NOW()),
|
||||
|
||||
('tmpl-iso-9001-3', 'ISO 9001:2015 Korrigerande åtgärd', 'iso', 'quality',
|
||||
E'KORRIGERANDE ÅTGÄRDSRAPPORT (CAR)
|
||||
|
||||
CAR-nummer: {{car_number}}
|
||||
Datum: {{date}}
|
||||
Rapporterad av: {{reporter}}
|
||||
|
||||
1. BESKRIVNING AV AVVIKELSE
|
||||
{{deviation_description}}
|
||||
|
||||
2. ROTORSAKSANALYS
|
||||
{{root_cause}}
|
||||
|
||||
3. KORRIGERANDE ÅTGÄRD
|
||||
{{corrective_action}}
|
||||
|
||||
4. FÖREBYGGANDE ÅTGÄRD
|
||||
{{preventive_action}}
|
||||
|
||||
5. VERIFIERING
|
||||
Verifierad av: {{verifier}}
|
||||
Datum: {{verification_date}}
|
||||
Resultat: {{verification_result}}',
|
||||
'["car_number", "date", "reporter", "deviation_description", "root_cause", "corrective_action", "preventive_action", "verifier", "verification_date", "verification_result"]',
|
||||
NOW(), NOW()),
|
||||
|
||||
('tmpl-employment-1', 'Anställningsavtal', 'employment', 'hr',
|
||||
E'ANSTÄLLNINGSAVTAL
|
||||
|
||||
1. PARTER
|
||||
Arbetsgivare: {{employer_name}} (org.nr {{employer_org}})
|
||||
Arbetstagare: {{employee_name}} (personnr {{employee_ssn}})
|
||||
|
||||
2. ANSTÄLLNING
|
||||
Befattning: {{position}}
|
||||
Avdelning: {{department}}
|
||||
Anställningsform: {{employment_type}}
|
||||
Startdatum: {{start_date}}
|
||||
|
||||
3. LÖN OCH FÖRMÅNER
|
||||
Månadslön: {{salary}} {{currency}}
|
||||
Semester: {{vacation_days}} dagar/år
|
||||
Arbetstid: {{working_hours}}
|
||||
|
||||
4. UPPSÄGNING
|
||||
Uppsägningstid: {{notice_period}}
|
||||
|
||||
5. ÖVRIGT
|
||||
{{additional_terms}}
|
||||
|
||||
Ort och datum: {{place_date}}
|
||||
|
||||
Arbetsgivarens underskrift: ___________________
|
||||
Arbetstagarens underskrift: ___________________',
|
||||
'["employer_name", "employer_org", "employee_name", "employee_ssn", "position", "department", "employment_type", "start_date", "salary", "currency", "vacation_days", "working_hours", "notice_period", "additional_terms", "place_date"]',
|
||||
NOW(), NOW()),
|
||||
|
||||
('tmpl-nda-1', 'Sekretessavtal (NDA)', 'legal', 'confidentiality',
|
||||
E'SEKRETESSAVTAL
|
||||
|
||||
1. PARTER
|
||||
Uppdragsgivare: {{party_a}}
|
||||
Mottagare: {{party_b}}
|
||||
Datum: {{date}}
|
||||
|
||||
2. SYFTE
|
||||
Part B ska få tillgång till konfidentiell information från Part A i syfte att {{purpose}}.
|
||||
|
||||
3. DEFINITION AV KONFIDENTIELL INFORMATION
|
||||
Konfidentiell information inkluderar men är inte begränsat till:
|
||||
- Affärsplaner och strategier
|
||||
- Teknisk dokumentation
|
||||
- Kundlistor och prisinformation
|
||||
- Programkod och algoritmer
|
||||
|
||||
4. SKYLDIGHETER
|
||||
Mottagaren förbinder sig att:
|
||||
- Inte avslöja konfidentiell information för tredje part
|
||||
- Inte använda informationen för andra syften än avtalat
|
||||
- Vidta rimliga säkerhetsåtgärder
|
||||
|
||||
5. GILTIGHETSTID
|
||||
{{validity_years}} år från avtalets undertecknande.
|
||||
|
||||
6. PÅFÖLJD
|
||||
Vid brott mot detta avtal utgår vite om {{penalty}} {{currency}}.
|
||||
|
||||
Underskrifter:
|
||||
{{party_a}}: ___________________ {{party_b}}: ___________________',
|
||||
'["party_a", "party_b", "date", "purpose", "validity_years", "penalty", "currency"]',
|
||||
NOW(), NOW()),
|
||||
|
||||
('tmpl-service-1', 'Tjänsteavtal (SLA)', 'service', 'operations',
|
||||
E'TJÄNSTEAVTAL
|
||||
|
||||
1. PARTER
|
||||
Leverantör: {{provider}}
|
||||
Kund: {{customer}}
|
||||
Avtalsnummer: {{contract_number}}
|
||||
|
||||
2. TJÄNSTER
|
||||
{{services_description}}
|
||||
|
||||
3. SERVICENIVÅER (SLA)
|
||||
- Tillgänglighet: {{availability}}%
|
||||
- Responstid: {{response_time}} timmar
|
||||
- Återställningstid: {{recovery_time}} timmar
|
||||
|
||||
4. PRISER OCH BETALNING
|
||||
Månadsavgift: {{monthly_fee}} {{currency}}
|
||||
Betalningsvillkor: {{payment_terms}} dagar
|
||||
|
||||
5. AVTALSTID
|
||||
Start: {{start_date}}
|
||||
Slut: {{end_date}}
|
||||
Uppsägningstid: {{notice_period}} månader
|
||||
|
||||
6. KONTAKTPERSONER
|
||||
Leverantör: {{provider_contact}}
|
||||
Kund: {{customer_contact}}
|
||||
|
||||
Underskrifter:
|
||||
{{provider}}: ___________________ {{customer}}: ___________________',
|
||||
'["provider", "customer", "contract_number", "services_description", "availability", "response_time", "recovery_time", "monthly_fee", "currency", "payment_terms", "start_date", "end_date", "notice_period", "provider_contact", "customer_contact"]',
|
||||
NOW(), NOW()),
|
||||
|
||||
('tmpl-gdpr-1', 'GDPR Personuppgiftsbiträdesavtal', 'gdpr', 'privacy',
|
||||
E'PERSONUPPGIFTSBITRÄDESAVTAL
|
||||
|
||||
1. PARTER
|
||||
Personuppgiftsansvarig: {{data_controller}}
|
||||
Personuppgiftsbiträde: {{data_processor}}
|
||||
Datum: {{date}}
|
||||
|
||||
2. BEHANDLING
|
||||
Personuppgiftsbiträdet ska behandla personuppgifter för följande ändamål:
|
||||
{{processing_purpose}}
|
||||
|
||||
3. KATEGORIER AV REGISTERFÖRDA
|
||||
{{data_subjects}}
|
||||
|
||||
4. TYP AV PERSONUPPGIFTER
|
||||
{{data_types}}
|
||||
|
||||
5. SÄKERHETSÅTGÄRDER
|
||||
Biträdet ska implementera följande tekniska och organisatoriska åtgärder:
|
||||
{{security_measures}}
|
||||
|
||||
6. UNDERLEVERANTÖRER
|
||||
Godkända underleverantörer: {{subprocessors}}
|
||||
|
||||
7. AVTALSTID OCH UPPSÄGNING
|
||||
Giltig från: {{start_date}}
|
||||
Uppsägningstid: {{notice_period}} månader
|
||||
|
||||
Underskrifter:
|
||||
{{data_controller}}: ___________________ {{data_processor}}: ___________________',
|
||||
'["data_controller", "data_processor", "date", "processing_purpose", "data_subjects", "data_types", "security_measures", "subprocessors", "start_date", "notice_period"]',
|
||||
NOW(), NOW());
|
||||
|
||||
-- Seed sample contracts
|
||||
INSERT INTO boc_contracts (id, template_type, name, counterparty, counterparty_org, status, value, currency, start_date, end_date, responsible, created_at, updated_at) VALUES
|
||||
('ctr-001', 'tmpl-employment-1', 'Anställningsavtal - Erik Svensson', 'Erik Svensson', 'LandveX AB', 'active', 0, 'SEK', '2024-01-01', NULL, 'Erik Svensson', NOW(), NOW()),
|
||||
('ctr-002', 'tmpl-employment-1', 'Anställningsavtal - Johan Berglund', 'Johan Berglund', 'LandveX AB', 'active', 0, 'SEK', '2024-01-01', NULL, 'Erik Svensson', NOW(), NOW()),
|
||||
('ctr-003', 'tmpl-iso-9001-1', 'ISO 9001:2015 Kvalitetscertifiering', 'LandveX AB', 'LandveX AB', 'active', 150000, 'SEK', '2024-01-01', '2027-01-01', 'Erik Svensson', NOW(), NOW()),
|
||||
('ctr-004', 'tmpl-service-1', 'AWS Hosting SLA', 'Amazon Web Services', 'AWS', 'active', 50000, 'USD', '2024-01-01', '2025-01-01', 'Johan Berglund', NOW(), NOW()),
|
||||
('ctr-005', 'tmpl-nda-1', 'Sekretessavtal - Atlas Capture', 'Jun Wakabayashi', 'Atlas Capture', 'active', 0, 'SEK', '2024-07-03', '2026-07-03', 'Erik Svensson', NOW(), NOW());
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BOC API Documentation</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.10.0/swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
#swagger-ui { max-width: 1200px; margin: 0 auto; }
|
||||
.topbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.10.0/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
SwaggerUIBundle({
|
||||
url: '/swagger.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.presets.standalone
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
validatorUrl: null
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "BOC API",
|
||||
"description": "Business Operations Center API - LandveX",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "LandveX Support",
|
||||
"email": "support@landvex.com"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:9096",
|
||||
"description": "Local development"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"tags": ["System"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Service is healthy",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": { "type": "boolean" },
|
||||
"service": { "type": "string" },
|
||||
"version": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"get": {
|
||||
"summary": "Get current user",
|
||||
"tags": ["Auth"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sub": { "type": "string" },
|
||||
"email": { "type": "string" },
|
||||
"roles": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Valid Bearer token required"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/hr/employees": {
|
||||
"get": {
|
||||
"summary": "List employees",
|
||||
"tags": ["HR"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "List of employees" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Create employee",
|
||||
"tags": ["HR"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"201": { "description": "Employee created" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/crm/customers": {
|
||||
"get": {
|
||||
"summary": "List customers",
|
||||
"tags": ["CRM"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "List of customers" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/sales/deals": {
|
||||
"get": {
|
||||
"summary": "List deals",
|
||||
"tags": ["Sales"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "List of deals" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/legal/contracts": {
|
||||
"get": {
|
||||
"summary": "List contracts",
|
||||
"tags": ["Legal"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "List of contracts" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/marketing/campaigns": {
|
||||
"get": {
|
||||
"summary": "List campaigns",
|
||||
"tags": ["Marketing"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "List of campaigns" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/support/tickets": {
|
||||
"get": {
|
||||
"summary": "List tickets",
|
||||
"tags": ["Support"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "List of tickets" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/analytics/dashboard": {
|
||||
"get": {
|
||||
"summary": "Get dashboard data",
|
||||
"tags": ["Analytics"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "Dashboard data" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/finance/balance": {
|
||||
"get": {
|
||||
"summary": "Get balance sheet",
|
||||
"tags": ["Finance"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "Balance sheet data" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/briefing/daily": {
|
||||
"get": {
|
||||
"summary": "Get daily briefing",
|
||||
"tags": ["Briefing"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": { "description": "Daily briefing data" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "RS256 JWT token from ouroboros-identity"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
)
|
||||
|
||||
// IMAPClient handles reading emails via IMAP
|
||||
type IMAPClient struct {
|
||||
server string
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
useTLS bool
|
||||
}
|
||||
|
||||
// EmailMessage represents an email in the inbox
|
||||
type EmailMessage struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Date time.Time `json:"date"`
|
||||
Body string `json:"body"`
|
||||
Preview string `json:"preview"`
|
||||
Read bool `json:"read"`
|
||||
Attachments int `json:"attachments"`
|
||||
}
|
||||
|
||||
// NewIMAPClient creates a new IMAP client
|
||||
func NewIMAPClient(server string, port int, username, password string) *IMAPClient {
|
||||
return &IMAPClient{
|
||||
server: server,
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
useTLS: port == 993,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes connection to IMAP server
|
||||
func (c *IMAPClient) Connect() (*client.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", c.server, c.port)
|
||||
|
||||
var cl *client.Client
|
||||
var err error
|
||||
|
||||
if c.useTLS {
|
||||
cl, err = client.DialTLS(addr, &tls.Config{InsecureSkipVerify: true})
|
||||
} else {
|
||||
cl, err = client.Dial(addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial imap: %w", err)
|
||||
}
|
||||
|
||||
if err := cl.Login(c.username, c.password); err != nil {
|
||||
cl.Logout()
|
||||
return nil, fmt.Errorf("imap login: %w", err)
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// ListMessages fetches emails from inbox
|
||||
func (c *IMAPClient) ListMessages(limit int) ([]EmailMessage, error) {
|
||||
cl, err := c.Connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cl.Logout()
|
||||
|
||||
// Select INBOX
|
||||
mbox, err := cl.Select("INBOX", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select inbox: %w", err)
|
||||
}
|
||||
|
||||
if mbox.Messages == 0 {
|
||||
return []EmailMessage{}, nil
|
||||
}
|
||||
|
||||
// Fetch last N messages
|
||||
from := uint32(1)
|
||||
if mbox.Messages > uint32(limit) {
|
||||
from = mbox.Messages - uint32(limit) + 1
|
||||
}
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddRange(from, mbox.Messages)
|
||||
|
||||
messages := make(chan *imap.Message, 10)
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
done <- cl.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchRFC822Text}, messages)
|
||||
}()
|
||||
|
||||
var result []EmailMessage
|
||||
for msg := range messages {
|
||||
email := EmailMessage{
|
||||
UID: msg.Uid,
|
||||
Subject: msg.Envelope.Subject,
|
||||
Date: msg.Envelope.Date,
|
||||
Read: !hasFlag(msg.Flags, imap.RecentFlag),
|
||||
}
|
||||
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
email.From = msg.Envelope.From[0].Address()
|
||||
}
|
||||
|
||||
for _, to := range msg.Envelope.To {
|
||||
email.To = append(email.To, to.Address())
|
||||
}
|
||||
|
||||
// Extract preview from body
|
||||
for _, literal := range msg.Body {
|
||||
if buf := make([]byte, 0); literal != nil {
|
||||
buf = make([]byte, literal.Len())
|
||||
n, _ := literal.Read(buf)
|
||||
if n > 0 {
|
||||
body := string(buf[:n])
|
||||
email.Body = body
|
||||
email.Preview = truncate(stripHTML(body), 200)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, email)
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return nil, fmt.Errorf("fetch messages: %w", err)
|
||||
}
|
||||
|
||||
// Reverse to show newest first
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMessage fetches a single email by UID
|
||||
func (c *IMAPClient) GetMessage(uid uint32) (*EmailMessage, error) {
|
||||
cl, err := c.Connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cl.Logout()
|
||||
|
||||
// Select INBOX
|
||||
if _, err := cl.Select("INBOX", false); err != nil {
|
||||
return nil, fmt.Errorf("select inbox: %w", err)
|
||||
}
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(uid)
|
||||
|
||||
messages := make(chan *imap.Message, 1)
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
done <- cl.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchRFC822Text}, messages)
|
||||
}()
|
||||
|
||||
var email *EmailMessage
|
||||
for msg := range messages {
|
||||
email = &EmailMessage{
|
||||
UID: msg.Uid,
|
||||
Subject: msg.Envelope.Subject,
|
||||
Date: msg.Envelope.Date,
|
||||
Read: !hasFlag(msg.Flags, imap.RecentFlag),
|
||||
}
|
||||
|
||||
if len(msg.Envelope.From) > 0 {
|
||||
email.From = msg.Envelope.From[0].Address()
|
||||
}
|
||||
|
||||
for _, to := range msg.Envelope.To {
|
||||
email.To = append(email.To, to.Address())
|
||||
}
|
||||
|
||||
for _, literal := range msg.Body {
|
||||
if literal != nil {
|
||||
buf := make([]byte, literal.Len())
|
||||
n, _ := literal.Read(buf)
|
||||
if n > 0 {
|
||||
body := string(buf[:n])
|
||||
email.Body = body
|
||||
email.Preview = truncate(stripHTML(body), 200)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return nil, fmt.Errorf("fetch message: %w", err)
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// MarkAsRead marks an email as read
|
||||
func (c *IMAPClient) MarkAsRead(uid uint32) error {
|
||||
cl, err := c.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cl.Logout()
|
||||
|
||||
if _, err := cl.Select("INBOX", false); err != nil {
|
||||
return fmt.Errorf("select inbox: %w", err)
|
||||
}
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(uid)
|
||||
|
||||
item := imap.FormatFlagsOp(imap.AddFlags, true)
|
||||
flags := []interface{}{imap.SeenFlag}
|
||||
|
||||
if err := cl.Store(seqset, item, flags, nil); err != nil {
|
||||
return fmt.Errorf("mark as read: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUnreadCount returns number of unread messages
|
||||
func (c *IMAPClient) GetUnreadCount() (int, error) {
|
||||
cl, err := c.Connect()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer cl.Logout()
|
||||
|
||||
mbox, err := cl.Select("INBOX", false)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("select inbox: %w", err)
|
||||
}
|
||||
|
||||
return int(mbox.Unseen), nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func hasFlag(flags []string, flag string) bool {
|
||||
for _, f := range flags {
|
||||
if f == flag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stripHTML(html string) string {
|
||||
// Simple HTML stripping
|
||||
result := html
|
||||
result = strings.ReplaceAll(result, "<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:]
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
result = strings.ReplaceAll(result, " ", " ")
|
||||
result = strings.ReplaceAll(result, "<", "<")
|
||||
result = strings.ReplaceAll(result, ">", ">")
|
||||
result = strings.ReplaceAll(result, "&", "&")
|
||||
result = strings.ReplaceAll(result, """, "\"")
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// ParseIMAPURL parses an IMAP URL like imaps://user:pass@server:993
|
||||
func ParseIMAPURL(imapURL string) (*IMAPClient, error) {
|
||||
u, err := url.Parse(imapURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
password, _ := u.User.Password()
|
||||
port := 993
|
||||
if u.Port() != "" {
|
||||
p, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port = p
|
||||
}
|
||||
|
||||
return NewIMAPClient(u.Hostname(), port, u.User.Username(), password), nil
|
||||
}
|
||||
+5
-6
@@ -4,14 +4,16 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/segmentio/kafka-go v0.4.47
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
)
|
||||
@@ -21,20 +23,17 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.24.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+16
-68
@@ -7,8 +7,6 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -16,10 +14,18 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -28,11 +34,10 @@ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+
|
||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -42,9 +47,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -63,82 +65,28 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0=
|
||||
github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
})
|
||||
}
|
||||
+30
-22
@@ -122,9 +122,20 @@ func main() {
|
||||
jwtService := auth.NewJWTService(cfg.JWTSecret, "boc-auth", "boc")
|
||||
_ = jwtService
|
||||
|
||||
// För utveckling: använd öppen auth
|
||||
authMiddleware := middleware.APIKeyAuth("")
|
||||
logger.Info().Msg("Development auth initialized (open access)")
|
||||
// Auth service med databas
|
||||
authService := auth.NewAuthService(database, cfg.JWTSecret, "boc-auth", "boc")
|
||||
|
||||
// Auth middleware - RS256 för produktion, HS256 för utveckling
|
||||
var authMiddleware func(http.Handler) http.Handler
|
||||
if cfg.Port == "9092" {
|
||||
// Utveckling: tillåt HS256
|
||||
authMiddleware = middleware.JWTAuthWithFallback(cfg.JWTSecret)
|
||||
logger.Info().Msg("Development auth initialized (HS256 + RS256)")
|
||||
} else {
|
||||
// Produktion: endast RS256
|
||||
authMiddleware = middleware.JWTAuth("http://localhost:3208/.well-known/jwks.json")
|
||||
logger.Info().Msg("Production auth initialized (RS256 only)")
|
||||
}
|
||||
|
||||
// Prometheus metrics
|
||||
requestDuration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
@@ -176,42 +187,38 @@ func main() {
|
||||
|
||||
// Auth endpoints (no auth required)
|
||||
r.Post("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
var req auth.LoginRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implement proper password verification against database
|
||||
// For now, reject all login attempts in production
|
||||
if cfg.Port != "9092" {
|
||||
http.Error(w, `{"error":"authentication service unavailable"}`, http.StatusServiceUnavailable)
|
||||
// Validera input
|
||||
if req.Email == "" || req.Password == "" {
|
||||
http.Error(w, `{"error":"email and password required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Development only - generate token without password check
|
||||
token, err := jwtService.GenerateToken("3847477b-3d56-4975-9157-ae8f9ce52aa7", req.Email, "admin")
|
||||
// Försök logga in
|
||||
resp, err := authService.Login(r.Context(), req)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
|
||||
// Generiskt felmeddelande för att inte avslöja om email finns
|
||||
http.Error(w, `{"error":"invalid email or password"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600, // 1 hour - reduced from 30 days
|
||||
"algorithm": "HS256",
|
||||
"token": resp.Token,
|
||||
"token_type": resp.TokenType,
|
||||
"expires_in": resp.ExpiresIn,
|
||||
"user": map[string]string{
|
||||
"id": "3847477b-3d56-4975-9157-ae8f9ce52aa7",
|
||||
"email": req.Email,
|
||||
"name": "Erik Svensson",
|
||||
"role": "admin",
|
||||
"id": resp.User.ID,
|
||||
"email": resp.User.Email,
|
||||
"name": resp.User.Name,
|
||||
"role": resp.User.Role,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -219,6 +226,7 @@ func main() {
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
r.Use(middleware.TenantIsolation)
|
||||
|
||||
// Auth me
|
||||
r.Get("/api/v1/auth/me", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import "context"
|
||||
|
||||
// contextKey är en privat typ för att undvika kollisioner
|
||||
type contextKey int
|
||||
|
||||
const claimsKey contextKey = iota
|
||||
|
||||
// FromContext hämtar claims från context
|
||||
func FromContext(ctx context.Context) (*Claims, bool) {
|
||||
claims, ok := ctx.Value(claimsKey).(*Claims)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
// WithContext lägger till claims i context
|
||||
func WithContext(ctx context.Context, claims *Claims) context.Context {
|
||||
return context.WithValue(ctx, claimsKey, claims)
|
||||
}
|
||||
@@ -242,3 +242,62 @@ func getStringClaim(claims jwt.MapClaims, key string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// JWTAuthWithFallback middleware för utveckling - stödjer både RS256 och HS256
|
||||
func JWTAuthWithFallback(jwtSecret string) func(http.Handler) http.Handler {
|
||||
validator := NewJWTValidator("http://localhost:3208/.well-known/jwks.json")
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
writeError(w, http.StatusUnauthorized, "invalid authorization header format")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Försök validera med RS256 först
|
||||
_, claims, err := validator.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
// Fallback: Tillåt HS256 tokens för utveckling
|
||||
token, parseErr := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); ok {
|
||||
return []byte(jwtSecret), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
})
|
||||
if parseErr != nil || !token.Valid {
|
||||
log.Warn().Err(err).Str("path", r.URL.Path).Msg("JWT validation failed")
|
||||
writeError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
claims = token.Claims.(jwt.MapClaims)
|
||||
}
|
||||
|
||||
// Extrahera claims
|
||||
userClaims := Claims{
|
||||
Sub: getStringClaim(claims, "sub"),
|
||||
Email: getStringClaim(claims, "email"),
|
||||
Name: getStringClaim(claims, "name"),
|
||||
}
|
||||
|
||||
// Hantera roles som kan vara []interface{}
|
||||
if roles, ok := claims["roles"].([]interface{}); ok {
|
||||
for _, r := range roles {
|
||||
if s, ok := r.(string); ok {
|
||||
userClaims.Roles = append(userClaims.Roles, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx := WithContext(r.Context(), &userClaims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"boc/auth"
|
||||
)
|
||||
|
||||
// RBAC middleware kontrollerar att användaren har minst en av de tillåtna rollerna
|
||||
func RBAC(allowedRoles ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Kontrollera om användaren har någon av de tillåtna rollerna
|
||||
hasRole := false
|
||||
for _, role := range allowedRoles {
|
||||
if claims.HasRole(role) {
|
||||
hasRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRole {
|
||||
writeError(w, http.StatusForbidden, "forbidden: insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly middleware - endast admin-roll tillåten
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return RBAC("admin")(next)
|
||||
}
|
||||
|
||||
// ManagerOrAdmin middleware - manager eller admin
|
||||
func ManagerOrAdmin(next http.Handler) http.Handler {
|
||||
return RBAC("admin", "manager")(next)
|
||||
}
|
||||
@@ -3,101 +3,41 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"boc/auth"
|
||||
)
|
||||
|
||||
// TenantContext key for storing tenant ID
|
||||
// TenantContextKey är nyckeln för tenant_id i context
|
||||
type TenantContextKey struct{}
|
||||
|
||||
// TenantConfig holds tenant configuration
|
||||
type TenantConfig struct {
|
||||
ID string
|
||||
Name string
|
||||
Slug string
|
||||
Domain string
|
||||
IsActive bool
|
||||
// GetTenantID hämtar tenant_id från användarens claims
|
||||
func GetTenantID(ctx context.Context) string {
|
||||
claims, ok := auth.FromContext(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return claims.OrgID
|
||||
}
|
||||
|
||||
// MultiTenancy middleware handles tenant identification and isolation
|
||||
func MultiTenancy(next http.Handler) http.Handler {
|
||||
// TenantIsolation middleware lägger till tenant_id i context
|
||||
func TenantIsolation(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract tenant from multiple sources (in priority order)
|
||||
tenantID := extractTenantID(r)
|
||||
|
||||
tenantID := GetTenantID(r.Context())
|
||||
if tenantID == "" {
|
||||
http.Error(w, `{"error":"tenant not identified"}`, http.StatusBadRequest)
|
||||
return
|
||||
// Om ingen tenant finns, använd default
|
||||
tenantID = "11111111-1111-1111-1111-111111111111"
|
||||
}
|
||||
|
||||
// Add tenant to context
|
||||
|
||||
ctx := context.WithValue(r.Context(), TenantContextKey{}, tenantID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// extractTenantID tries multiple methods to identify tenant
|
||||
func extractTenantID(r *http.Request) string {
|
||||
// 1. Header (for API clients)
|
||||
if tenantID := r.Header.Get("X-Tenant-ID"); tenantID != "" {
|
||||
return tenantID
|
||||
// GetTenantFromContext hämtar tenant_id från context
|
||||
func GetTenantFromContext(ctx context.Context) string {
|
||||
tenantID, ok := ctx.Value(TenantContextKey{}).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 2. Subdomain (e.g., landvex.boc.aamos.systems)
|
||||
host := r.Host
|
||||
if idx := strings.Index(host, "."); idx > 0 {
|
||||
subdomain := host[:idx]
|
||||
if subdomain != "www" && subdomain != "boc" {
|
||||
// Map subdomain to tenant ID
|
||||
return resolveSubdomain(subdomain)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Query parameter (for testing/debugging)
|
||||
if tenantID := r.URL.Query().Get("tenant"); tenantID != "" {
|
||||
return tenantID
|
||||
}
|
||||
|
||||
// 4. JWT token claim (if authenticated)
|
||||
// This would be handled by auth middleware
|
||||
|
||||
// 5. Default tenant (for backward compatibility)
|
||||
return "default"
|
||||
}
|
||||
|
||||
// resolveSubdomain maps subdomain to tenant ID
|
||||
func resolveSubdomain(subdomain string) string {
|
||||
// In production, this would query the database
|
||||
// For now, use a simple mapping
|
||||
subdomainMap := map[string]string{
|
||||
"landvex": "11111111-1111-1111-1111-111111111111",
|
||||
"landvex-ab": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
|
||||
"quixzoom": "quixzoom-tenant-id",
|
||||
"aamos": "aamos-tenant-id",
|
||||
}
|
||||
|
||||
if id, ok := subdomainMap[subdomain]; ok {
|
||||
return id
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetTenantID retrieves tenant ID from context
|
||||
func GetTenantID(ctx context.Context) string {
|
||||
if tenantID, ok := ctx.Value(TenantContextKey{}).(string); ok {
|
||||
return tenantID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TenantIsolation ensures all database queries are scoped to tenant
|
||||
func TenantIsolation(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := GetTenantID(r.Context())
|
||||
if tenantID == "" {
|
||||
http.Error(w, `{"error":"tenant isolation required"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
return tenantID
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user