diff --git a/agricultural-insurance-suite/cmd/server/main.go b/agricultural-insurance-suite/cmd/server/main.go new file mode 100644 index 000000000..8f0d5cccf --- /dev/null +++ b/agricultural-insurance-suite/cmd/server/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "agricultural-insurance-suite/internal/handlers" + "agricultural-insurance-suite/internal/repository" + "agricultural-insurance-suite/internal/service" + "encoding/json" + "log" + "net/http" + + "github.com/gorilla/mux" +) + +func main() { + repo := repository.NewRepository() + svc := service.NewService(repo) + h := handlers.NewHandler(svc) + + r := mux.NewRouter() + r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"status": "healthy", "service": "agricultural-insurance-suite"}) + }).Methods("GET") + r.HandleFunc("/ready", func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"status": "ready"}) + }).Methods("GET") + h.RegisterRoutes(r) + + log.Println("[agricultural-insurance-suite] starting on :8140 — 13 climate/agricultural products") + log.Fatal(http.ListenAndServe(":8140", r)) +} diff --git a/agricultural-insurance-suite/go.mod b/agricultural-insurance-suite/go.mod new file mode 100644 index 000000000..79a0d3cae --- /dev/null +++ b/agricultural-insurance-suite/go.mod @@ -0,0 +1,5 @@ +module agricultural-insurance-suite + +go 1.22.0 + +require github.com/gorilla/mux v1.8.1 diff --git a/agricultural-insurance-suite/go.sum b/agricultural-insurance-suite/go.sum new file mode 100644 index 000000000..712833743 --- /dev/null +++ b/agricultural-insurance-suite/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= diff --git a/agricultural-insurance-suite/internal/handlers/handlers.go b/agricultural-insurance-suite/internal/handlers/handlers.go new file mode 100644 index 000000000..dc2a80759 --- /dev/null +++ b/agricultural-insurance-suite/internal/handlers/handlers.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "agricultural-insurance-suite/internal/models" + "agricultural-insurance-suite/internal/service" + "encoding/json" + "net/http" + + "github.com/gorilla/mux" +) + +type Handler struct { + svc *service.Service +} + +func NewHandler(svc *service.Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(r *mux.Router) { + api := r.PathPrefix("/api/v1/agricultural").Subrouter() + api.HandleFunc("/products", h.listProducts).Methods("GET") + api.HandleFunc("/products/{id}", h.getProduct).Methods("GET") + api.HandleFunc("/products/category/{category}", h.getByCategory).Methods("GET") + api.HandleFunc("/enroll", h.enrollPolicy).Methods("POST") + api.HandleFunc("/policies", h.listPolicies).Methods("GET") + api.HandleFunc("/trigger/evaluate", h.evaluateTrigger).Methods("POST") + api.HandleFunc("/triggers", h.listTriggers).Methods("GET") + api.HandleFunc("/payouts", h.listPayouts).Methods("GET") + api.HandleFunc("/ndvi/assess", h.ndviAssess).Methods("POST") +} + +func (h *Handler) listProducts(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"products": h.svc.GetAllProducts(), "count": len(h.svc.GetAllProducts())}) +} + +func (h *Handler) getProduct(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + p := h.svc.GetProduct(id) + if p == nil { + http.Error(w, `{"error":"product not found"}`, 404) + return + } + json.NewEncoder(w).Encode(p) +} + +func (h *Handler) getByCategory(w http.ResponseWriter, r *http.Request) { + cat := mux.Vars(r)["category"] + products := h.svc.GetProductsByCategory(cat) + json.NewEncoder(w).Encode(map[string]interface{}{"category": cat, "products": products, "count": len(products)}) +} + +func (h *Handler) enrollPolicy(w http.ResponseWriter, r *http.Request) { + var req models.EnrollRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request body"}`, 400) + return + } + policy, err := h.svc.EnrollPolicy(req) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, 400) + return + } + w.WriteHeader(201) + json.NewEncoder(w).Encode(policy) +} + +func (h *Handler) listPolicies(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"policies": h.svc.GetAllPolicies()}) +} + +func (h *Handler) evaluateTrigger(w http.ResponseWriter, r *http.Request) { + var req models.TriggerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request body"}`, 400) + return + } + event, err := h.svc.EvaluateTrigger(req) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, 400) + return + } + json.NewEncoder(w).Encode(event) +} + +func (h *Handler) listTriggers(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"triggers": h.svc.GetAllTriggers()}) +} + +func (h *Handler) listPayouts(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"payouts": h.svc.GetAllPayouts()}) +} + +func (h *Handler) ndviAssess(w http.ResponseWriter, r *http.Request) { + var req struct { + Region string `json:"region"` + Value float64 `json:"ndvi_value"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, 400) + return + } + json.NewEncoder(w).Encode(h.svc.GetNDVIAssessment(req.Region, req.Value)) +} diff --git a/agricultural-insurance-suite/internal/models/models.go b/agricultural-insurance-suite/internal/models/models.go new file mode 100644 index 000000000..9bf9e3bca --- /dev/null +++ b/agricultural-insurance-suite/internal/models/models.go @@ -0,0 +1,122 @@ +package models + +import "time" + +type ProductType string + +const ( + ProductClimaCashRain ProductType = "climacash_rain" + ProductClimaCashDrought ProductType = "climacash_drought" + ProductClimaCashFlood ProductType = "climacash_flood" + ProductClimaCashHeat ProductType = "climacash_heat" + ProductWeatherIndexCrop ProductType = "weather_index_crop" + ProductLivestockIndex ProductType = "livestock_index_ibli" + ProductLivestockTakaful ProductType = "livestock_takaful_iblt" + ProductFertiliserBundled ProductType = "fertiliser_bundled" + ProductAreaYieldIndex ProductType = "area_yield_index" + ProductAquaculture ProductType = "aquaculture_fisheries" + ProductMultiPerilCrop ProductType = "multi_peril_crop" + ProductPastoralRoute ProductType = "pastoral_route" + ProductCarbonCredit ProductType = "carbon_credit" +) + +type TriggerType string + +const ( + TriggerRainfall TriggerType = "rainfall" + TriggerTemperature TriggerType = "temperature" + TriggerNDVI TriggerType = "ndvi_vegetation" + TriggerWindSpeed TriggerType = "wind_speed" + TriggerAreaYield TriggerType = "area_yield" + TriggerGPS TriggerType = "gps_movement" + TriggerCarbonFlux TriggerType = "carbon_flux" +) + +type Product struct { + ID string `json:"id"` + Name string `json:"name"` + Type ProductType `json:"type"` + Description string `json:"description"` + TriggerType TriggerType `json:"trigger_type"` + TriggerSource string `json:"trigger_source"` + ThresholdValue float64 `json:"threshold_value"` + ThresholdUnit string `json:"threshold_unit"` + PayoutAmount float64 `json:"payout_amount_ngn"` + PremiumAmount float64 `json:"premium_amount_ngn"` + CoverageRegions []string `json:"coverage_regions"` + CoveredAssets []string `json:"covered_assets"` + MaxPayoutNGN float64 `json:"max_payout_ngn"` + Season string `json:"season"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` +} + +type Policy struct { + ID string `json:"id"` + ProductID string `json:"product_id"` + CustomerID string `json:"customer_id"` + CustomerName string `json:"customer_name"` + Region string `json:"region"` + State string `json:"state"` + LGA string `json:"lga"` + Assets []Asset `json:"assets"` + PremiumPaid float64 `json:"premium_paid_ngn"` + CoverageStart time.Time `json:"coverage_start"` + CoverageEnd time.Time `json:"coverage_end"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type Asset struct { + Type string `json:"type"` + Quantity int `json:"quantity"` + Value float64 `json:"value_ngn"` +} + +type TriggerEvent struct { + ID string `json:"id"` + ProductType string `json:"product_type"` + TriggerType string `json:"trigger_type"` + Region string `json:"region"` + MeasuredValue float64 `json:"measured_value"` + Threshold float64 `json:"threshold"` + Triggered bool `json:"triggered"` + DataSource string `json:"data_source"` + Timestamp time.Time `json:"timestamp"` +} + +type ClaimPayout struct { + ID string `json:"id"` + PolicyID string `json:"policy_id"` + TriggerID string `json:"trigger_event_id"` + Amount float64 `json:"amount_ngn"` + Status string `json:"status"` + PayoutMethod string `json:"payout_method"` + ProcessedAt time.Time `json:"processed_at"` +} + +type NDVIReading struct { + Region string `json:"region"` + Value float64 `json:"ndvi_value"` + Percentile float64 `json:"percentile"` + Condition string `json:"condition"` + Timestamp time.Time `json:"timestamp"` +} + +type EnrollRequest struct { + ProductID string `json:"product_id"` + CustomerID string `json:"customer_id"` + CustomerName string `json:"customer_name"` + Region string `json:"region"` + State string `json:"state"` + LGA string `json:"lga"` + Assets []Asset `json:"assets"` +} + +type TriggerRequest struct { + ProductType string `json:"product_type"` + TriggerType string `json:"trigger_type"` + Region string `json:"region"` + MeasuredValue float64 `json:"measured_value"` + DataSource string `json:"data_source"` +} diff --git a/agricultural-insurance-suite/internal/repository/repository.go b/agricultural-insurance-suite/internal/repository/repository.go new file mode 100644 index 000000000..ac13f5111 --- /dev/null +++ b/agricultural-insurance-suite/internal/repository/repository.go @@ -0,0 +1,127 @@ +package repository + +import ( + "agricultural-insurance-suite/internal/models" + "sync" + "time" +) + +type Repository struct { + mu sync.RWMutex + products map[string]*models.Product + policies map[string]*models.Policy + triggers map[string]*models.TriggerEvent + payouts map[string]*models.ClaimPayout +} + +func NewRepository() *Repository { + r := &Repository{ + products: make(map[string]*models.Product), + policies: make(map[string]*models.Policy), + triggers: make(map[string]*models.TriggerEvent), + payouts: make(map[string]*models.ClaimPayout), + } + r.seedProducts() + return r +} + +func (r *Repository) seedProducts() { + products := []models.Product{ + {ID: "PROD-RAIN-001", Name: "ClimaCash RainCash", Type: models.ProductClimaCashRain, Description: "Parametric payout when rainfall exceeds threshold — protects farmers from flood damage", TriggerType: models.TriggerRainfall, TriggerSource: "NiMet", ThresholdValue: 255, ThresholdUnit: "mm/week", PayoutAmount: 50000, PremiumAmount: 2500, CoverageRegions: []string{"North-Central", "South-West", "South-South"}, CoveredAssets: []string{"crops", "farmland"}, MaxPayoutNGN: 200000, Season: "rainy", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-DROUGHT-001", Name: "ClimaCash DroughtCash", Type: models.ProductClimaCashDrought, Description: "Auto payout when rainfall drops below minimum for extended period", TriggerType: models.TriggerRainfall, TriggerSource: "NiMet", ThresholdValue: 20, ThresholdUnit: "mm/month", PayoutAmount: 75000, PremiumAmount: 3500, CoverageRegions: []string{"North-West", "North-East", "North-Central"}, CoveredAssets: []string{"crops", "livestock_feed"}, MaxPayoutNGN: 300000, Season: "dry", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-FLOOD-001", Name: "ClimaCash FloodCash", Type: models.ProductClimaCashFlood, Description: "Emergency cash for communities impacted by flooding events", TriggerType: models.TriggerRainfall, TriggerSource: "NiMet", ThresholdValue: 380, ThresholdUnit: "mm/week", PayoutAmount: 100000, PremiumAmount: 5000, CoverageRegions: []string{"South-South", "South-East", "North-Central"}, CoveredAssets: []string{"property", "crops", "livestock"}, MaxPayoutNGN: 500000, Season: "rainy", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-HEAT-001", Name: "ClimaCash HeatCash", Type: models.ProductClimaCashHeat, Description: "Payout when temperature exceeds dangerous threshold for livestock and crops", TriggerType: models.TriggerTemperature, TriggerSource: "NiMet", ThresholdValue: 42, ThresholdUnit: "celsius", PayoutAmount: 40000, PremiumAmount: 2000, CoverageRegions: []string{"North-East", "North-West"}, CoveredAssets: []string{"livestock", "crops"}, MaxPayoutNGN: 160000, Season: "harmattan", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-WICI-001", Name: "Weather Index Crop Insurance", Type: models.ProductWeatherIndexCrop, Description: "NiMet satellite rainfall data triggers automatic crop loss payouts — GIZ-EU VACE Programme", TriggerType: models.TriggerRainfall, TriggerSource: "NiMet-Satellite", ThresholdValue: 150, ThresholdUnit: "mm/season", PayoutAmount: 85000, PremiumAmount: 4200, CoverageRegions: []string{"Benue", "Niger", "Kaduna"}, CoveredAssets: []string{"maize", "rice", "sorghum", "millet", "cassava"}, MaxPayoutNGN: 350000, Season: "long_rains", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-IBLI-001", Name: "Index-Based Livestock Insurance", Type: models.ProductLivestockIndex, Description: "NDVI satellite monitors pasture — auto-payout below 20th percentile — Africa Re model", TriggerType: models.TriggerNDVI, TriggerSource: "NDVI-Satellite", ThresholdValue: 0.20, ThresholdUnit: "percentile", PayoutAmount: 120000, PremiumAmount: 6000, CoverageRegions: []string{"Sokoto", "Bauchi", "Adamawa", "Plateau"}, CoveredAssets: []string{"cattle", "camels", "sheep", "goats"}, MaxPayoutNGN: 500000, Season: "year_round", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-IBLT-001", Name: "Index-Based Livestock Takaful", Type: models.ProductLivestockTakaful, Description: "Sharia-compliant IBLI with Takaful mutual structure and NDVI triggers", TriggerType: models.TriggerNDVI, TriggerSource: "NDVI-Satellite", ThresholdValue: 0.20, ThresholdUnit: "percentile", PayoutAmount: 120000, PremiumAmount: 5500, CoverageRegions: []string{"Sokoto", "Zamfara", "Katsina", "Kano", "Borno"}, CoveredAssets: []string{"cattle", "camels", "sheep", "goats"}, MaxPayoutNGN: 500000, Season: "year_round", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-FERT-001", Name: "Fertiliser-Bundled Crop Insurance", Type: models.ProductFertiliserBundled, Description: "Auto coverage bundled with subsidised fertiliser purchase — zero-friction enrollment", TriggerType: models.TriggerRainfall, TriggerSource: "NiMet", ThresholdValue: 100, ThresholdUnit: "mm/season", PayoutAmount: 7000, PremiumAmount: 500, CoverageRegions: []string{"Trans-Nzoia", "Kakamega", "Kericho"}, CoveredAssets: []string{"fertiliser_investment", "crops"}, MaxPayoutNGN: 28000, Season: "long_rains", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-AYI-001", Name: "Area Yield Index Insurance", Type: models.ProductAreaYieldIndex, Description: "Payouts based on average area yield — lower basis risk than weather-only indices", TriggerType: models.TriggerAreaYield, TriggerSource: "NAIC-Nigeria", ThresholdValue: 70, ThresholdUnit: "pct_of_avg", PayoutAmount: 95000, PremiumAmount: 4800, CoverageRegions: []string{"Benue", "Niger", "Kaduna", "Kogi", "Taraba"}, CoveredAssets: []string{"maize", "rice", "yam", "cassava"}, MaxPayoutNGN: 400000, Season: "harvest", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-AQUA-001", Name: "Aquaculture & Fisheries Insurance", Type: models.ProductAquaculture, Description: "Protects fisherfolk against storms — wind speed + wave height triggers", TriggerType: models.TriggerWindSpeed, TriggerSource: "NiMet-Marine", ThresholdValue: 65, ThresholdUnit: "kmh", PayoutAmount: 80000, PremiumAmount: 4000, CoverageRegions: []string{"Lagos", "Rivers", "Bayelsa", "Cross-River", "Akwa-Ibom"}, CoveredAssets: []string{"fishing_boats", "nets", "catch", "aquaculture_ponds"}, MaxPayoutNGN: 350000, Season: "year_round", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-MPCI-001", Name: "Multi-Peril Crop Insurance", Type: models.ProductMultiPerilCrop, Description: "Hybrid parametric + indemnity combining weather index with named perils", TriggerType: models.TriggerRainfall, TriggerSource: "NiMet+FieldAssessors", ThresholdValue: 100, ThresholdUnit: "mm/season", PayoutAmount: 150000, PremiumAmount: 7500, CoverageRegions: []string{"All-Nigeria"}, CoveredAssets: []string{"all_crops"}, MaxPayoutNGN: 600000, Season: "year_round", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-PAST-001", Name: "Pastoral Migration Route Insurance", Type: models.ProductPastoralRoute, Description: "Insures pastoralist transhumance corridor movements with GPS + drought triggers", TriggerType: models.TriggerGPS, TriggerSource: "GPS+NDVI", ThresholdValue: 0.25, ThresholdUnit: "ndvi_along_route", PayoutAmount: 60000, PremiumAmount: 3000, CoverageRegions: []string{"Adamawa-Taraba-Benue-Corridor", "Sokoto-Zamfara-Corridor"}, CoveredAssets: []string{"cattle_in_transit", "herder_equipment"}, MaxPayoutNGN: 250000, Season: "migration", IsActive: true, CreatedAt: time.Now()}, + {ID: "PROD-CARB-001", Name: "Carbon Credit Insurance", Type: models.ProductCarbonCredit, Description: "Protects farmers carbon credit revenue from climate events reducing sequestration", TriggerType: models.TriggerCarbonFlux, TriggerSource: "Verra-Registry", ThresholdValue: 30, ThresholdUnit: "pct_reduction", PayoutAmount: 200000, PremiumAmount: 10000, CoverageRegions: []string{"All-Nigeria"}, CoveredAssets: []string{"carbon_credits", "agroforestry"}, MaxPayoutNGN: 800000, Season: "year_round", IsActive: true, CreatedAt: time.Now()}, + } + for i := range products { + r.products[products[i].ID] = &products[i] + } +} + +func (r *Repository) GetProducts() []models.Product { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]models.Product, 0, len(r.products)) + for _, p := range r.products { + result = append(result, *p) + } + return result +} + +func (r *Repository) GetProduct(id string) *models.Product { + r.mu.RLock() + defer r.mu.RUnlock() + if p, ok := r.products[id]; ok { + copy := *p + return © + } + return nil +} + +func (r *Repository) CreatePolicy(p *models.Policy) { + r.mu.Lock() + defer r.mu.Unlock() + r.policies[p.ID] = p +} + +func (r *Repository) GetPolicies() []models.Policy { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]models.Policy, 0, len(r.policies)) + for _, p := range r.policies { + result = append(result, *p) + } + return result +} + +func (r *Repository) GetPoliciesByProduct(productID string) []models.Policy { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Policy + for _, p := range r.policies { + if p.ProductID == productID { + result = append(result, *p) + } + } + return result +} + +func (r *Repository) RecordTrigger(t *models.TriggerEvent) { + r.mu.Lock() + defer r.mu.Unlock() + r.triggers[t.ID] = t +} + +func (r *Repository) GetTriggers() []models.TriggerEvent { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]models.TriggerEvent, 0, len(r.triggers)) + for _, t := range r.triggers { + result = append(result, *t) + } + return result +} + +func (r *Repository) RecordPayout(p *models.ClaimPayout) { + r.mu.Lock() + defer r.mu.Unlock() + r.payouts[p.ID] = p +} + +func (r *Repository) GetPayouts() []models.ClaimPayout { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]models.ClaimPayout, 0, len(r.payouts)) + for _, p := range r.payouts { + result = append(result, *p) + } + return result +} diff --git a/agricultural-insurance-suite/internal/service/service.go b/agricultural-insurance-suite/internal/service/service.go new file mode 100644 index 000000000..c4e259ffd --- /dev/null +++ b/agricultural-insurance-suite/internal/service/service.go @@ -0,0 +1,198 @@ +package service + +import ( + "agricultural-insurance-suite/internal/models" + "agricultural-insurance-suite/internal/repository" + "fmt" + "math" + "time" +) + +type Service struct { + repo *repository.Repository +} + +func NewService(repo *repository.Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetAllProducts() []models.Product { + return s.repo.GetProducts() +} + +func (s *Service) GetProduct(id string) *models.Product { + return s.repo.GetProduct(id) +} + +func (s *Service) GetProductsByCategory(category string) []models.Product { + all := s.repo.GetProducts() + var result []models.Product + for _, p := range all { + if matchCategory(p.Type, category) { + result = append(result, p) + } + } + return result +} + +func matchCategory(pt models.ProductType, cat string) bool { + m := map[string][]models.ProductType{ + "climacash": {models.ProductClimaCashRain, models.ProductClimaCashDrought, models.ProductClimaCashFlood, models.ProductClimaCashHeat}, + "crop": {models.ProductWeatherIndexCrop, models.ProductAreaYieldIndex, models.ProductMultiPerilCrop, models.ProductFertiliserBundled}, + "livestock": {models.ProductLivestockIndex, models.ProductLivestockTakaful, models.ProductPastoralRoute}, + "marine": {models.ProductAquaculture}, + "carbon": {models.ProductCarbonCredit}, + } + for _, t := range m[cat] { + if pt == t { + return true + } + } + return false +} + +func (s *Service) EnrollPolicy(req models.EnrollRequest) (*models.Policy, error) { + product := s.repo.GetProduct(req.ProductID) + if product == nil { + return nil, fmt.Errorf("product not found: %s", req.ProductID) + } + if !product.IsActive { + return nil, fmt.Errorf("product %s is not currently active", req.ProductID) + } + totalVal := 0.0 + for _, a := range req.Assets { + totalVal += a.Value * float64(a.Quantity) + } + premium := calculatePremium(product, totalVal, req.Region) + policy := &models.Policy{ + ID: fmt.Sprintf("POL-%d", time.Now().UnixNano()%1000000000), + ProductID: req.ProductID, + CustomerID: req.CustomerID, + CustomerName: req.CustomerName, + Region: req.Region, + State: req.State, + LGA: req.LGA, + Assets: req.Assets, + PremiumPaid: premium, + CoverageStart: time.Now(), + CoverageEnd: time.Now().Add(365 * 24 * time.Hour), + Status: "active", + CreatedAt: time.Now(), + } + s.repo.CreatePolicy(policy) + return policy, nil +} + +func calculatePremium(product *models.Product, assetValue float64, region string) float64 { + base := product.PremiumAmount + mult := 1.0 + switch region { + case "North-East", "North-West": + mult = 1.3 + case "South-South": + mult = 1.2 + case "North-Central": + mult = 1.1 + } + af := math.Min(assetValue/1000000.0, 3.0) + return base * mult * (1 + af*0.1) +} + +func (s *Service) EvaluateTrigger(req models.TriggerRequest) (*models.TriggerEvent, error) { + products := s.repo.GetProducts() + var matched *models.Product + for i, p := range products { + if string(p.Type) == req.ProductType { + matched = &products[i] + break + } + } + if matched == nil { + return nil, fmt.Errorf("no product for type: %s", req.ProductType) + } + triggered := false + switch models.TriggerType(req.TriggerType) { + case models.TriggerRainfall: + if matched.Type == models.ProductClimaCashDrought { + triggered = req.MeasuredValue < matched.ThresholdValue + } else { + triggered = req.MeasuredValue > matched.ThresholdValue + } + case models.TriggerTemperature: + triggered = req.MeasuredValue > matched.ThresholdValue + case models.TriggerNDVI: + triggered = req.MeasuredValue < matched.ThresholdValue + case models.TriggerWindSpeed: + triggered = req.MeasuredValue > matched.ThresholdValue + case models.TriggerAreaYield: + triggered = req.MeasuredValue < matched.ThresholdValue + case models.TriggerCarbonFlux: + triggered = req.MeasuredValue > matched.ThresholdValue + } + event := &models.TriggerEvent{ + ID: fmt.Sprintf("TRG-%d", time.Now().UnixNano()%1000000000), + ProductType: req.ProductType, + TriggerType: req.TriggerType, + Region: req.Region, + MeasuredValue: req.MeasuredValue, + Threshold: matched.ThresholdValue, + Triggered: triggered, + DataSource: req.DataSource, + Timestamp: time.Now(), + } + s.repo.RecordTrigger(event) + if triggered { + policies := s.repo.GetPoliciesByProduct(matched.ID) + for _, pol := range policies { + if pol.Region == req.Region && pol.Status == "active" { + payout := &models.ClaimPayout{ + ID: fmt.Sprintf("PAY-%d", time.Now().UnixNano()%1000000000), + PolicyID: pol.ID, + TriggerID: event.ID, + Amount: matched.PayoutAmount, + Status: "pending_disbursement", + PayoutMethod: "mobile_money", + ProcessedAt: time.Now(), + } + s.repo.RecordPayout(payout) + } + } + } + return event, nil +} + +func (s *Service) GetNDVIAssessment(region string, ndviValue float64) *models.NDVIReading { + pct := ndviToPercentile(ndviValue) + cond := "normal" + if pct < 20 { + cond = "severe_drought" + } else if pct < 35 { + cond = "drought_stress" + } else if pct < 50 { + cond = "below_normal" + } else if pct >= 70 { + cond = "above_normal" + } + return &models.NDVIReading{Region: region, Value: ndviValue, Percentile: pct, Condition: cond, Timestamp: time.Now()} +} + +func ndviToPercentile(v float64) float64 { + if v <= 0.1 { + return 5 + } else if v <= 0.2 { + return 15 + } else if v <= 0.3 { + return 30 + } else if v <= 0.4 { + return 50 + } else if v <= 0.5 { + return 65 + } else if v <= 0.6 { + return 78 + } + return 90 +} + +func (s *Service) GetAllPolicies() []models.Policy { return s.repo.GetPolicies() } +func (s *Service) GetAllTriggers() []models.TriggerEvent { return s.repo.GetTriggers() } +func (s *Service) GetAllPayouts() []models.ClaimPayout { return s.repo.GetPayouts() } diff --git a/customer-portal-full/.env.example b/customer-portal-full/.env.example new file mode 100644 index 000000000..c764316cd --- /dev/null +++ b/customer-portal-full/.env.example @@ -0,0 +1,188 @@ +# ============================================================================ +# Unified Insurance Platform — Environment Variables Reference +# Copy this file to .env and fill in your values +# ============================================================================ + +# ── Application ────────────────────────────────────────────────────────────── +NODE_ENV=production +PORT=5000 +APP_NAME=unified-insurance-platform +APP_URL=https://insurance.example.com + +# ── Authentication & OAuth ──────────────────────────────────────────────────── +VITE_OAUTH_PORTAL_URL=https://auth.insurance.example.com +VITE_APP_ID=unified-insurance-platform +OAUTH_SERVER_URL=https://auth.insurance.example.com +JWT_SECRET= +JWT_EXPIRY=24h +SESSION_SECRET= + +# ── Database ────────────────────────────────────────────────────────────────── +DATABASE_URL=postgresql://insurance_user:password@localhost:5432/insurance_db +DATABASE_POOL_MIN=2 +DATABASE_POOL_MAX=20 +DATABASE_SSL=true +# Read replica (for analytics queries) +DATABASE_READ_REPLICA_URL=postgresql://insurance_user:password@replica:5432/insurance_db + +# ── Redis Cache ─────────────────────────────────────────────────────────────── +REDIS_URL=redis://localhost:6379 +REDIS_PASSWORD= +REDIS_TLS=true +CACHE_TTL_SECONDS=300 + +# ── Core Microservice URLs ──────────────────────────────────────────────────── +POLICY_SERVICE_URL=http://policy-service:8081 +CLAIM_SERVICE_URL=http://claims-adjudication:8082 +PAYMENT_SERVICE_URL=http://payment-service:8083 +CUSTOMER_SERVICE_URL=http://customer-360-service:8084 +VERIFICATION_SERVICE_URL=http://kyc-orchestrator:8085 +TELCO_SERVICE_URL=http://telco-integration:8010 +FRAUD_DATABASE_URL=http://fraud-detection:8020 + +# ── Extended Microservice URLs ──────────────────────────────────────────────── +ACTUARIAL_SERVICE_URL=http://actuarial-module:8091 +BANCASSURANCE_SERVICE_URL=http://bancassurance-integration:8092 +GROUP_LIFE_SERVICE_URL=http://group-life-admin:8093 +NMID_SERVICE_URL=http://nmid-integration:8094 +PFA_SERVICE_URL=http://pfa-integration:8095 +REINSURANCE_SERVICE_URL=http://reinsurance-management:8096 +KYC_SERVICE_URL=http://enhanced-kyc-kyb:8097 +ANALYTICS_SERVICE_URL=http://analytics-service:8098 +GEOSPATIAL_SERVICE_URL=http://geospatial-service:8099 +COMMUNICATION_SERVICE_URL=http://communication-service:8100 +DOCUMENT_SERVICE_URL=http://document-management:8101 +UNDERWRITING_SERVICE_URL=http://underwriting-service:8102 +ERPNEXT_SERVICE_URL=http://erpnext-integration:8103 +OPENIMIS_SERVICE_URL=http://openimis-integration:8104 +ETHERISC_SERVICE_URL=http://etherisc-gif:8105 +MOJALOOP_SERVICE_URL=http://mojaloop-integration:8106 +GDPR_SERVICE_URL=http://gdpr-compliance:8107 +USSD_SERVICE_URL=http://ussd-gateway:8108 + +# ── AI / LLM ────────────────────────────────────────────────────────────────── +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o +AI_ADVISOR_ENABLED=true +FRAUD_AI_MODEL_ENDPOINT=http://ray-serve:8000/fraud-detection +CHURN_AI_MODEL_ENDPOINT=http://ray-serve:8000/churn-prediction +UNDERWRITING_AI_MODEL_ENDPOINT=http://ray-serve:8000/underwriting-risk + +# ── Payment Gateways ────────────────────────────────────────────────────────── +PAYSTACK_SECRET_KEY=sk_live_ +PAYSTACK_PUBLIC_KEY=pk_live_ +FLUTTERWAVE_SECRET_KEY=FLWSECK_ +FLUTTERWAVE_PUBLIC_KEY=FLWPUBK_ +INTERSWITCH_CLIENT_ID= +INTERSWITCH_CLIENT_SECRET= +REMITA_MERCHANT_ID= +REMITA_API_KEY= + +# ── Nigerian Telcos (for credit scoring) ───────────────────────────────────── +MTN_API_KEY= +MTN_API_SECRET= +AIRTEL_API_KEY= +AIRTEL_API_SECRET= +GLO_API_KEY= +NINE_MOBILE_API_KEY= + +# ── NAICOM & Regulatory ─────────────────────────────────────────────────────── +NAICOM_API_KEY= +NAICOM_API_URL=https://api.naicom.gov.ng +NMID_API_KEY= +NMID_API_URL=https://api.nmid.gov.ng +NIN_VERIFICATION_API_KEY= +NIN_API_URL=https://api.nimc.gov.ng +BVN_VERIFICATION_API_KEY= +BVN_API_URL=https://api.nibss-plc.org.ng +CAC_API_KEY= + +# ── SMS / Email / Push Notifications ───────────────────────────────────────── +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_PHONE_NUMBER=+1234567890 +TERMII_API_KEY= +SENDGRID_API_KEY=SG. +EMAIL_FROM=noreply@insurance.example.com +FIREBASE_SERVER_KEY= +FIREBASE_PROJECT_ID= + +# ── WhatsApp Business API ───────────────────────────────────────────────────── +WHATSAPP_API_URL=https://graph.facebook.com/v18.0 +WHATSAPP_PHONE_NUMBER_ID= +WHATSAPP_ACCESS_TOKEN= +WHATSAPP_VERIFY_TOKEN= + +# ── Storage ─────────────────────────────────────────────────────────────────── +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=af-south-1 +S3_BUCKET_DOCUMENTS=insurance-documents-prod +S3_BUCKET_CLAIMS=insurance-claims-prod +S3_BUCKET_BACKUPS=insurance-backups-prod +# Or use MinIO for on-premise +MINIO_ENDPOINT=http://minio:9000 +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + +# ── Observability ───────────────────────────────────────────────────────────── +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 +OTEL_SERVICE_NAME=customer-portal +JAEGER_ENDPOINT=http://jaeger:14268/api/traces +PROMETHEUS_METRICS_PORT=9090 +LOG_LEVEL=info +LOG_FORMAT=json + +# ── Security ────────────────────────────────────────────────────────────────── +VAULT_ADDR=http://vault:8200 +VAULT_TOKEN= +VAULT_ROLE=insurance-platform +ENCRYPTION_KEY= +CORS_ORIGINS=https://insurance.example.com,https://admin.insurance.example.com +RATE_LIMIT_MAX=100 +RATE_LIMIT_WINDOW_MS=60000 + +# ── Feature Flags (Unleash) ─────────────────────────────────────────────────── +UNLEASH_URL=http://unleash:4242/api +UNLEASH_API_TOKEN= +UNLEASH_APP_NAME=insurance-platform +UNLEASH_ENVIRONMENT=production + +# ── ERPNext Integration ─────────────────────────────────────────────────────── +ERPNEXT_URL=https://erp.insurance.example.com +ERPNEXT_API_KEY= +ERPNEXT_API_SECRET= + +# ── OpenIMIS Integration ────────────────────────────────────────────────────── +OPENIMIS_URL=https://openimis.insurance.example.com +OPENIMIS_USERNAME= +OPENIMIS_PASSWORD= + +# ── Etherisc Parametric Insurance ──────────────────────────────────────────── +ETHERISC_API_KEY= +ETHERISC_PRODUCT_ID= +CHAINLINK_NODE_URL=http://chainlink-node:6688 +WEATHER_API_KEY= + +# ── Mojaloop Payments ───────────────────────────────────────────────────────── +MOJALOOP_HUB_URL=https://mojaloop.insurance.example.com +MOJALOOP_DFSP_ID= +MOJALOOP_JWS_KEY= + +# ── Analytics ───────────────────────────────────────────────────────────────── +VITE_ANALYTICS_ENDPOINT=https://analytics.insurance.example.com +VITE_ANALYTICS_WEBSITE_ID= +APACHE_PINOT_URL=http://pinot-broker:8099 +APACHE_ICEBERG_CATALOG_URL=http://iceberg-rest:8181 + +# ── Geospatial ──────────────────────────────────────────────────────────────── +GOOGLE_MAPS_API_KEY= +MAPBOX_ACCESS_TOKEN= + +# ── Owner / Admin ───────────────────────────────────────────────────────────── +OWNER_OPEN_ID=admin +ADMIN_EMAIL=admin@insurance.example.com + +# ── Internal API ───────────────────────────────────────────────────────────── +BUILT_IN_FORGE_API_URL=http://localhost:8080 +BUILT_IN_FORGE_API_KEY= diff --git a/customer-portal-full/.gitignore b/customer-portal-full/.gitignore new file mode 100644 index 000000000..c1dbd8b34 --- /dev/null +++ b/customer-portal-full/.gitignore @@ -0,0 +1,107 @@ +# Dependencies +**/node_modules +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock +*.bak + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Database +*.db +*.sqlite +*.sqlite3 diff --git a/customer-portal-full/.gitkeep b/customer-portal-full/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/customer-portal-full/.prettierignore b/customer-portal-full/.prettierignore new file mode 100644 index 000000000..72842592f --- /dev/null +++ b/customer-portal-full/.prettierignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Generated files +*.tsbuildinfo +coverage/ + +# Package files +package-lock.json +pnpm-lock.yaml + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# Environment files +.env* + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db diff --git a/customer-portal-full/.prettierrc b/customer-portal-full/.prettierrc new file mode 100644 index 000000000..67c0bc83c --- /dev/null +++ b/customer-portal-full/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "proseWrap": "preserve" +} diff --git a/customer-portal-full/Dockerfile b/customer-portal-full/Dockerfile new file mode 100644 index 000000000..9a8d30930 --- /dev/null +++ b/customer-portal-full/Dockerfile @@ -0,0 +1,52 @@ +# Multi-stage build for customer portal + +# Stage 1: Build frontend +FROM node:22-alpine AS frontend-builder +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ +RUN npm install -g pnpm && pnpm install --frozen-lockfile + +# Copy source code +COPY . . + +# Build frontend +RUN pnpm build + +# Stage 2: Build backend +FROM node:22-alpine AS backend-builder +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ +RUN npm install -g pnpm && pnpm install --frozen-lockfile --prod + +# Stage 3: Production image +FROM node:22-alpine +WORKDIR /app + +# Install production dependencies +RUN npm install -g pnpm + +# Copy package files +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +# Copy built frontend from frontend-builder +COPY --from=frontend-builder /app/dist ./dist + +# Copy server code +COPY server ./server +COPY shared ./shared +COPY drizzle ./drizzle + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \ + CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# Start server +CMD ["node", "server/_core/index.js"] diff --git a/customer-portal-full/client/index.html b/customer-portal-full/client/index.html new file mode 100644 index 000000000..350f76c79 --- /dev/null +++ b/customer-portal-full/client/index.html @@ -0,0 +1,51 @@ + + + + + + + Unified Insurance Platform + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/customer-portal-full/client/public/.gitkeep b/customer-portal-full/client/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/customer-portal-full/client/public/__manus__/debug-collector.js b/customer-portal-full/client/public/__manus__/debug-collector.js new file mode 100644 index 000000000..050455560 --- /dev/null +++ b/customer-portal-full/client/public/__manus__/debug-collector.js @@ -0,0 +1,821 @@ +/** + * Manus Debug Collector (agent-friendly) + * + * Captures: + * 1) Console logs + * 2) Network requests (fetch + XHR) + * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.) + * + * Data is periodically sent to /__manus__/logs + * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log + */ +(function () { + "use strict"; + + // Prevent double initialization + if (window.__MANUS_DEBUG_COLLECTOR__) return; + + // ========================================================================== + // Configuration + // ========================================================================== + const CONFIG = { + reportEndpoint: "/__manus__/logs", + bufferSize: { + console: 500, + network: 200, + // semantic, agent-friendly UI events + ui: 500, + }, + reportInterval: 2000, + sensitiveFields: [ + "password", + "token", + "secret", + "key", + "authorization", + "cookie", + "session", + ], + maxBodyLength: 10240, + // UI event logging privacy policy: + // - inputs matching sensitiveFields or type=password are masked by default + // - non-sensitive inputs log up to 200 chars + uiInputMaxLen: 200, + uiTextMaxLen: 80, + // Scroll throttling: minimum ms between scroll events + scrollThrottleMs: 500, + }; + + // ========================================================================== + // Storage + // ========================================================================== + const store = { + consoleLogs: [], + networkRequests: [], + uiEvents: [], + lastReportTime: Date.now(), + lastScrollTime: 0, + }; + + // ========================================================================== + // Utility Functions + // ========================================================================== + + function sanitizeValue(value, depth) { + if (depth === void 0) depth = 0; + if (depth > 5) return "[Max Depth]"; + if (value === null) return null; + if (value === undefined) return undefined; + + if (typeof value === "string") { + return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value; + } + + if (typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.slice(0, 100).map(function (v) { + return sanitizeValue(v, depth + 1); + }); + } + + var sanitized = {}; + for (var k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + var isSensitive = CONFIG.sensitiveFields.some(function (f) { + return k.toLowerCase().indexOf(f) !== -1; + }); + if (isSensitive) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeValue(value[k], depth + 1); + } + } + } + return sanitized; + } + + function formatArg(arg) { + try { + if (arg instanceof Error) { + return { type: "Error", message: arg.message, stack: arg.stack }; + } + if (typeof arg === "object") return sanitizeValue(arg); + return String(arg); + } catch (e) { + return "[Unserializable]"; + } + } + + function formatArgs(args) { + var result = []; + for (var i = 0; i < args.length; i++) result.push(formatArg(args[i])); + return result; + } + + function pruneBuffer(buffer, maxSize) { + if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize); + } + + function tryParseJson(str) { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch (e) { + return str; + } + } + + // ========================================================================== + // Semantic UI Event Logging (agent-friendly) + // ========================================================================== + + function shouldIgnoreTarget(target) { + try { + if (!target || !(target instanceof Element)) return false; + return !!target.closest(".manus-no-record"); + } catch (e) { + return false; + } + } + + function compactText(s, maxLen) { + try { + var t = (s || "").trim().replace(/\s+/g, " "); + if (!t) return ""; + return t.length > maxLen ? t.slice(0, maxLen) + "…" : t; + } catch (e) { + return ""; + } + } + + function elText(el) { + try { + var t = el.innerText || el.textContent || ""; + return compactText(t, CONFIG.uiTextMaxLen); + } catch (e) { + return ""; + } + } + + function describeElement(el) { + if (!el || !(el instanceof Element)) return null; + + var getAttr = function (name) { + return el.getAttribute(name); + }; + + var tag = el.tagName ? el.tagName.toLowerCase() : null; + var id = el.id || null; + var name = getAttr("name") || null; + var role = getAttr("role") || null; + var ariaLabel = getAttr("aria-label") || null; + + var dataLoc = getAttr("data-loc") || null; + var testId = + getAttr("data-testid") || + getAttr("data-test-id") || + getAttr("data-test") || + null; + + var type = tag === "input" ? (getAttr("type") || "text") : null; + var href = tag === "a" ? getAttr("href") || null : null; + + // a small, stable hint for agents (avoid building full CSS paths) + var selectorHint = null; + if (testId) selectorHint = '[data-testid="' + testId + '"]'; + else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]'; + else if (id) selectorHint = "#" + id; + else selectorHint = tag || "unknown"; + + return { + tag: tag, + id: id, + name: name, + type: type, + role: role, + ariaLabel: ariaLabel, + testId: testId, + dataLoc: dataLoc, + href: href, + text: elText(el), + selectorHint: selectorHint, + }; + } + + function isSensitiveField(el) { + if (!el || !(el instanceof Element)) return false; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea") return false; + + var type = (el.getAttribute("type") || "").toLowerCase(); + if (type === "password") return true; + + var name = (el.getAttribute("name") || "").toLowerCase(); + var id = (el.id || "").toLowerCase(); + + return CONFIG.sensitiveFields.some(function (f) { + return name.indexOf(f) !== -1 || id.indexOf(f) !== -1; + }); + } + + function getInputValueSafe(el) { + if (!el || !(el instanceof Element)) return null; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea" && tag !== "select") return null; + + var v = ""; + try { + v = el.value != null ? String(el.value) : ""; + } catch (e) { + v = ""; + } + + if (isSensitiveField(el)) return { masked: true, length: v.length }; + + if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…"; + return v; + } + + function logUiEvent(kind, payload) { + var entry = { + timestamp: Date.now(), + kind: kind, + url: location.href, + viewport: { width: window.innerWidth, height: window.innerHeight }, + payload: sanitizeValue(payload), + }; + store.uiEvents.push(entry); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + } + + function installUiEventListeners() { + // Clicks + document.addEventListener( + "click", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("click", { + target: describeElement(t), + x: e.clientX, + y: e.clientY, + }); + }, + true + ); + + // Typing "commit" events + document.addEventListener( + "change", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("change", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + document.addEventListener( + "focusin", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusin", { target: describeElement(t) }); + }, + true + ); + + document.addEventListener( + "focusout", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusout", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + // Enter/Escape are useful for form flows & modals + document.addEventListener( + "keydown", + function (e) { + if (e.key !== "Enter" && e.key !== "Escape") return; + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("keydown", { key: e.key, target: describeElement(t) }); + }, + true + ); + + // Form submissions + document.addEventListener( + "submit", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("submit", { target: describeElement(t) }); + }, + true + ); + + // Throttled scroll events + window.addEventListener( + "scroll", + function () { + var now = Date.now(); + if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return; + store.lastScrollTime = now; + + logUiEvent("scroll", { + scrollX: window.scrollX, + scrollY: window.scrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + }, + { passive: true } + ); + + // Navigation tracking for SPAs + function nav(reason) { + logUiEvent("navigate", { reason: reason }); + } + + var origPush = history.pushState; + history.pushState = function () { + origPush.apply(this, arguments); + nav("pushState"); + }; + + var origReplace = history.replaceState; + history.replaceState = function () { + origReplace.apply(this, arguments); + nav("replaceState"); + }; + + window.addEventListener("popstate", function () { + nav("popstate"); + }); + window.addEventListener("hashchange", function () { + nav("hashchange"); + }); + } + + // ========================================================================== + // Console Interception + // ========================================================================== + + var originalConsole = { + log: console.log.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + ["log", "debug", "info", "warn", "error"].forEach(function (method) { + console[method] = function () { + var args = Array.prototype.slice.call(arguments); + + var entry = { + timestamp: Date.now(), + level: method.toUpperCase(), + args: formatArgs(args), + stack: method === "error" ? new Error().stack : null, + }; + + store.consoleLogs.push(entry); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", function (event) { + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UncaughtError", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }, + ], + stack: event.error ? event.error.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + // Mark an error moment in UI event stream for agents + logUiEvent("error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + window.addEventListener("unhandledrejection", function (event) { + var reason = event.reason; + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UnhandledRejection", + reason: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : null, + }, + ], + stack: reason && reason.stack ? reason.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + logUiEvent("unhandledrejection", { + reason: reason && reason.message ? reason.message : String(reason), + }); + }); + + // ========================================================================== + // Fetch Interception + // ========================================================================== + + var originalFetch = window.fetch.bind(window); + + window.fetch = function (input, init) { + init = init || {}; + var startTime = Date.now(); + // Handle string, Request object, or URL object + var url = typeof input === "string" + ? input + : (input && (input.url || input.href || String(input))) || ""; + var method = init.method || (input && input.method) || "GET"; + + // Don't intercept internal requests + if (url.indexOf("/__manus__/") === 0) { + return originalFetch(input, init); + } + + // Safely parse headers (avoid breaking if headers format is invalid) + var requestHeaders = {}; + try { + if (init.headers) { + requestHeaders = Object.fromEntries(new Headers(init.headers).entries()); + } + } catch (e) { + requestHeaders = { _parseError: true }; + } + + var entry = { + timestamp: startTime, + type: "fetch", + method: method.toUpperCase(), + url: url, + request: { + headers: requestHeaders, + body: init.body ? sanitizeValue(tryParseJson(init.body)) : null, + }, + response: null, + duration: null, + error: null, + }; + + return originalFetch(input, init) + .then(function (response) { + entry.duration = Date.now() - startTime; + + var contentType = (response.headers.get("content-type") || "").toLowerCase(); + var contentLength = response.headers.get("content-length"); + + entry.response = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: null, + }; + + // Semantic network hint for agents on failures (sync, no need to wait for body) + if (response.status >= 400) { + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + status: response.status, + statusText: response.statusText, + }); + } + + // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + if (isStreaming) { + entry.response.body = "[Streaming response - not captured]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for large responses to avoid memory issues + if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) { + entry.response.body = "[Response too large: " + contentLength + " bytes]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + if (isBinary) { + entry.response.body = "[Binary content: " + contentType + "]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // For text responses, clone and read body in background + var clonedResponse = response.clone(); + + // Async: read body in background, don't block the response + clonedResponse + .text() + .then(function (text) { + if (text.length <= CONFIG.maxBodyLength) { + entry.response.body = sanitizeValue(tryParseJson(text)); + } else { + entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } + }) + .catch(function () { + entry.response.body = "[Unable to read body]"; + }) + .finally(function () { + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + }); + + // Return response immediately, don't wait for body reading + return response; + }) + .catch(function (error) { + entry.duration = Date.now() - startTime; + entry.error = { message: error.message, stack: error.stack }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + message: error.message, + }); + + throw error; + }); + }; + + // ========================================================================== + // XHR Interception + // ========================================================================== + + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._manusData = { + method: (method || "GET").toUpperCase(), + url: url, + startTime: null, + }; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + + if ( + xhr._manusData && + xhr._manusData.url && + xhr._manusData.url.indexOf("/__manus__/") !== 0 + ) { + xhr._manusData.startTime = Date.now(); + xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null; + + xhr.addEventListener("load", function () { + var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase(); + var responseBody = null; + + // Skip body capture for streaming responses + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + + if (isStreaming) { + responseBody = "[Streaming response - not captured]"; + } else if (isBinary) { + responseBody = "[Binary content: " + contentType + "]"; + } else { + // Safe to read responseText for text responses + try { + var text = xhr.responseText || ""; + if (text.length > CONFIG.maxBodyLength) { + responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } else { + responseBody = sanitizeValue(tryParseJson(text)); + } + } catch (e) { + // responseText may throw for non-text responses + responseBody = "[Unable to read response: " + e.message + "]"; + } + } + + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: { + status: xhr.status, + statusText: xhr.statusText, + body: responseBody, + }, + duration: Date.now() - xhr._manusData.startTime, + error: null, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + if (entry.response && entry.response.status >= 400) { + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + status: entry.response.status, + statusText: entry.response.statusText, + }); + } + }); + + xhr.addEventListener("error", function () { + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: null, + duration: Date.now() - xhr._manusData.startTime, + error: { message: "Network error" }, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + message: "Network error", + }); + }); + } + + return originalXHRSend.apply(this, arguments); + }; + + // ========================================================================== + // Data Reporting + // ========================================================================== + + function reportLogs() { + var consoleLogs = store.consoleLogs.splice(0); + var networkRequests = store.networkRequests.splice(0); + var uiEvents = store.uiEvents.splice(0); + + // Skip if no new data + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return Promise.resolve(); + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + // agent-friendly semantic events + uiEvents: uiEvents, + }; + + return originalFetch(CONFIG.reportEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(function () { + // Put data back on failure (but respect limits) + store.consoleLogs = consoleLogs.concat(store.consoleLogs); + store.networkRequests = networkRequests.concat(store.networkRequests); + store.uiEvents = uiEvents.concat(store.uiEvents); + + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + }); + } + + // Periodic reporting + setInterval(reportLogs, CONFIG.reportInterval); + + // Report on page unload + window.addEventListener("beforeunload", function () { + var consoleLogs = store.consoleLogs; + var networkRequests = store.networkRequests; + var uiEvents = store.uiEvents; + + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return; + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + uiEvents: uiEvents, + }; + + if (navigator.sendBeacon) { + var payloadStr = JSON.stringify(payload); + // sendBeacon has ~64KB limit, truncate if too large + var MAX_BEACON_SIZE = 60000; // Leave some margin + if (payloadStr.length > MAX_BEACON_SIZE) { + // Prioritize: keep recent events, drop older logs + var truncatedPayload = { + timestamp: Date.now(), + consoleLogs: consoleLogs.slice(-50), + networkRequests: networkRequests.slice(-20), + sessionEvents: uiEvents.slice(-100), + uiEvents: uiEvents.slice(-100), + _truncated: true, + }; + payloadStr = JSON.stringify(truncatedPayload); + } + navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr); + } + }); + + // ========================================================================== + // Initialization + // ========================================================================== + + // Install semantic UI listeners ASAP + try { + installUiEventListeners(); + } catch (e) { + console.warn("[Manus] Failed to install UI listeners:", e); + } + + // Mark as initialized + window.__MANUS_DEBUG_COLLECTOR__ = { + version: "2.0-no-rrweb", + store: store, + forceReport: reportLogs, + }; + + console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)"); +})(); diff --git a/customer-portal-full/client/public/icons/icon-128x128.png b/customer-portal-full/client/public/icons/icon-128x128.png new file mode 100644 index 000000000..0ad3a0c39 Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-128x128.png differ diff --git a/customer-portal-full/client/public/icons/icon-144x144.png b/customer-portal-full/client/public/icons/icon-144x144.png new file mode 100644 index 000000000..f2f1ffde5 Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-144x144.png differ diff --git a/customer-portal-full/client/public/icons/icon-152x152.png b/customer-portal-full/client/public/icons/icon-152x152.png new file mode 100644 index 000000000..c65ab3c53 Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-152x152.png differ diff --git a/customer-portal-full/client/public/icons/icon-192x192.png b/customer-portal-full/client/public/icons/icon-192x192.png new file mode 100644 index 000000000..92e7202fa Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-192x192.png differ diff --git a/customer-portal-full/client/public/icons/icon-384x384.png b/customer-portal-full/client/public/icons/icon-384x384.png new file mode 100644 index 000000000..cc0ba03df Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-384x384.png differ diff --git a/customer-portal-full/client/public/icons/icon-512x512.png b/customer-portal-full/client/public/icons/icon-512x512.png new file mode 100644 index 000000000..c3a7f91b5 Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-512x512.png differ diff --git a/customer-portal-full/client/public/icons/icon-72x72.png b/customer-portal-full/client/public/icons/icon-72x72.png new file mode 100644 index 000000000..134b9ec2c Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-72x72.png differ diff --git a/customer-portal-full/client/public/icons/icon-96x96.png b/customer-portal-full/client/public/icons/icon-96x96.png new file mode 100644 index 000000000..45483c277 Binary files /dev/null and b/customer-portal-full/client/public/icons/icon-96x96.png differ diff --git a/customer-portal-full/client/public/manifest.json b/customer-portal-full/client/public/manifest.json new file mode 100644 index 000000000..a13e1d387 --- /dev/null +++ b/customer-portal-full/client/public/manifest.json @@ -0,0 +1,85 @@ +{ + "name": "Unified Insurance Platform", + "short_name": "InsurePlatform", + "description": "End-to-end unified insurance management platform for all stakeholders", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#3b82f6", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ], + "categories": ["finance", "business", "productivity"], + "screenshots": [], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View your insurance dashboard", + "url": "/dashboard", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Claims", + "short_name": "Claims", + "description": "Manage insurance claims", + "url": "/claims", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Policies", + "short_name": "Policies", + "description": "View your policies", + "url": "/policies", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + } + ] +} diff --git a/customer-portal-full/client/public/offline.html b/customer-portal-full/client/public/offline.html new file mode 100644 index 000000000..aebe89fa7 --- /dev/null +++ b/customer-portal-full/client/public/offline.html @@ -0,0 +1,45 @@ + + + + + + Offline - Unified Insurance Platform + + + +
+
🛡️
+

You're Offline

+

The Unified Insurance Platform requires an internet connection. Please check your network and try again.

+ +
+ + diff --git a/customer-portal-full/client/public/sw.js b/customer-portal-full/client/public/sw.js new file mode 100644 index 000000000..bc83c12f7 --- /dev/null +++ b/customer-portal-full/client/public/sw.js @@ -0,0 +1,114 @@ +// Unified Insurance Platform - Service Worker +const CACHE_NAME = 'uip-v1'; +const OFFLINE_URL = '/offline.html'; + +const PRECACHE_ASSETS = [ + '/', + '/manifest.json', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(PRECACHE_ASSETS); + }).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests and API calls (always network-first for APIs) + if (request.method !== 'GET') return; + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/trpc/')) return; + + // For navigation requests, use network-first with cache fallback + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }) + .catch(() => caches.match('/') || caches.match(OFFLINE_URL)) + ); + return; + } + + // For static assets, use cache-first strategy + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request).then((response) => { + if (response.ok && response.type === 'basic') { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }); + }) + ); +}); + +// Background sync for offline form submissions +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-claims') { + event.waitUntil(syncPendingClaims()); + } + if (event.tag === 'sync-payments') { + event.waitUntil(syncPendingPayments()); + } +}); + +async function syncPendingClaims() { + // Sync any offline-queued claims when connectivity is restored + const clients = await self.clients.matchAll(); + clients.forEach((client) => client.postMessage({ type: 'SYNC_CLAIMS' })); +} + +async function syncPendingPayments() { + const clients = await self.clients.matchAll(); + clients.forEach((client) => client.postMessage({ type: 'SYNC_PAYMENTS' })); +} + +// Push notifications +self.addEventListener('push', (event) => { + if (!event.data) return; + const data = event.data.json(); + event.waitUntil( + self.registration.showNotification(data.title || 'Insurance Platform', { + body: data.body || 'You have a new notification', + icon: '/icons/icon-192x192.png', + badge: '/icons/icon-96x96.png', + data: { url: data.url || '/' }, + actions: [ + { action: 'view', title: 'View' }, + { action: 'dismiss', title: 'Dismiss' }, + ], + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + if (event.action === 'view' || !event.action) { + const url = event.notification.data?.url || '/'; + event.waitUntil(clients.openWindow(url)); + } +}); diff --git a/customer-portal-full/client/src/App.tsx b/customer-portal-full/client/src/App.tsx new file mode 100644 index 000000000..ec72f4557 --- /dev/null +++ b/customer-portal-full/client/src/App.tsx @@ -0,0 +1,731 @@ +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import NotFound from "@/pages/NotFound"; +import { Route, Switch, Link } from "wouter"; +import { Button } from "@/components/ui/button"; +import { Shield } from "lucide-react"; +import ErrorBoundary from "./components/ErrorBoundary"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import { RoleProvider } from "./contexts/RoleContext"; +import UnifiedLayout from "./components/UnifiedLayout"; +import Home from "./pages/Home"; +import Dashboard from "./pages/Dashboard"; +import Policies from "./pages/Policies"; +import Claims from "./pages/Claims"; +import Payments from "./pages/Payments"; +import Profile from "./pages/Profile"; +import Referrals from "./pages/Referrals"; +import Reviews from "./pages/Reviews"; +import KYCStatus from "./pages/KYCStatus"; +import BlockchainStatus from "./pages/BlockchainStatus"; +import FraudAlerts from "./pages/FraudAlerts"; +import Analytics from "./pages/Analytics"; +import Communication from "./pages/Communication"; +import UserManagement from "./pages/UserManagement"; +import SystemSettings from "./pages/SystemSettings"; +import RiskAssessment from "./pages/RiskAssessment"; +import PolicyApproval from "./pages/PolicyApproval"; +import CustomerManagement from "./pages/CustomerManagement"; +import Commission from "./pages/Commission"; +import AuditLogs from "./pages/AuditLogs"; +import InsuranceProducts from "./pages/InsuranceProducts"; +import InsuranceApplication from "./pages/InsuranceApplication"; +import MyApplications from "./pages/MyApplications"; +import Auth from "./pages/Auth"; +import AIAdvisor from "./pages/AIAdvisor"; +import AIClaimsAdjudication from "./pages/AIClaimsAdjudication"; +import DynamicPricing from "./pages/DynamicPricing"; +import ComplianceMonitoring from "./pages/ComplianceMonitoring"; +import Onboarding from "./pages/Onboarding"; +import PolicyComparison from "./pages/PolicyComparison"; +import FamilyPolicies from "./pages/FamilyPolicies"; +import WhatsAppIntegration from "./pages/WhatsAppIntegration"; +import DocumentScanner from "./pages/DocumentScanner"; +import ExecutiveDashboard from "./pages/ExecutiveDashboard"; +import Telematics from "./pages/Telematics"; +import GeospatialMap from "./pages/GeospatialMap"; +import AdminPolicyCreation from "./pages/AdminPolicyCreation"; +import AgriculturalUnderwriting from "./pages/AgriculturalUnderwriting"; +import BrokerAPIManagement from "./pages/BrokerAPIManagement"; +import Gamification from "./pages/Gamification"; +import TwoFactorAuth from "./pages/TwoFactorAuth"; +import InsuranceMarketplace from "./pages/InsuranceMarketplace"; +import Chatbot from "./pages/Chatbot"; +import ReferralProgram from "./pages/ReferralProgram"; +import AgentPerformance from "./pages/AgentPerformance"; +import KnowledgeGraphExplorer from "./pages/KnowledgeGraphExplorer"; +import AIKnowledgeAssistant from "./pages/AIKnowledgeAssistant"; +import FraudNetworkVisualization from "./pages/FraudNetworkVisualization"; +import MCMCRiskModeling from "./pages/MCMCRiskModeling"; +import VoiceAssistant from "./pages/VoiceAssistant"; +import ChurnPrediction from "./pages/ChurnPrediction"; +import LoyaltyProgram from "./pages/LoyaltyProgram"; +import InsuranceLiteracyHub from "./pages/InsuranceLiteracyHub"; +import SmartClaimRouting from "./pages/SmartClaimRouting"; +import ProductRecommendationQuiz from "./pages/ProductRecommendationQuiz"; +import PremiumCalculator from "./pages/PremiumCalculator"; +import InsuranceScore from "./pages/InsuranceScore"; +import ClaimsTimeline from "./pages/ClaimsTimeline"; +import EmergencySOS from "./pages/EmergencySOS"; +import DigitalWallet from "./pages/DigitalWallet"; +import PremiumRateManagement from "./pages/PremiumRateManagement"; +import ERPNextIntegration from "./pages/ERPNextIntegration"; +import TelcoCreditScoring from "./pages/TelcoCreditScoring"; +import Microinsurance from "./pages/Microinsurance"; +import ModelSecurityDashboard from "./pages/ModelSecurityDashboard"; +import ClaimsEvidence from "./pages/ClaimsEvidence"; +import PolicyRenewal from "./pages/PolicyRenewal"; +import FamilyCoverage from "./pages/FamilyCoverage"; +import ClaimsTracker from "./pages/ClaimsTracker"; +import HealthWellness from "./pages/HealthWellness"; +import EmbeddedInsurance from "./pages/EmbeddedInsurance"; +import SavingsInvestment from "./pages/SavingsInvestment"; +import P2PInsurance from "./pages/P2PInsurance"; +import ParametricInsurance from "./pages/ParametricInsurance"; +import Bancassurance from "./pages/Bancassurance"; +import GigEconomy from "./pages/GigEconomy"; +import SMEBusiness from "./pages/SMEBusiness"; +import LoyaltyRewards from "./pages/LoyaltyRewards"; +import FinancialWellness from "./pages/FinancialWellness"; +import ReinsuranceManagement from "./pages/ReinsuranceManagement"; +import OperationalReports from "./pages/OperationalReports"; +import NAICOMCompliance from "./pages/NAICOMCompliance"; +import AuditTrailSystem from "./pages/AuditTrailSystem"; +import ClaimsAdjudicationEngine from "./pages/ClaimsAdjudicationEngine"; +import PolicyRenewalAutomation from "./pages/PolicyRenewalAutomation"; +import AgentCommissionManagement from "./pages/AgentCommissionManagement"; +import BatchProcessingEngine from "./pages/BatchProcessingEngine"; +import Customer360View from "./pages/Customer360View"; +import DocumentManagementSystem from "./pages/DocumentManagementSystem"; +import CustomerFeedbackLoop from "./pages/CustomerFeedbackLoop"; +import MultiCurrencySupport from "./pages/MultiCurrencySupport"; +import NigerianBankIntegrations from "./pages/NigerianBankIntegrations"; +import ReconciliationEngine from "./pages/ReconciliationEngine"; +import DisasterRecoveryModule from "./pages/DisasterRecoveryModule"; +import ABTestingFramework from "./pages/ABTestingFramework"; +import PerformanceMonitoringDashboard from "./pages/PerformanceMonitoringDashboard"; +import InsuranceRadar from "./pages/InsuranceRadar"; +import PostgreSQLScaling from "./pages/PostgreSQLScaling"; +import USSDGateway from "./pages/USSDGateway"; +import NMIDIntegration from "./pages/NMIDIntegration"; +import ActuarialModule from "./pages/ActuarialModule"; +import AgentPortal from "./pages/AgentPortal"; +import BancassurancePortal from "./pages/BancassurancePortal"; +import GroupLifeAdmin from "./pages/GroupLifeAdmin"; +import PFAIntegration from "./pages/PFAIntegration"; +import AgriculturalInsuranceSuite from "./pages/AgriculturalInsuranceSuite"; +import EmbeddedDistributionPlatform from "./pages/EmbeddedDistributionPlatform"; +import DigitalConsumerProducts from "./pages/DigitalConsumerProducts"; +import TakafulProductsSuite from "./pages/TakafulProductsSuite"; +import NIIRACompulsoryInsurance from "./pages/NIIRACompulsoryInsurance"; +import InsuranceTechInnovations from "./pages/InsuranceTechInnovations"; + +function Router() { + return ( + + + + {/* Public routes - accessible without login */} + +
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +function App() { + return ( + + + + + + + + + + + ); +} + +export default App; diff --git a/customer-portal-full/client/src/_core/hooks/useAuth.ts b/customer-portal-full/client/src/_core/hooks/useAuth.ts new file mode 100644 index 000000000..dcef9bd84 --- /dev/null +++ b/customer-portal-full/client/src/_core/hooks/useAuth.ts @@ -0,0 +1,84 @@ +import { getLoginUrl } from "@/const"; +import { trpc } from "@/lib/trpc"; +import { TRPCClientError } from "@trpc/client"; +import { useCallback, useEffect, useMemo } from "react"; + +type UseAuthOptions = { + redirectOnUnauthenticated?: boolean; + redirectPath?: string; +}; + +export function useAuth(options?: UseAuthOptions) { + const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } = + options ?? {}; + const utils = trpc.useUtils(); + + const meQuery = trpc.auth.me.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + + const logoutMutation = trpc.auth.logout.useMutation({ + onSuccess: () => { + utils.auth.me.setData(undefined, null); + }, + }); + + const logout = useCallback(async () => { + try { + await logoutMutation.mutateAsync(); + } catch (error: unknown) { + if ( + error instanceof TRPCClientError && + error.data?.code === "UNAUTHORIZED" + ) { + return; + } + throw error; + } finally { + utils.auth.me.setData(undefined, null); + await utils.auth.me.invalidate(); + } + }, [logoutMutation, utils]); + + const state = useMemo(() => { + localStorage.setItem( + "manus-runtime-user-info", + JSON.stringify(meQuery.data) + ); + return { + user: meQuery.data ?? null, + loading: meQuery.isLoading || logoutMutation.isPending, + error: meQuery.error ?? logoutMutation.error ?? null, + isAuthenticated: Boolean(meQuery.data), + }; + }, [ + meQuery.data, + meQuery.error, + meQuery.isLoading, + logoutMutation.error, + logoutMutation.isPending, + ]); + + useEffect(() => { + if (!redirectOnUnauthenticated) return; + if (meQuery.isLoading || logoutMutation.isPending) return; + if (state.user) return; + if (typeof window === "undefined") return; + if (window.location.pathname === redirectPath) return; + + window.location.href = redirectPath + }, [ + redirectOnUnauthenticated, + redirectPath, + logoutMutation.isPending, + meQuery.isLoading, + state.user, + ]); + + return { + ...state, + refresh: () => meQuery.refetch(), + logout, + }; +} diff --git a/customer-portal-full/client/src/components/AIChatBox.tsx b/customer-portal-full/client/src/components/AIChatBox.tsx new file mode 100644 index 000000000..1c00871fc --- /dev/null +++ b/customer-portal-full/client/src/components/AIChatBox.tsx @@ -0,0 +1,335 @@ +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Loader2, Send, User, Sparkles } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { Streamdown } from "streamdown"; + +/** + * Message type matching server-side LLM Message interface + */ +export type Message = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export type AIChatBoxProps = { + /** + * Messages array to display in the chat. + * Should match the format used by invokeLLM on the server. + */ + messages: Message[]; + + /** + * Callback when user sends a message. + * Typically you'll call a tRPC mutation here to invoke the LLM. + */ + onSendMessage: (content: string) => void; + + /** + * Whether the AI is currently generating a response + */ + isLoading?: boolean; + + /** + * Placeholder text for the input field + */ + placeholder?: string; + + /** + * Custom className for the container + */ + className?: string; + + /** + * Height of the chat box (default: 600px) + */ + height?: string | number; + + /** + * Empty state message to display when no messages + */ + emptyStateMessage?: string; + + /** + * Suggested prompts to display in empty state + * Click to send directly + */ + suggestedPrompts?: string[]; +}; + +/** + * A ready-to-use AI chat box component that integrates with the LLM system. + * + * Features: + * - Matches server-side Message interface for seamless integration + * - Markdown rendering with Streamdown + * - Auto-scrolls to latest message + * - Loading states + * - Uses global theme colors from index.css + * + * @example + * ```tsx + * const ChatPage = () => { + * const [messages, setMessages] = useState([ + * { role: "system", content: "You are a helpful assistant." } + * ]); + * + * const chatMutation = trpc.ai.chat.useMutation({ + * onSuccess: (response) => { + * // Assuming your tRPC endpoint returns the AI response as a string + * setMessages(prev => [...prev, { + * role: "assistant", + * content: response + * }]); + * }, + * onError: (error) => { + * console.error("Chat error:", error); + * // Optionally show error message to user + * } + * }); + * + * const handleSend = (content: string) => { + * const newMessages = [...messages, { role: "user", content }]; + * setMessages(newMessages); + * chatMutation.mutate({ messages: newMessages }); + * }; + * + * return ( + * + * ); + * }; + * ``` + */ +export function AIChatBox({ + messages, + onSendMessage, + isLoading = false, + placeholder = "Type your message...", + className, + height = "600px", + emptyStateMessage = "Start a conversation with AI", + suggestedPrompts, +}: AIChatBoxProps) { + const [input, setInput] = useState(""); + const scrollAreaRef = useRef(null); + const containerRef = useRef(null); + const inputAreaRef = useRef(null); + const textareaRef = useRef(null); + + // Filter out system messages + const displayMessages = messages.filter((msg) => msg.role !== "system"); + + // Calculate min-height for last assistant message to push user message to top + const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0); + + useEffect(() => { + if (containerRef.current && inputAreaRef.current) { + const containerHeight = containerRef.current.offsetHeight; + const inputHeight = inputAreaRef.current.offsetHeight; + const scrollAreaHeight = containerHeight - inputHeight; + + // Reserve space for: + // - padding (p-4 = 32px top+bottom) + // - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px + // Note: margin-bottom is not counted because it naturally pushes the assistant message down + const userMessageReservedHeight = 56; + const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight; + + setMinHeightForLastMessage(Math.max(0, calculatedHeight)); + } + }, []); + + // Scroll to bottom helper function with smooth animation + const scrollToBottom = () => { + const viewport = scrollAreaRef.current?.querySelector( + '[data-radix-scroll-area-viewport]' + ) as HTMLDivElement; + + if (viewport) { + requestAnimationFrame(() => { + viewport.scrollTo({ + top: viewport.scrollHeight, + behavior: 'smooth' + }); + }); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedInput = input.trim(); + if (!trimmedInput || isLoading) return; + + onSendMessage(trimmedInput); + setInput(""); + + // Scroll immediately after sending + scrollToBottom(); + + // Keep focus on input + textareaRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+ {/* Messages Area */} +
+ {displayMessages.length === 0 ? ( +
+
+
+ +

{emptyStateMessage}

+
+ + {suggestedPrompts && suggestedPrompts.length > 0 && ( +
+ {suggestedPrompts.map((prompt, index) => ( + + ))} +
+ )} +
+
+ ) : ( + +
+ {displayMessages.map((message, index) => { + // Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it) + const isLastMessage = index === displayMessages.length - 1; + const shouldApplyMinHeight = + isLastMessage && !isLoading && minHeightForLastMessage > 0; + + return ( +
+ {message.role === "assistant" && ( +
+ +
+ )} + +
+ {message.role === "assistant" ? ( +
+ {message.content} +
+ ) : ( +

+ {message.content} +

+ )} +
+ + {message.role === "user" && ( +
+ +
+ )} +
+ ); + })} + + {isLoading && ( +
0 + ? { minHeight: `${minHeightForLastMessage}px` } + : undefined + } + > +
+ +
+
+ +
+
+ )} +
+
+ )} +
+ + {/* Input Area */} +
+