779 lines
23 KiB
Go
779 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"asset-tracker/internal/auth"
|
|
"asset-tracker/internal/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type Handler struct {
|
|
db *gorm.DB
|
|
tm *auth.TokenManager
|
|
}
|
|
|
|
func NewHandler(db *gorm.DB, tm *auth.TokenManager) *Handler {
|
|
return &Handler{db: db, tm: tm}
|
|
}
|
|
|
|
func toJSON(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func (h *Handler) writeAudit(userID uint, entityType string, entityID uint, action string, before any, after any) {
|
|
log := model.AuditLog{
|
|
UserID: userID,
|
|
EntityType: entityType,
|
|
EntityID: entityID,
|
|
Action: action,
|
|
BeforeJSON: toJSON(before),
|
|
AfterJSON: toJSON(after),
|
|
}
|
|
_ = h.db.Create(&log).Error
|
|
}
|
|
|
|
var currencyPattern = regexp.MustCompile(`^[A-Z]{3,10}$`)
|
|
|
|
type loginRequest struct {
|
|
Username string `json:"username" binding:"required"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
func (h *Handler) Login(c *gin.Context) {
|
|
var req loginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
bizError(c, "auth", "login", "BAD_REQUEST", err, nil)
|
|
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
|
return
|
|
}
|
|
|
|
var user model.User
|
|
if err := h.db.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
|
bizError(c, "auth", "login", "AUTH_INVALID_CREDENTIALS", err, map[string]any{"username": strings.TrimSpace(req.Username)})
|
|
JSONUnauthorized(c, "AUTH_INVALID_CREDENTIALS", "invalid username or password")
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
bizError(c, "auth", "login", "AUTH_INVALID_CREDENTIALS", err, map[string]any{"username": user.Username, "uid": user.ID})
|
|
JSONUnauthorized(c, "AUTH_INVALID_CREDENTIALS", "invalid username or password")
|
|
return
|
|
}
|
|
|
|
access, err := h.tm.GenerateAccessToken(user.ID, user.Username, user.Timezone)
|
|
if err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
refresh, jti, exp, err := h.tm.GenerateRefreshToken(user.ID, user.Username, user.Timezone)
|
|
if err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
if err := h.db.Create(&model.RefreshSession{UserID: user.ID, JTI: jti, ExpiresAt: exp}).Error; err != nil {
|
|
bizError(c, "auth", "login", "REFRESH_SESSION_CREATE_FAILED", err, map[string]any{"uid": user.ID})
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
secure := strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https") || c.Request.TLS != nil
|
|
c.SetSameSite(http.SameSiteLaxMode)
|
|
c.SetCookie("refresh_token", refresh, h.tm.RefreshMaxAgeSeconds(), "/api/v1/auth/refresh", "", secure, true)
|
|
|
|
bizInfo(c, "auth", "login", map[string]any{"uid": user.ID, "username": user.Username})
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"access_token": access,
|
|
"token_type": "Bearer",
|
|
})
|
|
}
|
|
|
|
type refreshRequest struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
|
|
func (h *Handler) Refresh(c *gin.Context) {
|
|
refreshToken := strings.TrimSpace(c.GetHeader("X-Refresh-Token"))
|
|
if refreshToken == "" {
|
|
if cookie, err := c.Cookie("refresh_token"); err == nil {
|
|
refreshToken = strings.TrimSpace(cookie)
|
|
}
|
|
}
|
|
if refreshToken == "" {
|
|
var req refreshRequest
|
|
if err := c.ShouldBindJSON(&req); err == nil {
|
|
refreshToken = strings.TrimSpace(req.RefreshToken)
|
|
}
|
|
}
|
|
if refreshToken == "" {
|
|
bizError(c, "auth", "refresh", "AUTH_MISSING_REFRESH", nil, nil)
|
|
JSONUnauthorized(c, "AUTH_MISSING_REFRESH", "missing refresh token")
|
|
return
|
|
}
|
|
|
|
claims, err := h.tm.ParseAndValidate(refreshToken, "refresh")
|
|
if err != nil {
|
|
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
|
return
|
|
}
|
|
if strings.TrimSpace(claims.ID) == "" {
|
|
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
|
return
|
|
}
|
|
|
|
var session model.RefreshSession
|
|
if err := h.db.Where("jti = ? AND user_id = ?", claims.ID, claims.UserID).First(&session).Error; err != nil {
|
|
bizError(c, "auth", "refresh", "AUTH_INVALID_REFRESH", err, map[string]any{"uid": claims.UserID, "jti": claims.ID})
|
|
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
|
return
|
|
}
|
|
if session.RevokedAt != nil || session.ExpiresAt.Before(time.Now().UTC()) {
|
|
bizError(c, "auth", "refresh", "AUTH_INVALID_REFRESH", nil, map[string]any{"uid": claims.UserID, "jti": claims.ID, "revoked": session.RevokedAt != nil})
|
|
JSONUnauthorized(c, "AUTH_INVALID_REFRESH", "invalid refresh token")
|
|
return
|
|
}
|
|
|
|
access, err := h.tm.GenerateAccessToken(claims.UserID, claims.Username, claims.Timezone)
|
|
if err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
newRefresh, newJTI, newExp, err := h.tm.GenerateRefreshToken(claims.UserID, claims.Username, claims.Timezone)
|
|
if err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
|
now := time.Now().UTC()
|
|
if err := tx.Model(&model.RefreshSession{}).Where("id = ?", session.ID).Updates(map[string]any{"revoked_at": &now, "replaced_by": newJTI}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(&model.RefreshSession{UserID: claims.UserID, JTI: newJTI, ExpiresAt: newExp}).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
bizError(c, "auth", "refresh", "REFRESH_ROTATE_FAILED", err, map[string]any{"uid": claims.UserID, "old_jti": claims.ID, "new_jti": newJTI})
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
secure := strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https") || c.Request.TLS != nil
|
|
c.SetSameSite(http.SameSiteLaxMode)
|
|
c.SetCookie("refresh_token", newRefresh, h.tm.RefreshMaxAgeSeconds(), "/api/v1/auth/refresh", "", secure, true)
|
|
|
|
bizInfo(c, "auth", "refresh", map[string]any{"uid": claims.UserID, "old_jti": claims.ID, "new_jti": newJTI})
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"access_token": access,
|
|
"token_type": "Bearer",
|
|
})
|
|
}
|
|
|
|
type createCategoryRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Type string `json:"type" binding:"required,oneof=real digital"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
func (h *Handler) CreateCategory(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
var req createCategoryRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
bizError(c, "category", "create", "BAD_REQUEST", err, nil)
|
|
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
|
return
|
|
}
|
|
|
|
cat := model.Category{
|
|
UserID: userID,
|
|
Name: strings.TrimSpace(req.Name),
|
|
Type: req.Type,
|
|
Color: strings.TrimSpace(req.Color),
|
|
}
|
|
if cat.Name == "" {
|
|
bizError(c, "category", "create", "CATEGORY_NAME_REQUIRED", nil, nil)
|
|
JSONBadRequest(c, "CATEGORY_NAME_REQUIRED", "name is required", nil)
|
|
return
|
|
}
|
|
|
|
if err := h.db.Create(&cat).Error; err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
bizError(c, "category", "create", "CATEGORY_DUPLICATE", err, map[string]any{"name": cat.Name})
|
|
JSONError(c, http.StatusConflict, "CATEGORY_DUPLICATE", "category already exists", nil)
|
|
return
|
|
}
|
|
bizError(c, "category", "create", "INTERNAL_ERROR", err, nil)
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
bizInfo(c, "category", "create", map[string]any{"category_id": cat.ID, "name": cat.Name})
|
|
c.JSON(http.StatusCreated, gin.H{"data": cat})
|
|
}
|
|
|
|
func (h *Handler) ListCategories(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
var categories []model.Category
|
|
if err := h.db.Where("user_id = ?", userID).Order("id desc").Find(&categories).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": categories})
|
|
}
|
|
|
|
type createAssetRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
CategoryID uint `json:"category_id" binding:"required"`
|
|
Quantity float64 `json:"quantity" binding:"required"`
|
|
UnitPrice float64 `json:"unit_price" binding:"required"`
|
|
Currency string `json:"currency" binding:"required"`
|
|
ExpiryDate string `json:"expiry_date"`
|
|
Note string `json:"note"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type updateAssetRequest struct {
|
|
Name *string `json:"name"`
|
|
CategoryID *uint `json:"category_id"`
|
|
Quantity *float64 `json:"quantity"`
|
|
UnitPrice *float64 `json:"unit_price"`
|
|
Currency *string `json:"currency"`
|
|
ExpiryDate *string `json:"expiry_date"`
|
|
Note *string `json:"note"`
|
|
Status *string `json:"status"`
|
|
}
|
|
|
|
func parseExpiryToUTC(raw string) (*time.Time, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u := parsed.UTC()
|
|
return &u, nil
|
|
}
|
|
|
|
func (h *Handler) CreateAsset(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
var req createAssetRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
bizError(c, "asset", "create", "BAD_REQUEST", err, nil)
|
|
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
|
return
|
|
}
|
|
|
|
if req.Quantity < 0 || req.UnitPrice < 0 {
|
|
JSONBadRequest(c, "ASSET_NEGATIVE_VALUE", "quantity and unit_price must be >= 0", nil)
|
|
return
|
|
}
|
|
|
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
if !currencyPattern.MatchString(currency) {
|
|
JSONBadRequest(c, "ASSET_INVALID_CURRENCY", "currency must match [A-Z]{3,10}", nil)
|
|
return
|
|
}
|
|
|
|
status := strings.TrimSpace(req.Status)
|
|
if status == "" {
|
|
status = "active"
|
|
}
|
|
if status != "active" && status != "inactive" {
|
|
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
|
return
|
|
}
|
|
|
|
expiry, err := parseExpiryToUTC(req.ExpiryDate)
|
|
if err != nil {
|
|
JSONBadRequest(c, "ASSET_INVALID_EXPIRY", "expiry_date must be RFC3339", nil)
|
|
return
|
|
}
|
|
|
|
var count int64
|
|
if err := h.db.Model(&model.Category{}).Where("id = ? AND user_id = ?", req.CategoryID, userID).Count(&count).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
if count == 0 {
|
|
JSONBadRequest(c, "CATEGORY_NOT_FOUND", "category not found", nil)
|
|
return
|
|
}
|
|
|
|
asset := model.Asset{
|
|
UserID: userID,
|
|
Name: strings.TrimSpace(req.Name),
|
|
CategoryID: req.CategoryID,
|
|
Quantity: req.Quantity,
|
|
UnitPrice: req.UnitPrice,
|
|
TotalValue: req.Quantity * req.UnitPrice,
|
|
Currency: currency,
|
|
ExpiryDate: expiry,
|
|
Note: strings.TrimSpace(req.Note),
|
|
Status: status,
|
|
}
|
|
if asset.Name == "" {
|
|
JSONBadRequest(c, "CATEGORY_NAME_REQUIRED", "name is required", nil)
|
|
return
|
|
}
|
|
|
|
if err := h.db.Create(&asset).Error; err != nil {
|
|
bizError(c, "asset", "create", "INTERNAL_ERROR", err, nil)
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
h.ensureRemindersForAsset(asset)
|
|
h.writeAudit(userID, "asset", asset.ID, "create", nil, asset)
|
|
bizInfo(c, "asset", "create", map[string]any{"asset_id": asset.ID, "name": asset.Name})
|
|
c.JSON(http.StatusCreated, gin.H{"data": formatAssetForTZ(asset, c.GetString("timezone"))})
|
|
}
|
|
|
|
func (h *Handler) UpdateAsset(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
assetID := c.Param("id")
|
|
|
|
var asset model.Asset
|
|
if err := h.db.Where("id = ? AND user_id = ?", assetID, userID).First(&asset).Error; err != nil {
|
|
bizError(c, "asset", "update", "ASSET_NOT_FOUND", err, map[string]any{"asset_id": assetID})
|
|
JSONError(c, http.StatusNotFound, "ASSET_NOT_FOUND", "asset not found", nil)
|
|
return
|
|
}
|
|
before := asset
|
|
|
|
var req updateAssetRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
bizError(c, "asset", "update", "BAD_REQUEST", err, map[string]any{"asset_id": asset.ID})
|
|
JSONBadRequest(c, "BAD_REQUEST", "invalid request", err.Error())
|
|
return
|
|
}
|
|
|
|
if req.Name != nil {
|
|
asset.Name = strings.TrimSpace(*req.Name)
|
|
if asset.Name == "" {
|
|
JSONBadRequest(c, "ASSET_NAME_EMPTY", "name cannot be empty", nil)
|
|
return
|
|
}
|
|
}
|
|
if req.CategoryID != nil {
|
|
var count int64
|
|
if err := h.db.Model(&model.Category{}).Where("id = ? AND user_id = ?", *req.CategoryID, userID).Count(&count).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
if count == 0 {
|
|
JSONBadRequest(c, "CATEGORY_NOT_FOUND", "category not found", nil)
|
|
return
|
|
}
|
|
asset.CategoryID = *req.CategoryID
|
|
}
|
|
if req.Quantity != nil {
|
|
if *req.Quantity < 0 {
|
|
JSONBadRequest(c, "ASSET_QUANTITY_NEGATIVE", "quantity must be >= 0", nil)
|
|
return
|
|
}
|
|
asset.Quantity = *req.Quantity
|
|
}
|
|
if req.UnitPrice != nil {
|
|
if *req.UnitPrice < 0 {
|
|
JSONBadRequest(c, "ASSET_UNIT_PRICE_NEGATIVE", "unit_price must be >= 0", nil)
|
|
return
|
|
}
|
|
asset.UnitPrice = *req.UnitPrice
|
|
}
|
|
if req.Currency != nil {
|
|
cur := strings.ToUpper(strings.TrimSpace(*req.Currency))
|
|
if !currencyPattern.MatchString(cur) {
|
|
JSONBadRequest(c, "ASSET_INVALID_CURRENCY", "currency must match [A-Z]{3,10}", nil)
|
|
return
|
|
}
|
|
asset.Currency = cur
|
|
}
|
|
if req.Status != nil {
|
|
status := strings.TrimSpace(*req.Status)
|
|
if status != "active" && status != "inactive" {
|
|
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
|
return
|
|
}
|
|
asset.Status = status
|
|
}
|
|
if req.Note != nil {
|
|
asset.Note = strings.TrimSpace(*req.Note)
|
|
}
|
|
if req.ExpiryDate != nil {
|
|
expiry, err := parseExpiryToUTC(*req.ExpiryDate)
|
|
if err != nil {
|
|
JSONBadRequest(c, "ASSET_INVALID_EXPIRY", "expiry_date must be RFC3339", nil)
|
|
return
|
|
}
|
|
asset.ExpiryDate = expiry
|
|
}
|
|
|
|
asset.TotalValue = asset.Quantity * asset.UnitPrice
|
|
if err := h.db.Save(&asset).Error; err != nil {
|
|
bizError(c, "asset", "update", "INTERNAL_ERROR", err, map[string]any{"asset_id": asset.ID})
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
h.ensureRemindersForAsset(asset)
|
|
h.writeAudit(userID, "asset", asset.ID, "update", before, asset)
|
|
bizInfo(c, "asset", "update", map[string]any{"asset_id": asset.ID, "status": asset.Status})
|
|
c.JSON(http.StatusOK, gin.H{"data": formatAssetForTZ(asset, c.GetString("timezone"))})
|
|
}
|
|
|
|
func (h *Handler) DeleteAsset(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
assetID := c.Param("id")
|
|
|
|
var asset model.Asset
|
|
if err := h.db.Where("id = ? AND user_id = ?", assetID, userID).First(&asset).Error; err != nil {
|
|
bizError(c, "asset", "delete", "ASSET_NOT_FOUND", err, map[string]any{"asset_id": assetID})
|
|
JSONError(c, http.StatusNotFound, "ASSET_NOT_FOUND", "asset not found", nil)
|
|
return
|
|
}
|
|
|
|
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("asset_id = ? AND user_id = ?", asset.ID, userID).Delete(&model.Reminder{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Delete(&asset).Error; err != nil {
|
|
return err
|
|
}
|
|
log := model.AuditLog{
|
|
UserID: userID,
|
|
EntityType: "asset",
|
|
EntityID: asset.ID,
|
|
Action: "delete",
|
|
BeforeJSON: toJSON(asset),
|
|
AfterJSON: "null",
|
|
}
|
|
return tx.Create(&log).Error
|
|
}); err != nil {
|
|
bizError(c, "asset", "delete", "INTERNAL_ERROR", err, map[string]any{"asset_id": asset.ID})
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
bizInfo(c, "asset", "delete", map[string]any{"asset_id": asset.ID})
|
|
c.JSON(http.StatusOK, gin.H{"message": "deleted", "request_id": requestID(c)})
|
|
}
|
|
|
|
func (h *Handler) ListAssets(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
var assets []model.Asset
|
|
query := h.db.Model(&model.Asset{}).Where("user_id = ?", userID).Order("id desc")
|
|
|
|
categoryID := strings.TrimSpace(c.Query("category_id"))
|
|
if categoryID != "" {
|
|
query = query.Where("category_id = ?", categoryID)
|
|
}
|
|
|
|
status := strings.TrimSpace(c.Query("status"))
|
|
if status != "" {
|
|
if status != "active" && status != "inactive" {
|
|
JSONBadRequest(c, "ASSET_INVALID_STATUS", "status must be active or inactive", nil)
|
|
return
|
|
}
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
|
|
page := 1
|
|
pageSize := 20
|
|
if p := strings.TrimSpace(c.Query("page")); p != "" {
|
|
fmt.Sscanf(p, "%d", &page)
|
|
}
|
|
if ps := strings.TrimSpace(c.Query("page_size")); ps != "" {
|
|
fmt.Sscanf(ps, "%d", &pageSize)
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = 20
|
|
}
|
|
if pageSize > 100 {
|
|
pageSize = 100
|
|
}
|
|
offset := (page - 1) * pageSize
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
if err := query.Offset(offset).Limit(pageSize).Find(&assets).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
resp := make([]gin.H, 0, len(assets))
|
|
for _, a := range assets {
|
|
resp = append(resp, formatAssetForTZ(a, c.GetString("timezone")))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": resp, "total": total, "page": page, "page_size": pageSize})
|
|
}
|
|
|
|
func (h *Handler) PublicRecords(c *gin.Context) {
|
|
tz := strings.TrimSpace(c.Query("timezone"))
|
|
if tz == "" {
|
|
tz = "Asia/Shanghai"
|
|
}
|
|
|
|
var assets []model.Asset
|
|
if err := h.db.Where("user_id = ?", 1).Order("updated_at desc").Limit(100).Find(&assets).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
var categories []model.Category
|
|
_ = h.db.Where("user_id = ?", 1).Find(&categories).Error
|
|
catName := map[uint]string{}
|
|
for _, x := range categories {
|
|
catName[x.ID] = x.Name
|
|
}
|
|
|
|
total := 0.0
|
|
activeCount := 0
|
|
byCat := map[string]float64{}
|
|
resp := make([]gin.H, 0, len(assets))
|
|
for _, a := range assets {
|
|
if a.Status == "active" {
|
|
total += a.TotalValue
|
|
activeCount++
|
|
byCat[catName[a.CategoryID]] += a.TotalValue
|
|
}
|
|
row := formatAssetForTZ(a, tz)
|
|
row["category_name"] = catName[a.CategoryID]
|
|
resp = append(resp, row)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"summary": gin.H{
|
|
"user_id": 1,
|
|
"active_asset_count": activeCount,
|
|
"total_assets_value": total,
|
|
"by_category": byCat,
|
|
},
|
|
"records": resp,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) ListReminders(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
query := h.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Order("status asc, remind_at asc, id desc")
|
|
|
|
status := strings.TrimSpace(c.Query("status"))
|
|
if status != "" {
|
|
allowed := map[string]bool{"pending": true, "sending": true, "sent": true, "failed": true}
|
|
if !allowed[status] {
|
|
JSONBadRequest(c, "REMINDER_INVALID_STATUS", "invalid status", nil)
|
|
return
|
|
}
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
|
|
page := 1
|
|
pageSize := 20
|
|
if p := strings.TrimSpace(c.Query("page")); p != "" {
|
|
fmt.Sscanf(p, "%d", &page)
|
|
}
|
|
if ps := strings.TrimSpace(c.Query("page_size")); ps != "" {
|
|
fmt.Sscanf(ps, "%d", &pageSize)
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = 20
|
|
}
|
|
if pageSize > 100 {
|
|
pageSize = 100
|
|
}
|
|
offset := (page - 1) * pageSize
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
var rows []model.Reminder
|
|
if err := query.Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
assetIDs := make([]uint, 0, len(rows))
|
|
for _, r := range rows {
|
|
assetIDs = append(assetIDs, r.AssetID)
|
|
}
|
|
nameMap := map[uint]string{}
|
|
if len(assetIDs) > 0 {
|
|
var assets []model.Asset
|
|
_ = h.db.Where("id IN ? AND user_id = ?", assetIDs, userID).Find(&assets).Error
|
|
for _, a := range assets {
|
|
nameMap[a.ID] = a.Name
|
|
}
|
|
}
|
|
|
|
loc, err := time.LoadLocation(c.GetString("timezone"))
|
|
if err != nil {
|
|
loc = time.UTC
|
|
}
|
|
resp := make([]gin.H, 0, len(rows))
|
|
for _, r := range rows {
|
|
item := gin.H{
|
|
"id": r.ID,
|
|
"asset_id": r.AssetID,
|
|
"asset_name": nameMap[r.AssetID],
|
|
"remind_at": r.RemindAt.In(loc).Format(time.RFC3339),
|
|
"channel": r.Channel,
|
|
"status": r.Status,
|
|
"retry_count": r.RetryCount,
|
|
"last_error": r.LastError,
|
|
"created_at": r.CreatedAt.In(loc).Format(time.RFC3339),
|
|
"updated_at": r.UpdatedAt.In(loc).Format(time.RFC3339),
|
|
}
|
|
if r.SentAt != nil {
|
|
item["sent_at"] = r.SentAt.In(loc).Format(time.RFC3339)
|
|
}
|
|
if r.NextRetryAt != nil {
|
|
item["next_retry_at"] = r.NextRetryAt.In(loc).Format(time.RFC3339)
|
|
}
|
|
resp = append(resp, item)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": resp, "total": total, "page": page, "page_size": pageSize})
|
|
}
|
|
|
|
func (h *Handler) DashboardSummary(c *gin.Context) {
|
|
userID := c.GetUint("user_id")
|
|
var assets []model.Asset
|
|
if err := h.db.Where("user_id = ? AND status = ?", userID, "active").Find(&assets).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
total := 0.0
|
|
for _, a := range assets {
|
|
total += a.TotalValue
|
|
}
|
|
|
|
type categoryAgg struct {
|
|
CategoryID uint `json:"category_id"`
|
|
CategoryName string `json:"category_name"`
|
|
TotalValue float64 `json:"total_value"`
|
|
Ratio float64 `json:"ratio"`
|
|
}
|
|
|
|
nameMap := map[uint]string{}
|
|
var categories []model.Category
|
|
_ = h.db.Where("user_id = ?", userID).Find(&categories).Error
|
|
for _, cat := range categories {
|
|
nameMap[cat.ID] = cat.Name
|
|
}
|
|
|
|
byCatMap := map[uint]float64{}
|
|
for _, a := range assets {
|
|
byCatMap[a.CategoryID] += a.TotalValue
|
|
}
|
|
|
|
byCategory := make([]categoryAgg, 0, len(byCatMap))
|
|
for categoryID, v := range byCatMap {
|
|
ratio := 0.0
|
|
if total > 0 {
|
|
ratio = v / total
|
|
}
|
|
byCategory = append(byCategory, categoryAgg{
|
|
CategoryID: categoryID,
|
|
CategoryName: nameMap[categoryID],
|
|
TotalValue: v,
|
|
Ratio: ratio,
|
|
})
|
|
}
|
|
|
|
sort.Slice(byCategory, func(i, j int) bool { return byCategory[i].TotalValue > byCategory[j].TotalValue })
|
|
|
|
nowUTC := time.Now().UTC()
|
|
endUTC := nowUTC.Add(30 * 24 * time.Hour)
|
|
var expiring []model.Asset
|
|
if err := h.db.Where("user_id = ? AND status = ? AND expiry_date IS NOT NULL AND expiry_date >= ? AND expiry_date <= ?", userID, "active", nowUTC, endUTC).Order("expiry_date asc").Find(&expiring).Error; err != nil {
|
|
JSONInternal(c, "internal server error", err.Error())
|
|
return
|
|
}
|
|
|
|
expiringResp := make([]gin.H, 0, len(expiring))
|
|
for _, a := range expiring {
|
|
expiringResp = append(expiringResp, formatAssetForTZ(a, c.GetString("timezone")))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"total_assets_value": total,
|
|
"by_category": byCategory,
|
|
"expiring_in_30_days": expiringResp,
|
|
})
|
|
}
|
|
|
|
func formatAssetForTZ(a model.Asset, tz string) gin.H {
|
|
loc, err := time.LoadLocation(tz)
|
|
if err != nil {
|
|
loc = time.UTC
|
|
}
|
|
var expiry any
|
|
if a.ExpiryDate != nil {
|
|
expiry = a.ExpiryDate.In(loc).Format(time.RFC3339)
|
|
}
|
|
return gin.H{
|
|
"id": a.ID,
|
|
"name": a.Name,
|
|
"category_id": a.CategoryID,
|
|
"quantity": a.Quantity,
|
|
"unit_price": a.UnitPrice,
|
|
"total_value": a.TotalValue,
|
|
"currency": a.Currency,
|
|
"expiry_date": expiry,
|
|
"note": a.Note,
|
|
"status": a.Status,
|
|
"created_at": a.CreatedAt.In(loc).Format(time.RFC3339),
|
|
"updated_at": a.UpdatedAt.In(loc).Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) ensureRemindersForAsset(asset model.Asset) {
|
|
_ = h.db.Where("asset_id = ? AND user_id = ? AND status IN ?", asset.ID, asset.UserID, []string{"pending", "failed", "sending"}).Delete(&model.Reminder{}).Error
|
|
if asset.ExpiryDate == nil || asset.Status != "active" {
|
|
return
|
|
}
|
|
base := asset.ExpiryDate.UTC()
|
|
days := []int{30, 7, 1}
|
|
for _, d := range days {
|
|
remindAt := base.Add(-time.Duration(d) * 24 * time.Hour)
|
|
dedupe := fmt.Sprintf("asset:%d:at:%s:ch:in_app", asset.ID, remindAt.Format(time.RFC3339))
|
|
r := model.Reminder{
|
|
UserID: asset.UserID,
|
|
AssetID: asset.ID,
|
|
RemindAt: remindAt,
|
|
Channel: "in_app",
|
|
Status: "pending",
|
|
DedupeKey: dedupe,
|
|
}
|
|
_ = h.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "dedupe_key"}}, DoNothing: true}).Create(&r).Error
|
|
}
|
|
}
|