feat(supervisor): add reminder settings and rate-limited logs
This commit is contained in:
@@ -91,6 +91,8 @@ func main() {
|
|||||||
&quitcheckinmodel.HPChangeLog{},
|
&quitcheckinmodel.HPChangeLog{},
|
||||||
&quitcheckinmodel.SupervisorInvite{},
|
&quitcheckinmodel.SupervisorInvite{},
|
||||||
&quitcheckinmodel.SupervisorBinding{},
|
&quitcheckinmodel.SupervisorBinding{},
|
||||||
|
&quitcheckinmodel.SupervisorReminderSetting{},
|
||||||
|
&quitcheckinmodel.SupervisorReminderLog{},
|
||||||
&quitcheckinmodel.RewardGoal{},
|
&quitcheckinmodel.RewardGoal{},
|
||||||
&quitcheckinmodel.DreamPreset{},
|
&quitcheckinmodel.DreamPreset{},
|
||||||
&achievement.Theme{},
|
&achievement.Theme{},
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/internal/middleware"
|
||||||
|
"wx_service/internal/model"
|
||||||
|
service "wx_service/internal/quitcheckin/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetReminderSettings GET /api/v2/supervisor/reminders/settings
|
||||||
|
// owner 读取自己的提醒设置。
|
||||||
|
func (h *Handler) GetReminderSettings(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
res, err := h.service.GetReminderSettings(c.Request.Context(), int(user.ID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取提醒设置失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateReminderSettingsRequest struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
NotifyTime *string `json:"notify_time"`
|
||||||
|
MaxPerDay *int `json:"max_per_day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateReminderSettings PUT /api/v2/supervisor/reminders/settings
|
||||||
|
func (h *Handler) UpdateReminderSettings(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
var req updateReminderSettingsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// basic trim
|
||||||
|
if req.NotifyTime != nil {
|
||||||
|
v := strings.TrimSpace(*req.NotifyTime)
|
||||||
|
req.NotifyTime = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.service.UpdateReminderSettings(c.Request.Context(), int(user.ID), service.UpdateReminderSettingsRequest{
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
NotifyTime: req.NotifyTime,
|
||||||
|
MaxPerDay: req.MaxPerDay,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "保存提醒设置失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunReminders POST /api/v2/supervisor/reminders/run
|
||||||
|
// supervisor 手动触发提醒(开发/测试用):只会给“当前用户作为监督人”的绑定关系写 reminder_log。
|
||||||
|
func (h *Handler) RunReminders(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
res, err := h.service.RunMissedCheckinRemindersForSupervisor(c.Request.Context(), int(user.ID), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "触发提醒失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(res))
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupervisorReminderLog 记录一次提醒尝试,用于:
|
||||||
|
// - 频控:按天限制每个 (owner, supervisor) 的提醒次数
|
||||||
|
// - 追踪:后续接入真实通道(订阅消息)时可落库 status/result
|
||||||
|
type SupervisorReminderLog struct {
|
||||||
|
ID uint `gorm:"primaryKey;comment:主键" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
|
||||||
|
|
||||||
|
OwnerUID int `gorm:"column:owner_uid;index:idx_owner_supervisor_date,priority:1;comment:被监督用户ID" json:"owner_uid"`
|
||||||
|
SupervisorUID int `gorm:"column:supervisor_uid;index:idx_owner_supervisor_date,priority:2;comment:监督人用户ID" json:"supervisor_uid"`
|
||||||
|
|
||||||
|
ReminderDate time.Time `gorm:"column:reminder_date;type:date;index:idx_owner_supervisor_date,priority:3;comment:所属自然日" json:"reminder_date"`
|
||||||
|
ReminderAt time.Time `gorm:"column:reminder_at;comment:提醒时间" json:"reminder_at"`
|
||||||
|
|
||||||
|
Type string `gorm:"column:type;size:32;index;comment:提醒类型(missed_checkin...)" json:"type"`
|
||||||
|
Status string `gorm:"column:status;size:16;index;comment:状态(stubbed|sent|failed)" json:"status"`
|
||||||
|
Channel string `gorm:"column:channel;size:32;comment:通道(stub|subscribe_msg...)" json:"channel"`
|
||||||
|
|
||||||
|
Message string `gorm:"column:message;type:text;comment:提醒内容(可选)" json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SupervisorReminderLog) TableName() string {
|
||||||
|
return "fa_quit_checkin_supervisor_reminder_log"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SupervisorReminderLog) TableComment() string {
|
||||||
|
return "V2-无烟打卡-监督提醒记录"
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupervisorReminderSetting 表示“被监督用户(owner)”对提醒机制的配置。
|
||||||
|
// 注意:提醒本质是隐私相关能力,默认应为关闭,需 owner 显式开启。
|
||||||
|
type SupervisorReminderSetting struct {
|
||||||
|
ID uint `gorm:"primaryKey;comment:主键" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
|
||||||
|
|
||||||
|
OwnerUID int `gorm:"column:owner_uid;uniqueIndex;comment:被监督用户ID" json:"owner_uid"`
|
||||||
|
|
||||||
|
Enabled bool `gorm:"column:enabled;comment:是否启用提醒" json:"enabled"`
|
||||||
|
NotifyTime string `gorm:"column:notify_time;size:5;comment:提醒时间(HH:MM)" json:"notify_time"`
|
||||||
|
MaxPerDay int `gorm:"column:max_per_day;comment:每日最多提醒次数(每个监督人)" json:"max_per_day"`
|
||||||
|
ChannelHint string `gorm:"column:channel_hint;size:32;comment:提醒通道提示(stub|subscribe_msg...)" json:"channel_hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SupervisorReminderSetting) TableName() string {
|
||||||
|
return "fa_quit_checkin_supervisor_reminder_setting"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SupervisorReminderSetting) TableComment() string {
|
||||||
|
return "V2-无烟打卡-监督提醒设置"
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
quitmodel "wx_service/internal/quitcheckin/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
reminderTypeMissedCheckin = "missed_checkin"
|
||||||
|
reminderStatusStubbed = "stubbed"
|
||||||
|
reminderChannelStub = "stub"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReminderSettingsResult struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
NotifyTime string `json:"notify_time"`
|
||||||
|
MaxPerDay int `json:"max_per_day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateReminderSettingsRequest struct {
|
||||||
|
Enabled *bool
|
||||||
|
NotifyTime *string
|
||||||
|
MaxPerDay *int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunRemindersResult struct {
|
||||||
|
Created int `json:"created"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrReminderSettingsInvalid = errors.New("提醒设置不合法")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) GetReminderSettings(ctx context.Context, ownerUID int) (ReminderSettingsResult, error) {
|
||||||
|
setting, err := s.loadOrInitReminderSetting(ctx, ownerUID, time.Now().In(time.Local))
|
||||||
|
if err != nil {
|
||||||
|
return ReminderSettingsResult{}, err
|
||||||
|
}
|
||||||
|
return ReminderSettingsResult{
|
||||||
|
Enabled: setting.Enabled,
|
||||||
|
NotifyTime: setting.NotifyTime,
|
||||||
|
MaxPerDay: setting.MaxPerDay,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateReminderSettings(ctx context.Context, ownerUID int, req UpdateReminderSettingsRequest, now time.Time) (ReminderSettingsResult, error) {
|
||||||
|
setting, err := s.loadOrInitReminderSetting(ctx, ownerUID, now)
|
||||||
|
if err != nil {
|
||||||
|
return ReminderSettingsResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
updates["enabled"] = *req.Enabled
|
||||||
|
setting.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.NotifyTime != nil {
|
||||||
|
v := strings.TrimSpace(*req.NotifyTime)
|
||||||
|
if !ParseNotifyTime(v) {
|
||||||
|
return ReminderSettingsResult{}, ErrReminderSettingsInvalid
|
||||||
|
}
|
||||||
|
updates["notify_time"] = v
|
||||||
|
setting.NotifyTime = v
|
||||||
|
}
|
||||||
|
if req.MaxPerDay != nil {
|
||||||
|
v := *req.MaxPerDay
|
||||||
|
if v < 0 || v > 10 {
|
||||||
|
return ReminderSettingsResult{}, ErrReminderSettingsInvalid
|
||||||
|
}
|
||||||
|
if v == 0 {
|
||||||
|
// 0 表示关闭提醒(即使 enabled=true 也不会发送)
|
||||||
|
updates["max_per_day"] = 0
|
||||||
|
setting.MaxPerDay = 0
|
||||||
|
} else {
|
||||||
|
updates["max_per_day"] = v
|
||||||
|
setting.MaxPerDay = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return ReminderSettingsResult{
|
||||||
|
Enabled: setting.Enabled,
|
||||||
|
NotifyTime: setting.NotifyTime,
|
||||||
|
MaxPerDay: setting.MaxPerDay,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
updates["updated_at"] = now
|
||||||
|
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&quitmodel.SupervisorReminderSetting{}).
|
||||||
|
Where("owner_uid = ?", ownerUID).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
|
return ReminderSettingsResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReminderSettingsResult{
|
||||||
|
Enabled: setting.Enabled,
|
||||||
|
NotifyTime: setting.NotifyTime,
|
||||||
|
MaxPerDay: setting.MaxPerDay,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMissedCheckinRemindersForSupervisor 手动触发提醒(用于开发/测试):
|
||||||
|
// - 只会针对“当前监督人”绑定的 owner
|
||||||
|
// - 只会写入 reminder_log(stubbed),不接真实通道
|
||||||
|
func (s *Service) RunMissedCheckinRemindersForSupervisor(ctx context.Context, supervisorUID int, now time.Time) (RunRemindersResult, error) {
|
||||||
|
today := normalizeDate(now)
|
||||||
|
|
||||||
|
var bindings []quitmodel.SupervisorBinding
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("supervisor_uid = ? AND status = ?", supervisorUID, "active").
|
||||||
|
Find(&bindings).Error; err != nil {
|
||||||
|
return RunRemindersResult{}, err
|
||||||
|
}
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return RunRemindersResult{Created: 0, Skipped: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
created := 0
|
||||||
|
skipped := 0
|
||||||
|
|
||||||
|
for _, b := range bindings {
|
||||||
|
ownerUID := b.OwnerUID
|
||||||
|
|
||||||
|
// owner 必须有 profile 才视为 quit-checkin 用户;没有就跳过。
|
||||||
|
_, err := s.loadProfile(ctx, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return RunRemindersResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
setting, err := s.loadOrInitReminderSetting(ctx, ownerUID, now)
|
||||||
|
if err != nil {
|
||||||
|
return RunRemindersResult{}, err
|
||||||
|
}
|
||||||
|
if !setting.Enabled || setting.MaxPerDay <= 0 {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发时间门槛:now >= notify_time
|
||||||
|
if !isAfterNotifyTime(now, setting.NotifyTime) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日状态:已打卡或已复吸,则无需提醒
|
||||||
|
var status quitmodel.DailyStatus
|
||||||
|
err = s.db.WithContext(ctx).
|
||||||
|
Where("uid = ? AND date = ?", ownerUID, today).
|
||||||
|
First(&status).Error
|
||||||
|
if err == nil {
|
||||||
|
if status.Status == quitmodel.DailyStatusCheckedIn || status.Status == quitmodel.DailyStatusRelapsed {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return RunRemindersResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频控:每日最多 N 次(按 owner+supervisor+date 计数)
|
||||||
|
var count int64
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&quitmodel.SupervisorReminderLog{}).
|
||||||
|
Where("owner_uid = ? AND supervisor_uid = ? AND reminder_date = ?", ownerUID, supervisorUID, today).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return RunRemindersResult{}, err
|
||||||
|
}
|
||||||
|
if int(count) >= setting.MaxPerDay {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("提醒:%s 今天还没打卡", safeNickname(ownerUID))
|
||||||
|
logRow := quitmodel.SupervisorReminderLog{
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
SupervisorUID: supervisorUID,
|
||||||
|
ReminderDate: today,
|
||||||
|
ReminderAt: now,
|
||||||
|
Type: reminderTypeMissedCheckin,
|
||||||
|
Status: reminderStatusStubbed,
|
||||||
|
Channel: reminderChannelStub,
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Create(&logRow).Error; err != nil {
|
||||||
|
return RunRemindersResult{}, err
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunRemindersResult{Created: created, Skipped: skipped}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadOrInitReminderSetting(ctx context.Context, ownerUID int, now time.Time) (*quitmodel.SupervisorReminderSetting, error) {
|
||||||
|
var row quitmodel.SupervisorReminderSetting
|
||||||
|
err := s.db.WithContext(ctx).Where("owner_uid = ?", ownerUID).First(&row).Error
|
||||||
|
if err == nil {
|
||||||
|
// sanity defaults
|
||||||
|
if row.NotifyTime == "" {
|
||||||
|
row.NotifyTime = "21:00"
|
||||||
|
}
|
||||||
|
if row.MaxPerDay < 0 {
|
||||||
|
row.MaxPerDay = 0
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认关闭,避免隐私风险
|
||||||
|
row = quitmodel.SupervisorReminderSetting{
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
Enabled: false,
|
||||||
|
NotifyTime: "21:00",
|
||||||
|
MaxPerDay: 1,
|
||||||
|
ChannelHint: "stub",
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAfterNotifyTime(now time.Time, notifyTime string) bool {
|
||||||
|
notifyTime = strings.TrimSpace(notifyTime)
|
||||||
|
if notifyTime == "" {
|
||||||
|
notifyTime = "21:00"
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation("15:04", notifyTime, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
target := time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, time.Local)
|
||||||
|
return !now.Before(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeNickname(uid int) string {
|
||||||
|
// 这里先不依赖 user.nickname,避免跨模块耦合和额外查询。
|
||||||
|
// motivation 字段也不适合作为昵称,仅作占位。
|
||||||
|
return fmt.Sprintf("用户%d", uid)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
quitmodel "wx_service/internal/quitcheckin/model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupReminderTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&quitmodel.Profile{},
|
||||||
|
&quitmodel.DailyStatus{},
|
||||||
|
&quitmodel.RelapseEvent{},
|
||||||
|
&quitmodel.RewardGoal{},
|
||||||
|
&quitmodel.DreamPreset{},
|
||||||
|
&quitmodel.SupervisorBinding{},
|
||||||
|
&quitmodel.SupervisorInvite{},
|
||||||
|
&quitmodel.SupervisorReminderSetting{},
|
||||||
|
&quitmodel.SupervisorReminderLog{},
|
||||||
|
&quitmodel.HPChangeLog{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReminderRunCreatesLogAfterNotifyTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := setupReminderTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ownerUID := 4001
|
||||||
|
supervisorUID := 4002
|
||||||
|
|
||||||
|
startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local)
|
||||||
|
now := time.Date(2026, 4, 16, 21, 30, 0, 0, time.Local)
|
||||||
|
|
||||||
|
// seed quit profile
|
||||||
|
if err := db.Create(&quitmodel.Profile{
|
||||||
|
UID: ownerUID,
|
||||||
|
QuitStartDate: startDate,
|
||||||
|
PackPriceCent: 2500,
|
||||||
|
BaselineCigsPerDay: 10,
|
||||||
|
NotifyTime: "21:00",
|
||||||
|
ResetRule: "relapse_clears_streak",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed binding
|
||||||
|
if err := db.Create(&quitmodel.SupervisorBinding{
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
SupervisorUID: supervisorUID,
|
||||||
|
Status: "active",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed binding: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enable reminder
|
||||||
|
if err := db.Create(&quitmodel.SupervisorReminderSetting{
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
Enabled: true,
|
||||||
|
NotifyTime: "21:00",
|
||||||
|
MaxPerDay: 1,
|
||||||
|
ChannelHint: "stub",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed setting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.RunMissedCheckinRemindersForSupervisor(ctx, supervisorUID, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
if res.Created != 1 {
|
||||||
|
t.Fatalf("created=%d want=1", res.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// run again should be rate-limited
|
||||||
|
res2, err := svc.RunMissedCheckinRemindersForSupervisor(ctx, supervisorUID, now.Add(10*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run2: %v", err)
|
||||||
|
}
|
||||||
|
if res2.Created != 0 {
|
||||||
|
t.Fatalf("created2=%d want=0", res2.Created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReminderRunSkipsBeforeNotifyTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := setupReminderTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ownerUID := 4011
|
||||||
|
supervisorUID := 4012
|
||||||
|
|
||||||
|
startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local)
|
||||||
|
now := time.Date(2026, 4, 16, 20, 50, 0, 0, time.Local)
|
||||||
|
|
||||||
|
if err := db.Create(&quitmodel.Profile{
|
||||||
|
UID: ownerUID,
|
||||||
|
QuitStartDate: startDate,
|
||||||
|
PackPriceCent: 2500,
|
||||||
|
BaselineCigsPerDay: 10,
|
||||||
|
NotifyTime: "21:00",
|
||||||
|
ResetRule: "relapse_clears_streak",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed profile: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&quitmodel.SupervisorBinding{
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
SupervisorUID: supervisorUID,
|
||||||
|
Status: "active",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed binding: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&quitmodel.SupervisorReminderSetting{
|
||||||
|
OwnerUID: ownerUID,
|
||||||
|
Enabled: true,
|
||||||
|
NotifyTime: "21:00",
|
||||||
|
MaxPerDay: 1,
|
||||||
|
ChannelHint: "stub",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed setting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.RunMissedCheckinRemindersForSupervisor(ctx, supervisorUID, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
if res.Created != 0 {
|
||||||
|
t.Fatalf("created=%d want=0", res.Created)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinh
|
|||||||
v2.POST("/supervisor/revoke", handler.RevokeSupervisorBinding)
|
v2.POST("/supervisor/revoke", handler.RevokeSupervisorBinding)
|
||||||
v2.GET("/supervisor/overview", handler.GetSupervisorOverview)
|
v2.GET("/supervisor/overview", handler.GetSupervisorOverview)
|
||||||
v2.GET("/supervisor/status", handler.GetSupervisorStatus)
|
v2.GET("/supervisor/status", handler.GetSupervisorStatus)
|
||||||
|
v2.GET("/supervisor/reminders/settings", handler.GetReminderSettings)
|
||||||
|
v2.PUT("/supervisor/reminders/settings", handler.UpdateReminderSettings)
|
||||||
|
v2.POST("/supervisor/reminders/run", handler.RunReminders)
|
||||||
|
|
||||||
v2.GET("/stats/overview", handler.StatsOverview)
|
v2.GET("/stats/overview", handler.StatsOverview)
|
||||||
v2.GET("/badges", handler.ListBadges)
|
v2.GET("/badges", handler.ListBadges)
|
||||||
|
|||||||
Reference in New Issue
Block a user