繰り返し追加機能の追加

This commit is contained in:
2026-01-07 23:38:23 +09:00
parent b2fbb472df
commit 920928746e
13 changed files with 964 additions and 129 deletions

View File

@@ -209,7 +209,6 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description
assignment.ReminderEnabled = reminderEnabled
assignment.ReminderAt = reminderAt
assignment.UrgentReminderEnabled = urgentReminderEnabled
// Reset reminder sent flag if reminder settings changed
if reminderEnabled && reminderAt != nil {
assignment.ReminderSent = false
}
@@ -279,7 +278,6 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err
}, nil
}
// StatisticsFilter holds filter parameters for statistics
type StatisticsFilter struct {
Subject string
From *time.Time
@@ -287,7 +285,6 @@ type StatisticsFilter struct {
IncludeArchived bool
}
// SubjectStats holds statistics for a subject
type SubjectStats struct {
Subject string `json:"subject"`
Total int64 `json:"total"`
@@ -298,7 +295,6 @@ type SubjectStats struct {
IsArchived bool `json:"is_archived,omitempty"`
}
// StatisticsSummary holds overall statistics
type StatisticsSummary struct {
TotalAssignments int64 `json:"total_assignments"`
CompletedAssignments int64 `json:"completed_assignments"`
@@ -309,7 +305,6 @@ type StatisticsSummary struct {
Subjects []SubjectStats `json:"subjects,omitempty"`
}
// FilterInfo shows applied filters in response
type FilterInfo struct {
Subject *string `json:"subject"`
From *string `json:"from"`
@@ -317,9 +312,7 @@ type FilterInfo struct {
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,
@@ -327,7 +320,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
IncludeArchived: filter.IncludeArchived,
}
// Get overall statistics
stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter)
if err != nil {
return nil, err
@@ -341,7 +333,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
OnTimeCompletionRate: stats.OnTimeCompletionRate,
}
// Build filter info
filterInfo := &FilterInfo{}
hasFilter := false
if filter.Subject != "" {
@@ -366,7 +357,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
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 {
@@ -388,22 +378,17 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
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)
}

View File

@@ -14,24 +14,21 @@ import (
"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,
@@ -42,7 +39,6 @@ func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotifica
return &settings, nil
}
// UpdateUserSettings updates notification settings for a user
func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error {
settings.UserID = userID
@@ -50,16 +46,12 @@ func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.U
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")
@@ -94,7 +86,6 @@ func (s *NotificationService) SendTelegramNotification(chatID, message string) e
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")
@@ -127,7 +118,6 @@ func (s *NotificationService) SendLineNotification(token, message string) error
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 {
@@ -144,14 +134,12 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
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))
@@ -165,7 +153,6 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
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 {
@@ -203,14 +190,12 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
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))
@@ -224,8 +209,6 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
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":
@@ -239,7 +222,6 @@ func getUrgentReminderInterval(priority string) time.Duration {
}
}
// ProcessPendingReminders checks and sends pending one-time reminders
func (s *NotificationService) ProcessPendingReminders() {
now := time.Now()
@@ -260,17 +242,14 @@ func (s *NotificationService) ProcessPendingReminders() {
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
urgentStartTime := 3 * time.Hour
var assignments []models.Assignment
result := database.GetDB().Where(
@@ -286,12 +265,10 @@ func (s *NotificationService) ProcessUrgentReminders() {
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 {
@@ -301,20 +278,17 @@ func (s *NotificationService) ProcessUrgentReminders() {
}
}
// 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)

View File

@@ -0,0 +1,467 @@
package service
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"homework-manager/internal/models"
"homework-manager/internal/repository"
)
var (
ErrRecurringAssignmentNotFound = errors.New("recurring assignment not found")
ErrRecurringUnauthorized = errors.New("unauthorized")
ErrInvalidRecurrenceType = errors.New("invalid recurrence type")
ErrInvalidEndType = errors.New("invalid end type")
)
type RecurringAssignmentService struct {
recurringRepo *repository.RecurringAssignmentRepository
assignmentRepo *repository.AssignmentRepository
}
func NewRecurringAssignmentService() *RecurringAssignmentService {
return &RecurringAssignmentService{
recurringRepo: repository.NewRecurringAssignmentRepository(),
assignmentRepo: repository.NewAssignmentRepository(),
}
}
type CreateRecurringInput struct {
Title string
Description string
Subject string
Priority string
RecurrenceType string
RecurrenceInterval int
RecurrenceWeekday *int
RecurrenceDay *int
DueTime string
EndType string
EndCount *int
EndDate *time.Time
EditBehavior string
ReminderEnabled bool
ReminderOffset *int
UrgentReminderEnabled bool
FirstDueDate time.Time
}
func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringInput) (*models.RecurringAssignment, error) {
if !isValidRecurrenceType(input.RecurrenceType) {
return nil, ErrInvalidRecurrenceType
}
if !isValidEndType(input.EndType) {
return nil, ErrInvalidEndType
}
if input.RecurrenceInterval < 1 {
input.RecurrenceInterval = 1
}
if input.EditBehavior == "" {
input.EditBehavior = models.EditBehaviorThisOnly
}
recurring := &models.RecurringAssignment{
UserID: userID,
Title: input.Title,
Description: input.Description,
Subject: input.Subject,
Priority: input.Priority,
RecurrenceType: input.RecurrenceType,
RecurrenceInterval: input.RecurrenceInterval,
RecurrenceWeekday: input.RecurrenceWeekday,
RecurrenceDay: input.RecurrenceDay,
DueTime: input.DueTime,
EndType: input.EndType,
EndCount: input.EndCount,
EndDate: input.EndDate,
EditBehavior: input.EditBehavior,
ReminderEnabled: input.ReminderEnabled,
ReminderOffset: input.ReminderOffset,
UrgentReminderEnabled: input.UrgentReminderEnabled,
IsActive: true,
GeneratedCount: 0,
}
if err := s.recurringRepo.Create(recurring); err != nil {
return nil, err
}
if err := s.generateAssignment(recurring, input.FirstDueDate); err != nil {
return nil, err
}
return recurring, nil
}
func (s *RecurringAssignmentService) GetByID(userID, recurringID uint) (*models.RecurringAssignment, error) {
recurring, err := s.recurringRepo.FindByID(recurringID)
if err != nil {
return nil, ErrRecurringAssignmentNotFound
}
if recurring.UserID != userID {
return nil, ErrRecurringUnauthorized
}
return recurring, nil
}
func (s *RecurringAssignmentService) GetAllByUser(userID uint) ([]models.RecurringAssignment, error) {
return s.recurringRepo.FindByUserID(userID)
}
func (s *RecurringAssignmentService) GetActiveByUser(userID uint) ([]models.RecurringAssignment, error) {
return s.recurringRepo.FindActiveByUserID(userID)
}
type UpdateRecurringInput struct {
Title string
Description string
Subject string
Priority string
DueTime string
EditBehavior string
ReminderEnabled bool
ReminderOffset *int
UrgentReminderEnabled bool
}
func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) {
recurring, err := s.GetByID(userID, recurringID)
if err != nil {
return nil, err
}
recurring.Title = input.Title
recurring.Description = input.Description
recurring.Subject = input.Subject
recurring.Priority = input.Priority
if input.DueTime != "" {
recurring.DueTime = input.DueTime
}
if input.EditBehavior != "" {
recurring.EditBehavior = input.EditBehavior
}
recurring.ReminderEnabled = input.ReminderEnabled
recurring.ReminderOffset = input.ReminderOffset
recurring.UrgentReminderEnabled = input.UrgentReminderEnabled
if err := s.recurringRepo.Update(recurring); err != nil {
return nil, err
}
return recurring, nil
}
func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior(
userID uint,
assignment *models.Assignment,
title, description, subject, priority string,
dueDate time.Time,
reminderEnabled bool,
reminderAt *time.Time,
urgentReminderEnabled bool,
editBehavior string,
) error {
if assignment.RecurringAssignmentID == nil {
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
}
recurring, err := s.GetByID(userID, *assignment.RecurringAssignmentID)
if err != nil {
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
}
switch editBehavior {
case models.EditBehaviorThisOnly:
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
case models.EditBehaviorThisAndFuture:
if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil {
return err
}
recurring.Title = title
recurring.Description = description
recurring.Subject = subject
recurring.Priority = priority
recurring.UrgentReminderEnabled = urgentReminderEnabled
if err := s.recurringRepo.Update(recurring); err != nil {
return err
}
return s.updateFutureAssignments(recurring.ID, assignment.DueDate, title, description, subject, priority, urgentReminderEnabled)
case models.EditBehaviorAll:
recurring.Title = title
recurring.Description = description
recurring.Subject = subject
recurring.Priority = priority
recurring.UrgentReminderEnabled = urgentReminderEnabled
if err := s.recurringRepo.Update(recurring); err != nil {
return err
}
return s.updateAllPendingAssignments(recurring.ID, title, description, subject, priority, urgentReminderEnabled)
default:
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
}
}
func (s *RecurringAssignmentService) updateSingleAssignment(
assignment *models.Assignment,
title, description, subject, priority string,
dueDate time.Time,
reminderEnabled bool,
reminderAt *time.Time,
urgentReminderEnabled bool,
) error {
assignment.Title = title
assignment.Description = description
assignment.Subject = subject
assignment.Priority = priority
assignment.DueDate = dueDate
assignment.ReminderEnabled = reminderEnabled
assignment.ReminderAt = reminderAt
assignment.UrgentReminderEnabled = urgentReminderEnabled
return s.assignmentRepo.Update(assignment)
}
func (s *RecurringAssignmentService) updateFutureAssignments(
recurringID uint,
fromDate time.Time,
title, description, subject, priority string,
urgentReminderEnabled bool,
) error {
assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, fromDate)
if err != nil {
return err
}
for _, a := range assignments {
if a.IsCompleted {
continue
}
a.Title = title
a.Description = description
a.Subject = subject
a.Priority = priority
a.UrgentReminderEnabled = urgentReminderEnabled
if err := s.assignmentRepo.Update(&a); err != nil {
return err
}
}
return nil
}
func (s *RecurringAssignmentService) updateAllPendingAssignments(
recurringID uint,
title, description, subject, priority string,
urgentReminderEnabled bool,
) error {
assignments, err := s.recurringRepo.GetAssignmentsByRecurringID(recurringID)
if err != nil {
return err
}
for _, a := range assignments {
if a.IsCompleted {
continue
}
a.Title = title
a.Description = description
a.Subject = subject
a.Priority = priority
a.UrgentReminderEnabled = urgentReminderEnabled
if err := s.assignmentRepo.Update(&a); err != nil {
return err
}
}
return nil
}
func (s *RecurringAssignmentService) Delete(userID, recurringID uint, deleteFutureAssignments bool) error {
recurring, err := s.GetByID(userID, recurringID)
if err != nil {
return err
}
if deleteFutureAssignments {
assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, time.Now())
if err != nil {
return err
}
for _, a := range assignments {
if !a.IsCompleted {
s.assignmentRepo.Delete(a.ID)
}
}
}
return s.recurringRepo.Delete(recurring.ID)
}
func (s *RecurringAssignmentService) GenerateNextAssignments() error {
recurrings, err := s.recurringRepo.FindDueForGeneration()
if err != nil {
return err
}
for _, recurring := range recurrings {
pendingCount, err := s.recurringRepo.CountPendingByRecurringID(recurring.ID)
if err != nil {
continue
}
if pendingCount == 0 {
latest, err := s.recurringRepo.GetLatestAssignmentByRecurringID(recurring.ID)
if err != nil {
continue
}
var nextDueDate time.Time
if latest != nil {
nextDueDate = recurring.CalculateNextDueDate(latest.DueDate)
} else {
nextDueDate = time.Now()
}
if nextDueDate.After(time.Now()) {
s.generateAssignment(&recurring, nextDueDate)
}
}
}
return nil
}
func (s *RecurringAssignmentService) generateAssignment(recurring *models.RecurringAssignment, dueDate time.Time) error {
if recurring.DueTime != "" {
parts := strings.Split(recurring.DueTime, ":")
if len(parts) == 2 {
hour, _ := strconv.Atoi(parts[0])
minute, _ := strconv.Atoi(parts[1])
dueDate = time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), hour, minute, 0, 0, dueDate.Location())
}
}
var reminderAt *time.Time
if recurring.ReminderEnabled && recurring.ReminderOffset != nil {
t := dueDate.Add(-time.Duration(*recurring.ReminderOffset) * time.Minute)
reminderAt = &t
}
assignment := &models.Assignment{
UserID: userID(recurring.UserID),
Title: recurring.Title,
Description: recurring.Description,
Subject: recurring.Subject,
Priority: recurring.Priority,
DueDate: dueDate,
ReminderEnabled: recurring.ReminderEnabled,
ReminderAt: reminderAt,
UrgentReminderEnabled: recurring.UrgentReminderEnabled,
RecurringAssignmentID: &recurring.ID,
}
if err := s.assignmentRepo.Create(assignment); err != nil {
return err
}
recurring.GeneratedCount++
return s.recurringRepo.Update(recurring)
}
func userID(id uint) uint {
return id
}
func isValidRecurrenceType(t string) bool {
switch t {
case models.RecurrenceNone, models.RecurrenceDaily, models.RecurrenceWeekly, models.RecurrenceMonthly:
return true
}
return false
}
func isValidEndType(t string) bool {
switch t {
case models.EndTypeNever, models.EndTypeCount, models.EndTypeDate:
return true
}
return false
}
func GetRecurrenceTypeLabel(t string) string {
switch t {
case models.RecurrenceDaily:
return "毎日"
case models.RecurrenceWeekly:
return "毎週"
case models.RecurrenceMonthly:
return "毎月"
default:
return "なし"
}
}
func GetEndTypeLabel(t string) string {
switch t {
case models.EndTypeCount:
return "回数指定"
case models.EndTypeDate:
return "終了日指定"
default:
return "無期限"
}
}
func FormatRecurringSummary(recurring *models.RecurringAssignment) string {
if recurring.RecurrenceType == models.RecurrenceNone {
return ""
}
var parts []string
typeLabel := GetRecurrenceTypeLabel(recurring.RecurrenceType)
if recurring.RecurrenceInterval > 1 {
switch recurring.RecurrenceType {
case models.RecurrenceDaily:
parts = append(parts, fmt.Sprintf("%d日ごと", recurring.RecurrenceInterval))
case models.RecurrenceWeekly:
parts = append(parts, fmt.Sprintf("%d週間ごと", recurring.RecurrenceInterval))
case models.RecurrenceMonthly:
parts = append(parts, fmt.Sprintf("%dヶ月ごと", recurring.RecurrenceInterval))
}
} else {
parts = append(parts, typeLabel)
}
if recurring.RecurrenceType == models.RecurrenceWeekly && recurring.RecurrenceWeekday != nil {
weekdays := []string{"日", "月", "火", "水", "木", "金", "土"}
if *recurring.RecurrenceWeekday >= 0 && *recurring.RecurrenceWeekday < 7 {
parts = append(parts, fmt.Sprintf("(%s曜日)", weekdays[*recurring.RecurrenceWeekday]))
}
}
if recurring.RecurrenceType == models.RecurrenceMonthly && recurring.RecurrenceDay != nil {
parts = append(parts, fmt.Sprintf("(%d日)", *recurring.RecurrenceDay))
}
switch recurring.EndType {
case models.EndTypeCount:
if recurring.EndCount != nil {
parts = append(parts, fmt.Sprintf("/ %d回まで", *recurring.EndCount))
}
case models.EndTypeDate:
if recurring.EndDate != nil {
parts = append(parts, fmt.Sprintf("/ %sまで", recurring.EndDate.Format("2006/01/02")))
}
}
return strings.Join(parts, " ")
}