Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@ func InitDB() (*gorm.DB, error) {
&models.WatchlistItem{},
// User activity model (#161)
&models.UserActivity{},
// Email template models (#163)
&models.EmailTemplate{},
&models.EmailTemplateVersion{},
&models.EmailTemplateVariant{},
// Asset comparison history (#167)
&models.ComparisonHistory{},
// Performance metrics models (#169)
&models.AssetDividend{},
&models.PerformanceMetric{},
// Referral program models (#170)
&models.ReferralCode{},
&models.Referral{},
&models.ReferralReward{},
); err != nil {
return nil, fmt.Errorf("failed to auto-migrate models: %w", err)
}
Expand Down
133 changes: 126 additions & 7 deletions backend/handlers/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import (

// AnalyticsHandler handles analytics and reporting endpoints.
type AnalyticsHandler struct {
DB *gorm.DB
ValuationTracker *services.ValuationTrackerService
DB *gorm.DB
ValuationTracker *services.ValuationTrackerService
MetricsCalculator *services.MetricsCalculatorService
}

// NewAnalyticsHandler creates an AnalyticsHandler.
func NewAnalyticsHandler(db *gorm.DB, vt *services.ValuationTrackerService) *AnalyticsHandler {
return &AnalyticsHandler{DB: db, ValuationTracker: vt}
return &AnalyticsHandler{
DB: db,
ValuationTracker: vt,
MetricsCalculator: services.NewMetricsCalculatorService(db),
}
}

// PlatformSummary holds aggregated platform metrics.
Expand Down Expand Up @@ -71,7 +76,7 @@ func (h *AnalyticsHandler) GetUserGrowth(c *gin.Context) {
// RecordValuation stores a new valuation snapshot for an asset.
// POST /api/v1/assets/:id/valuations
func (h *AnalyticsHandler) RecordValuation(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
Expand Down Expand Up @@ -100,7 +105,7 @@ func (h *AnalyticsHandler) RecordValuation(c *gin.Context) {
// GetValuationHistory returns paginated valuation snapshots for an asset.
// GET /api/v1/assets/:id/valuations?page=1&limit=50
func (h *AnalyticsHandler) GetValuationHistory(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
Expand Down Expand Up @@ -137,7 +142,7 @@ func (h *AnalyticsHandler) GetValuationHistory(c *gin.Context) {
// GetValuationTrend returns aggregated valuation trend data for charting.
// GET /api/v1/assets/:id/valuations/trend?from=2024-01-01&to=2024-12-31&granularity=daily
func (h *AnalyticsHandler) GetValuationTrend(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
Expand Down Expand Up @@ -183,7 +188,7 @@ func (h *AnalyticsHandler) GetValuationTrend(c *gin.Context) {
// GetLatestValuation returns the most recent valuation for an asset.
// GET /api/v1/assets/:id/valuations/latest
func (h *AnalyticsHandler) GetLatestValuation(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
Expand All @@ -197,3 +202,117 @@ func (h *AnalyticsHandler) GetLatestValuation(c *gin.Context) {

c.JSON(http.StatusOK, gin.H{"success": true, "data": v})
}

// parsePeriod resolves optional from/to query params (YYYY-MM-DD), defaulting to
// a trailing 12-month window.
func parsePeriod(c *gin.Context) (time.Time, time.Time, error) {
to := time.Now().UTC()
from := to.AddDate(-1, 0, 0)
if s := c.Query("from"); s != "" {
parsed, err := time.Parse("2006-01-02", s)
if err != nil {
return from, to, err
}
from = parsed
}
if s := c.Query("to"); s != "" {
parsed, err := time.Parse("2006-01-02", s)
if err != nil {
return from, to, err
}
to = parsed.Add(24*time.Hour - time.Second)
}
return from, to, nil
}

// GetPerformanceMetrics computes ROI, appreciation rate, dividend yield and
// related indicators for an asset over an optional date range (#169).
// GET /api/v1/assets/:id/performance?from=YYYY-MM-DD&to=YYYY-MM-DD
func (h *AnalyticsHandler) GetPerformanceMetrics(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
}
from, to, err := parsePeriod(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date (use YYYY-MM-DD)"})
return
}
metric, err := h.MetricsCalculator.Calculate(uint(assetID), from, to)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": metric})
}

// GetPerformanceHistory returns stored performance snapshots for an asset (#169).
// GET /api/v1/assets/:id/performance/history?limit=90
func (h *AnalyticsHandler) GetPerformanceHistory(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "90"))
metrics, err := h.MetricsCalculator.History(uint(assetID), limit)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch performance history"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": metrics})
}

// RecalculatePerformance computes and stores a fresh performance snapshot for an
// asset (admin-triggered) (#169).
// POST /api/v1/assets/:id/performance/recalculate
func (h *AnalyticsHandler) RecalculatePerformance(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
}
from, to, err := parsePeriod(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date (use YYYY-MM-DD)"})
return
}
metric, err := h.MetricsCalculator.CalculateAndStore(uint(assetID), from, to)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"success": true, "data": metric})
}

// RecordDividend records a dividend distribution for an asset, used by the
// dividend-yield calculation (#169).
// POST /api/v1/assets/:id/dividends
func (h *AnalyticsHandler) RecordDividend(c *gin.Context) {
assetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid asset id"})
return
}
var req struct {
AmountUSD float64 `json:"amount_usd" binding:"required,gt=0"`
Currency string `json:"currency"`
Note string `json:"note"`
PaidAt *time.Time `json:"paid_at"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
paidAt := time.Time{}
if req.PaidAt != nil {
paidAt = *req.PaidAt
}
dividend, err := h.MetricsCalculator.RecordDividend(uint(assetID), req.AmountUSD, req.Currency, req.Note, paidAt)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"success": true, "data": dividend})
}
1 change: 1 addition & 0 deletions backend/handlers/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
Expand Down
83 changes: 83 additions & 0 deletions backend/handlers/comparison.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package handlers

import (
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/yourusername/kor-assetforge/services"
"gorm.io/gorm"
)

// ComparisonHandler exposes endpoints for side-by-side comparison of multiple
// assets, with comparison history (#167).
type ComparisonHandler struct {
Service *services.ComparisonService
}

// NewComparisonHandler creates a ComparisonHandler.
func NewComparisonHandler(db *gorm.DB) *ComparisonHandler {
return &ComparisonHandler{Service: services.NewComparisonService(db)}
}

// Compare compares 2-10 assets side by side. When the caller is authenticated and
// save=true, the comparison is persisted to their history.
// POST /api/v1/comparisons
func (h *ComparisonHandler) Compare(c *gin.Context) {
var req struct {
AssetIDs []uint `json:"asset_ids" binding:"required"`
Criteria []string `json:"criteria"`
Save bool `json:"save"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

result, err := h.Service.Compare(req.AssetIDs, req.Criteria)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

if req.Save {
if userID := currentUserID(c); userID != 0 {
if _, err := h.Service.SaveHistory(userID, req.AssetIDs, result.Criteria); err != nil {
// History persistence is best-effort; don't fail the comparison.
c.Header("X-Comparison-History", "save-failed")
}
}
}

c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}

// ListHistory returns the authenticated user's saved comparisons.
// GET /api/v1/comparisons/history
func (h *ComparisonHandler) ListHistory(c *gin.Context) {
userID := currentUserID(c)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
records, err := h.Service.ListHistory(userID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch comparison history"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": records})
}

// GetHistory re-runs a saved comparison and returns fresh results.
// GET /api/v1/comparisons/history/:id
func (h *ComparisonHandler) GetHistory(c *gin.Context) {
userID := currentUserID(c)
historyID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid history id"})
return
}
record, result, err := h.Service.GetHistory(userID, uint(historyID))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "comparison not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"history": record, "comparison": result}})
}
Loading
Loading