.機能追加、DBバグ修正
This commit is contained in:
@@ -17,6 +17,10 @@ type DatabaseConfig struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type NotificationConfig struct {
|
||||
TelegramBotToken string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
SessionSecret string
|
||||
@@ -29,6 +33,7 @@ type Config struct {
|
||||
RateLimitWindow int
|
||||
TrustedProxies []string
|
||||
Database DatabaseConfig
|
||||
Notification NotificationConfig
|
||||
}
|
||||
|
||||
func Load(configPath string) *Config {
|
||||
@@ -123,6 +128,12 @@ func Load(configPath string) *Config {
|
||||
cfg.TrustedProxies = []string{proxies}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification section
|
||||
section = iniFile.Section("notification")
|
||||
if section.HasKey("telegram_bot_token") {
|
||||
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String()
|
||||
}
|
||||
} else {
|
||||
log.Println("config.ini not found, using environment variables or defaults")
|
||||
}
|
||||
@@ -169,6 +180,9 @@ func Load(configPath string) *Config {
|
||||
if trustedProxies := os.Getenv("TRUSTED_PROXIES"); trustedProxies != "" {
|
||||
cfg.TrustedProxies = []string{trustedProxies}
|
||||
}
|
||||
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
|
||||
cfg.Notification.TelegramBotToken = telegramToken
|
||||
}
|
||||
|
||||
if cfg.SessionSecret == "" {
|
||||
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")
|
||||
|
||||
@@ -2,6 +2,7 @@ package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/config"
|
||||
"homework-manager/internal/models"
|
||||
@@ -61,6 +62,21 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set connection pool settings
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMaxIdleConns sets the maximum number of connections in the idle connection pool.
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
|
||||
// SetMaxOpenConns sets the maximum number of open connections to the database.
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
|
||||
// SetConnMaxLifetime sets the maximum amount of time a connection may be reused.
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
DB = db
|
||||
return nil
|
||||
}
|
||||
@@ -70,10 +86,10 @@ func Migrate() error {
|
||||
&models.User{},
|
||||
&models.Assignment{},
|
||||
&models.APIKey{},
|
||||
&models.UserNotificationSettings{},
|
||||
)
|
||||
}
|
||||
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate)
|
||||
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, false, nil, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
||||
return
|
||||
@@ -351,7 +351,8 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate)
|
||||
// Preserve existing reminder settings for API updates
|
||||
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, existing.ReminderEnabled, existing.ReminderAt, existing.UrgentReminderEnabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
||||
return
|
||||
@@ -396,3 +397,44 @@ func (h *APIHandler) ToggleAssignment(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, assignment)
|
||||
}
|
||||
|
||||
// GetStatistics returns statistics for the authenticated user
|
||||
// GET /api/v1/statistics?subject=数学&from=2025-01-01&to=2025-12-31&include_archived=true
|
||||
func (h *APIHandler) GetStatistics(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
|
||||
// Parse filter parameters
|
||||
filter := service.StatisticsFilter{
|
||||
Subject: c.Query("subject"),
|
||||
IncludeArchived: c.Query("include_archived") == "true",
|
||||
}
|
||||
|
||||
// Parse from date
|
||||
if fromStr := c.Query("from"); fromStr != "" {
|
||||
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 'from' date format. Use YYYY-MM-DD"})
|
||||
return
|
||||
}
|
||||
filter.From = &fromDate
|
||||
}
|
||||
|
||||
// Parse to date
|
||||
if toStr := c.Query("to"); toStr != "" {
|
||||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 'to' date format. Use YYYY-MM-DD"})
|
||||
return
|
||||
}
|
||||
filter.To = &toDate
|
||||
}
|
||||
|
||||
stats, err := h.assignmentService.GetStatistics(userID, filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,17 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
// Parse reminder settings
|
||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||
reminderAtStr := c.PostForm("reminder_at")
|
||||
var reminderAt *time.Time
|
||||
if reminderEnabled && reminderAtStr != "" {
|
||||
if parsed, err := time.ParseInLocation("2006-01-02T15:04", reminderAtStr, time.Local); err == nil {
|
||||
reminderAt = &parsed
|
||||
}
|
||||
}
|
||||
urgentReminderEnabled := c.PostForm("urgent_reminder_enabled") == "on"
|
||||
|
||||
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||
if err != nil {
|
||||
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
||||
@@ -140,7 +151,7 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
|
||||
_, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate)
|
||||
_, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
if err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
@@ -191,6 +202,17 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
// Parse reminder settings
|
||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||
reminderAtStr := c.PostForm("reminder_at")
|
||||
var reminderAt *time.Time
|
||||
if reminderEnabled && reminderAtStr != "" {
|
||||
if parsed, err := time.ParseInLocation("2006-01-02T15:04", reminderAtStr, time.Local); err == nil {
|
||||
reminderAt = &parsed
|
||||
}
|
||||
}
|
||||
urgentReminderEnabled := c.PostForm("urgent_reminder_enabled") == "on"
|
||||
|
||||
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||
if err != nil {
|
||||
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
||||
@@ -201,7 +223,7 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
|
||||
_, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate)
|
||||
_, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
@@ -231,3 +253,90 @@ func (h *AssignmentHandler) Delete(c *gin.Context) {
|
||||
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
// Parse filter parameters
|
||||
filter := service.StatisticsFilter{
|
||||
Subject: c.Query("subject"),
|
||||
IncludeArchived: c.Query("include_archived") == "true",
|
||||
}
|
||||
|
||||
fromStr := c.Query("from")
|
||||
toStr := c.Query("to")
|
||||
|
||||
// Parse from date
|
||||
if fromStr != "" {
|
||||
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
||||
if err == nil {
|
||||
filter.From = &fromDate
|
||||
}
|
||||
}
|
||||
|
||||
// Parse to date
|
||||
if toStr != "" {
|
||||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
||||
if err == nil {
|
||||
filter.To = &toDate
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := h.assignmentService.GetStatistics(userID, filter)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{
|
||||
"title": "エラー",
|
||||
"message": "統計情報の取得に失敗しました",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get available subjects for filter dropdown (exclude archived)
|
||||
subjects, _ := h.assignmentService.GetSubjectsWithArchived(userID, false)
|
||||
archivedSubjects, _ := h.assignmentService.GetArchivedSubjects(userID)
|
||||
|
||||
// Create a map for quick lookup of archived subjects
|
||||
archivedMap := make(map[string]bool)
|
||||
for _, s := range archivedSubjects {
|
||||
archivedMap[s] = true
|
||||
}
|
||||
|
||||
RenderHTML(c, http.StatusOK, "assignments/statistics.html", gin.H{
|
||||
"title": "統計",
|
||||
"stats": stats,
|
||||
"subjects": subjects,
|
||||
"archivedSubjects": archivedMap,
|
||||
"selectedSubject": filter.Subject,
|
||||
"fromDate": fromStr,
|
||||
"toDate": toStr,
|
||||
"includeArchived": filter.IncludeArchived,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) ArchiveSubject(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
subject := c.PostForm("subject")
|
||||
|
||||
if subject != "" {
|
||||
h.assignmentService.ArchiveSubject(userID, subject)
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/statistics")
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
subject := c.PostForm("subject")
|
||||
|
||||
if subject != "" {
|
||||
h.assignmentService.UnarchiveSubject(userID, subject)
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/statistics?include_archived=true")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,18 +4,21 @@ import (
|
||||
"net/http"
|
||||
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ProfileHandler struct {
|
||||
authService *service.AuthService
|
||||
authService *service.AuthService
|
||||
notificationService *service.NotificationService
|
||||
}
|
||||
|
||||
func NewProfileHandler() *ProfileHandler {
|
||||
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
||||
return &ProfileHandler{
|
||||
authService: service.NewAuthService(),
|
||||
authService: service.NewAuthService(),
|
||||
notificationService: notificationService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,15 +30,17 @@ func (h *ProfileHandler) getUserID(c *gin.Context) uint {
|
||||
func (h *ProfileHandler) Show(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,24 +52,27 @@ func (h *ProfileHandler) Update(c *gin.Context) {
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"error": "プロフィールの更新に失敗しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"error": "プロフィールの更新に失敗しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"success": "プロフィールを更新しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": user.Name,
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"success": "プロフィールを更新しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": user.Name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,25 +85,28 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "新しいパスワードが一致しません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "新しいパスワードが一致しません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(newPassword) < 8 {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "パスワードは8文字以上で入力してください",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "パスワードは8文字以上で入力してください",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -103,11 +114,12 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
||||
err := h.authService.ChangePassword(userID, oldPassword, newPassword)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "現在のパスワードが正しくありません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "現在のパスワードが正しくありません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -118,5 +130,46 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
||||
"passwordSuccess": "パスワードを変更しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
settings := &models.UserNotificationSettings{
|
||||
TelegramEnabled: c.PostForm("telegram_enabled") == "on",
|
||||
TelegramChatID: c.PostForm("telegram_chat_id"),
|
||||
LineEnabled: c.PostForm("line_enabled") == "on",
|
||||
LineNotifyToken: c.PostForm("line_token"),
|
||||
}
|
||||
|
||||
err := h.notificationService.UpdateUserSettings(userID, settings)
|
||||
|
||||
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"notifyError": "通知設定の更新に失敗しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"notifySuccess": "通知設定を更新しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,18 @@ type Assignment struct {
|
||||
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high
|
||||
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||
IsArchived bool `gorm:"default:false;index" json:"is_archived"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
// Reminder notification settings (one-time)
|
||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||
ReminderAt *time.Time `json:"reminder_at,omitempty"`
|
||||
ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"`
|
||||
// Urgent reminder settings (repeating until completed)
|
||||
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
||||
LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
22
internal/models/notification_settings.go
Normal file
22
internal/models/notification_settings.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserNotificationSettings stores user's notification preferences
|
||||
type UserNotificationSettings struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
|
||||
TelegramChatID string `json:"telegram_chat_id"`
|
||||
LineEnabled bool `gorm:"default:false" json:"line_enabled"`
|
||||
LineNotifyToken string `json:"-"` // Hide token from JSON
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
@@ -186,3 +186,181 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error)
|
||||
Where("user_id = ? AND is_completed = ? AND due_date < ?", userID, false, now).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// StatisticsFilter holds filter parameters for statistics queries
|
||||
type StatisticsFilter struct {
|
||||
Subject string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
IncludeArchived bool
|
||||
}
|
||||
|
||||
// AssignmentStatistics holds statistics data
|
||||
type AssignmentStatistics struct {
|
||||
Total int64
|
||||
Completed int64
|
||||
Pending int64
|
||||
Overdue int64
|
||||
CompletedOnTime int64
|
||||
OnTimeCompletionRate float64
|
||||
}
|
||||
|
||||
// SubjectStatistics holds statistics for a specific subject
|
||||
type SubjectStatistics struct {
|
||||
Subject string
|
||||
Total int64
|
||||
Completed int64
|
||||
Pending int64
|
||||
Overdue int64
|
||||
CompletedOnTime int64
|
||||
OnTimeCompletionRate float64
|
||||
}
|
||||
|
||||
// GetStatistics returns statistics for a user with optional filters
|
||||
func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilter) (*AssignmentStatistics, error) {
|
||||
now := time.Now()
|
||||
stats := &AssignmentStatistics{}
|
||||
|
||||
// Base query
|
||||
baseQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
|
||||
|
||||
// Apply subject filter
|
||||
if filter.Subject != "" {
|
||||
baseQuery = baseQuery.Where("subject = ?", filter.Subject)
|
||||
}
|
||||
|
||||
// Apply date range filter (by created_at)
|
||||
if filter.From != nil {
|
||||
baseQuery = baseQuery.Where("created_at >= ?", *filter.From)
|
||||
}
|
||||
if filter.To != nil {
|
||||
// Add 1 day to include the entire "to" date
|
||||
toEnd := filter.To.AddDate(0, 0, 1)
|
||||
baseQuery = baseQuery.Where("created_at < ?", toEnd)
|
||||
}
|
||||
|
||||
// Apply archived filter
|
||||
if !filter.IncludeArchived {
|
||||
baseQuery = baseQuery.Where("is_archived = ?", false)
|
||||
}
|
||||
|
||||
// Total count
|
||||
if err := baseQuery.Count(&stats.Total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Completed count
|
||||
completedQuery := baseQuery.Session(&gorm.Session{})
|
||||
if err := completedQuery.Where("is_completed = ?", true).Count(&stats.Completed).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pending count
|
||||
pendingQuery := baseQuery.Session(&gorm.Session{})
|
||||
if err := pendingQuery.Where("is_completed = ?", false).Count(&stats.Pending).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Overdue count
|
||||
overdueQuery := baseQuery.Session(&gorm.Session{})
|
||||
if err := overdueQuery.Where("is_completed = ? AND due_date < ?", false, now).Count(&stats.Overdue).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Completed on time count (completed_at <= due_date)
|
||||
onTimeQuery := baseQuery.Session(&gorm.Session{})
|
||||
if err := onTimeQuery.Where("is_completed = ? AND completed_at <= due_date", true).Count(&stats.CompletedOnTime).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate on-time completion rate
|
||||
if stats.Completed > 0 {
|
||||
stats.OnTimeCompletionRate = float64(stats.CompletedOnTime) / float64(stats.Completed) * 100
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetStatisticsBySubjects returns statistics grouped by subject
|
||||
func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter StatisticsFilter) ([]SubjectStatistics, error) {
|
||||
now := time.Now()
|
||||
subjects, err := r.GetSubjectsByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []SubjectStatistics
|
||||
for _, subject := range subjects {
|
||||
subjectFilter := StatisticsFilter{
|
||||
Subject: subject,
|
||||
From: filter.From,
|
||||
To: filter.To,
|
||||
}
|
||||
stats, err := r.GetStatistics(userID, subjectFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Count overdue for this subject
|
||||
overdueQuery := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND subject = ? AND is_completed = ? AND due_date < ?", userID, subject, false, now)
|
||||
if filter.From != nil {
|
||||
overdueQuery = overdueQuery.Where("created_at >= ?", *filter.From)
|
||||
}
|
||||
if filter.To != nil {
|
||||
toEnd := filter.To.AddDate(0, 0, 1)
|
||||
overdueQuery = overdueQuery.Where("created_at < ?", toEnd)
|
||||
}
|
||||
var overdueCount int64
|
||||
overdueQuery.Count(&overdueCount)
|
||||
|
||||
results = append(results, SubjectStatistics{
|
||||
Subject: subject,
|
||||
Total: stats.Total,
|
||||
Completed: stats.Completed,
|
||||
Pending: stats.Pending,
|
||||
Overdue: overdueCount,
|
||||
CompletedOnTime: stats.CompletedOnTime,
|
||||
OnTimeCompletionRate: stats.OnTimeCompletionRate,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ArchiveBySubject archives all assignments for a subject
|
||||
func (r *AssignmentRepository) ArchiveBySubject(userID uint, subject string) error {
|
||||
return r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND subject = ?", userID, subject).
|
||||
Update("is_archived", true).Error
|
||||
}
|
||||
|
||||
// UnarchiveBySubject unarchives all assignments for a subject
|
||||
func (r *AssignmentRepository) UnarchiveBySubject(userID uint, subject string) error {
|
||||
return r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND subject = ?", userID, subject).
|
||||
Update("is_archived", false).Error
|
||||
}
|
||||
|
||||
// GetArchivedSubjects returns a list of archived subjects for a user
|
||||
func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error) {
|
||||
var subjects []string
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_archived = ? AND subject != ''", userID, true).
|
||||
Distinct("subject").
|
||||
Pluck("subject", &subjects).Error
|
||||
return subjects, err
|
||||
}
|
||||
|
||||
// GetSubjectsByUserIDWithArchived returns subjects optionally including archived
|
||||
func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) {
|
||||
var subjects []string
|
||||
query := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND subject != ''", userID)
|
||||
if !includeArchived {
|
||||
query = query.Where("is_archived = ?", false)
|
||||
}
|
||||
err := query.Distinct("subject").Pluck("subject", &subjects).Error
|
||||
return subjects, err
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ func getFuncMap() template.FuncMap {
|
||||
"daysUntil": func(t time.Time) int {
|
||||
return int(time.Until(t).Hours() / 24)
|
||||
},
|
||||
"divideFloat": func(a, b int64) float64 {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(a) / float64(b)
|
||||
},
|
||||
"multiplyFloat": func(a float64, b float64) float64 {
|
||||
return a * b
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +173,15 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
authService := service.NewAuthService()
|
||||
apiKeyService := service.NewAPIKeyService()
|
||||
notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken)
|
||||
|
||||
// Start notification reminder scheduler
|
||||
notificationService.StartReminderScheduler()
|
||||
|
||||
authHandler := handler.NewAuthHandler()
|
||||
assignmentHandler := handler.NewAssignmentHandler()
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
profileHandler := handler.NewProfileHandler()
|
||||
profileHandler := handler.NewProfileHandler(notificationService)
|
||||
apiHandler := handler.NewAPIHandler()
|
||||
|
||||
guest := r.Group("/")
|
||||
@@ -205,9 +218,15 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle)
|
||||
auth.POST("/assignments/:id/delete", assignmentHandler.Delete)
|
||||
|
||||
auth.GET("/statistics", assignmentHandler.Statistics)
|
||||
auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject)
|
||||
auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject)
|
||||
|
||||
auth.GET("/profile", profileHandler.Show)
|
||||
auth.POST("/profile", profileHandler.Update)
|
||||
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
|
||||
|
||||
admin := auth.Group("/admin")
|
||||
admin.Use(middleware.AdminRequired())
|
||||
{
|
||||
@@ -235,6 +254,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
|
||||
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
|
||||
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
|
||||
api.GET("/statistics", apiHandler.GetStatistics)
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
@@ -31,18 +31,22 @@ func NewAssignmentService() *AssignmentService {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) {
|
||||
func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) {
|
||||
if priority == "" {
|
||||
priority = "medium"
|
||||
}
|
||||
assignment := &models.Assignment{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
DueDate: dueDate,
|
||||
IsCompleted: false,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
DueDate: dueDate,
|
||||
IsCompleted: false,
|
||||
ReminderEnabled: reminderEnabled,
|
||||
ReminderAt: reminderAt,
|
||||
ReminderSent: false,
|
||||
UrgentReminderEnabled: urgentReminderEnabled,
|
||||
}
|
||||
|
||||
if err := s.assignmentRepo.Create(assignment); err != nil {
|
||||
@@ -191,7 +195,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) {
|
||||
func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) {
|
||||
assignment, err := s.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -202,6 +206,13 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description
|
||||
assignment.Subject = subject
|
||||
assignment.Priority = priority
|
||||
assignment.DueDate = dueDate
|
||||
assignment.ReminderEnabled = reminderEnabled
|
||||
assignment.ReminderAt = reminderAt
|
||||
assignment.UrgentReminderEnabled = urgentReminderEnabled
|
||||
// Reset reminder sent flag if reminder settings changed
|
||||
if reminderEnabled && reminderAt != nil {
|
||||
assignment.ReminderSent = false
|
||||
}
|
||||
|
||||
if err := s.assignmentRepo.Update(assignment); err != nil {
|
||||
return nil, err
|
||||
@@ -267,3 +278,133 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err
|
||||
Subjects: subjects,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StatisticsFilter holds filter parameters for statistics
|
||||
type StatisticsFilter struct {
|
||||
Subject string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
IncludeArchived bool
|
||||
}
|
||||
|
||||
// SubjectStats holds statistics for a subject
|
||||
type SubjectStats struct {
|
||||
Subject string `json:"subject"`
|
||||
Total int64 `json:"total"`
|
||||
Completed int64 `json:"completed"`
|
||||
Pending int64 `json:"pending"`
|
||||
Overdue int64 `json:"overdue"`
|
||||
OnTimeCompletionRate float64 `json:"on_time_completion_rate"`
|
||||
IsArchived bool `json:"is_archived,omitempty"`
|
||||
}
|
||||
|
||||
// StatisticsSummary holds overall statistics
|
||||
type StatisticsSummary struct {
|
||||
TotalAssignments int64 `json:"total_assignments"`
|
||||
CompletedAssignments int64 `json:"completed_assignments"`
|
||||
PendingAssignments int64 `json:"pending_assignments"`
|
||||
OverdueAssignments int64 `json:"overdue_assignments"`
|
||||
OnTimeCompletionRate float64 `json:"on_time_completion_rate"`
|
||||
Filter *FilterInfo `json:"filter,omitempty"`
|
||||
Subjects []SubjectStats `json:"subjects,omitempty"`
|
||||
}
|
||||
|
||||
// FilterInfo shows applied filters in response
|
||||
type FilterInfo struct {
|
||||
Subject *string `json:"subject"`
|
||||
From *string `json:"from"`
|
||||
To *string `json:"to"`
|
||||
IncludeArchived bool `json:"include_archived"`
|
||||
}
|
||||
|
||||
// GetStatistics returns statistics for a user with optional filters
|
||||
func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) (*StatisticsSummary, error) {
|
||||
// Convert filter to repository filter
|
||||
repoFilter := repository.StatisticsFilter{
|
||||
Subject: filter.Subject,
|
||||
From: filter.From,
|
||||
To: filter.To,
|
||||
IncludeArchived: filter.IncludeArchived,
|
||||
}
|
||||
|
||||
// Get overall statistics
|
||||
stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary := &StatisticsSummary{
|
||||
TotalAssignments: stats.Total,
|
||||
CompletedAssignments: stats.Completed,
|
||||
PendingAssignments: stats.Pending,
|
||||
OverdueAssignments: stats.Overdue,
|
||||
OnTimeCompletionRate: stats.OnTimeCompletionRate,
|
||||
}
|
||||
|
||||
// Build filter info
|
||||
filterInfo := &FilterInfo{}
|
||||
hasFilter := false
|
||||
if filter.Subject != "" {
|
||||
filterInfo.Subject = &filter.Subject
|
||||
hasFilter = true
|
||||
}
|
||||
if filter.From != nil {
|
||||
fromStr := filter.From.Format("2006-01-02")
|
||||
filterInfo.From = &fromStr
|
||||
hasFilter = true
|
||||
}
|
||||
if filter.To != nil {
|
||||
toStr := filter.To.Format("2006-01-02")
|
||||
filterInfo.To = &toStr
|
||||
hasFilter = true
|
||||
}
|
||||
filterInfo.IncludeArchived = filter.IncludeArchived
|
||||
if filter.IncludeArchived {
|
||||
hasFilter = true
|
||||
}
|
||||
if hasFilter {
|
||||
summary.Filter = filterInfo
|
||||
}
|
||||
|
||||
// If no specific subject filter, get per-subject statistics
|
||||
if filter.Subject == "" {
|
||||
subjectStats, err := s.assignmentRepo.GetStatisticsBySubjects(userID, repoFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, ss := range subjectStats {
|
||||
summary.Subjects = append(summary.Subjects, SubjectStats{
|
||||
Subject: ss.Subject,
|
||||
Total: ss.Total,
|
||||
Completed: ss.Completed,
|
||||
Pending: ss.Pending,
|
||||
Overdue: ss.Overdue,
|
||||
OnTimeCompletionRate: ss.OnTimeCompletionRate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// ArchiveSubject archives all assignments for a subject
|
||||
func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error {
|
||||
return s.assignmentRepo.ArchiveBySubject(userID, subject)
|
||||
}
|
||||
|
||||
// UnarchiveSubject unarchives all assignments for a subject
|
||||
func (s *AssignmentService) UnarchiveSubject(userID uint, subject string) error {
|
||||
return s.assignmentRepo.UnarchiveBySubject(userID, subject)
|
||||
}
|
||||
|
||||
// GetSubjectsWithArchived returns subjects optionally including archived
|
||||
func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived bool) ([]string, error) {
|
||||
return s.assignmentRepo.GetSubjectsByUserIDWithArchived(userID, includeArchived)
|
||||
}
|
||||
|
||||
// GetArchivedSubjects returns archived subjects only
|
||||
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
|
||||
return s.assignmentRepo.GetArchivedSubjects(userID)
|
||||
}
|
||||
|
||||
|
||||
330
internal/service/notification_service.go
Normal file
330
internal/service/notification_service.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/database"
|
||||
"homework-manager/internal/models"
|
||||
)
|
||||
|
||||
// NotificationService handles Telegram and LINE notifications
|
||||
type NotificationService struct {
|
||||
telegramBotToken string
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new notification service
|
||||
func NewNotificationService(telegramBotToken string) *NotificationService {
|
||||
return &NotificationService{
|
||||
telegramBotToken: telegramBotToken,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserSettings retrieves notification settings for a user
|
||||
func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) {
|
||||
var settings models.UserNotificationSettings
|
||||
result := database.GetDB().Where("user_id = ?", userID).First(&settings)
|
||||
if result.Error != nil {
|
||||
// If not found, return a new empty settings object
|
||||
if result.RowsAffected == 0 {
|
||||
return &models.UserNotificationSettings{
|
||||
UserID: userID,
|
||||
}, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// UpdateUserSettings updates notification settings for a user
|
||||
func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error {
|
||||
settings.UserID = userID
|
||||
|
||||
var existing models.UserNotificationSettings
|
||||
result := database.GetDB().Where("user_id = ?", userID).First(&existing)
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
// Create new
|
||||
return database.GetDB().Create(settings).Error
|
||||
}
|
||||
|
||||
// Update existing
|
||||
settings.ID = existing.ID
|
||||
return database.GetDB().Save(settings).Error
|
||||
}
|
||||
|
||||
// SendTelegramNotification sends a message via Telegram Bot API
|
||||
func (s *NotificationService) SendTelegramNotification(chatID, message string) error {
|
||||
if s.telegramBotToken == "" {
|
||||
return fmt.Errorf("telegram bot token is not configured")
|
||||
}
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("telegram chat ID is empty")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken)
|
||||
|
||||
payload := map[string]string{
|
||||
"chat_id": chatID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendLineNotification sends a message via LINE Notify API
|
||||
func (s *NotificationService) SendLineNotification(token, message string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("LINE Notify token is empty")
|
||||
}
|
||||
|
||||
apiURL := "https://notify-api.line.me/api/notify"
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("message", message)
|
||||
|
||||
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("LINE Notify API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAssignmentReminder sends a reminder notification for an assignment
|
||||
func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
|
||||
settings, err := s.GetUserSettings(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := fmt.Sprintf(
|
||||
"📚 課題リマインダー\n\n【%s】\n科目: %s\n期限: %s\n\n%s",
|
||||
assignment.Title,
|
||||
assignment.Subject,
|
||||
assignment.DueDate.Format("2006/01/02 15:04"),
|
||||
assignment.Description,
|
||||
)
|
||||
|
||||
var errors []string
|
||||
|
||||
// Send to Telegram if enabled
|
||||
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
||||
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Send to LINE if enabled
|
||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendUrgentReminder sends an urgent reminder notification for an assignment
|
||||
func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error {
|
||||
settings, err := s.GetUserSettings(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
timeRemaining := time.Until(assignment.DueDate)
|
||||
var timeStr string
|
||||
if timeRemaining < 0 {
|
||||
timeStr = "期限切れ!"
|
||||
} else if timeRemaining < time.Hour {
|
||||
timeStr = fmt.Sprintf("あと%d分", int(timeRemaining.Minutes()))
|
||||
} else {
|
||||
timeStr = fmt.Sprintf("あと%d時間%d分", int(timeRemaining.Hours()), int(timeRemaining.Minutes())%60)
|
||||
}
|
||||
|
||||
priorityEmoji := "📌"
|
||||
switch assignment.Priority {
|
||||
case "high":
|
||||
priorityEmoji = "🚨"
|
||||
case "medium":
|
||||
priorityEmoji = "⚠️"
|
||||
case "low":
|
||||
priorityEmoji = "📌"
|
||||
}
|
||||
|
||||
message := fmt.Sprintf(
|
||||
"%s 督促通知!\n\n【%s】\n科目: %s\n期限: %s (%s)\n\n完了したらアプリで完了ボタンを押してください!",
|
||||
priorityEmoji,
|
||||
assignment.Title,
|
||||
assignment.Subject,
|
||||
assignment.DueDate.Format("2006/01/02 15:04"),
|
||||
timeStr,
|
||||
)
|
||||
|
||||
var errors []string
|
||||
|
||||
// Send to Telegram if enabled
|
||||
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
||||
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Send to LINE if enabled
|
||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUrgentReminderInterval returns the reminder interval based on priority
|
||||
// high=10min, medium=30min, low=60min
|
||||
func getUrgentReminderInterval(priority string) time.Duration {
|
||||
switch priority {
|
||||
case "high":
|
||||
return 10 * time.Minute
|
||||
case "medium":
|
||||
return 30 * time.Minute
|
||||
case "low":
|
||||
return 60 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessPendingReminders checks and sends pending one-time reminders
|
||||
func (s *NotificationService) ProcessPendingReminders() {
|
||||
now := time.Now()
|
||||
|
||||
var assignments []models.Assignment
|
||||
result := database.GetDB().Where(
|
||||
"reminder_enabled = ? AND reminder_sent = ? AND reminder_at <= ? AND is_completed = ?",
|
||||
true, false, now, false,
|
||||
).Find(&assignments)
|
||||
|
||||
if result.Error != nil {
|
||||
log.Printf("Error fetching pending reminders: %v", result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
for _, assignment := range assignments {
|
||||
if err := s.SendAssignmentReminder(assignment.UserID, &assignment); err != nil {
|
||||
log.Printf("Error sending reminder for assignment %d: %v", assignment.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as sent
|
||||
database.GetDB().Model(&assignment).Update("reminder_sent", true)
|
||||
log.Printf("Sent reminder for assignment %d to user %d", assignment.ID, assignment.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessUrgentReminders checks and sends urgent (repeating) reminders
|
||||
// Starts 3 hours before deadline, repeats at interval based on priority
|
||||
func (s *NotificationService) ProcessUrgentReminders() {
|
||||
now := time.Now()
|
||||
urgentStartTime := 3 * time.Hour // Start 3 hours before deadline
|
||||
|
||||
var assignments []models.Assignment
|
||||
result := database.GetDB().Where(
|
||||
"urgent_reminder_enabled = ? AND is_completed = ? AND due_date > ?",
|
||||
true, false, now,
|
||||
).Find(&assignments)
|
||||
|
||||
if result.Error != nil {
|
||||
log.Printf("Error fetching urgent reminders: %v", result.Error)
|
||||
return
|
||||
}
|
||||
|
||||
for _, assignment := range assignments {
|
||||
timeUntilDue := assignment.DueDate.Sub(now)
|
||||
|
||||
// Only send if within 3 hours of deadline
|
||||
if timeUntilDue > urgentStartTime {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if enough time has passed since last urgent reminder
|
||||
interval := getUrgentReminderInterval(assignment.Priority)
|
||||
|
||||
if assignment.LastUrgentReminderSent != nil {
|
||||
timeSinceLastReminder := now.Sub(*assignment.LastUrgentReminderSent)
|
||||
if timeSinceLastReminder < interval {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Send urgent reminder
|
||||
if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil {
|
||||
log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update last sent time
|
||||
database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now)
|
||||
log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d",
|
||||
assignment.ID, assignment.Priority, assignment.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// StartReminderScheduler starts a background goroutine to process reminders
|
||||
func (s *NotificationService) StartReminderScheduler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
s.ProcessPendingReminders()
|
||||
s.ProcessUrgentReminders()
|
||||
}
|
||||
}()
|
||||
log.Println("Reminder scheduler started (one-time + urgent reminders)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user