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 } }