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:
Bernt
2026-08-10 12:52:48 +00:00
parent 8921fd1467
commit 78b57273e2
141 changed files with 29192 additions and 180 deletions
+10
View File
@@ -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
+62
View File
@@ -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
}
+194
View File
@@ -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())
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
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!
+225
View File
@@ -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());
+33
View File
@@ -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>
+188
View File
@@ -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"
}
}
}
}
+322
View File
@@ -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, "&nbsp;", " ")
result = strings.ReplaceAll(result, "&lt;", "<")
result = strings.ReplaceAll(result, "&gt;", ">")
result = strings.ReplaceAll(result, "&amp;", "&")
result = strings.ReplaceAll(result, "&quot;", "\"")
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
View File
@@ -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
View File
@@ -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=
+241
View File
@@ -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,
})
}
+190
View File
@@ -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
}
+484
View File
@@ -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
}
+285
View File
@@ -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 -2
View File
@@ -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
+82
View File
@@ -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,
})
}
+247
View File
@@ -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, &notes); 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
}
+224
View File
@@ -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,
})
}
+206
View File
@@ -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,
})
}
+253
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
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
}
+322
View File
@@ -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,
})
}
+335
View File
@@ -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,
})
}
+332
View File
@@ -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
}
+425
View File
@@ -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
}
+298
View File
@@ -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",
}
}
+128
View File
@@ -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.",
})
}
+267
View File
@@ -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,
})
}
+116
View File
@@ -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.",
})
}
+171
View File
@@ -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, &currency, &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
}
+293
View File
@@ -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
View File
@@ -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) {
+19
View File
@@ -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)
}
+59
View File
@@ -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))
})
}
}
+46
View File
@@ -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)
}
+22 -82
View File
@@ -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
}