package service import ( "encoding/json" "errors" "regexp" "sort" "strings" "carrot_bbs/internal/dto" "carrot_bbs/internal/model" "carrot_bbs/internal/repository" "gorm.io/gorm" ) var ( ErrInvalidSchedulePayload = &ServiceError{Code: 400, Message: "invalid schedule payload"} ErrScheduleCourseNotFound = &ServiceError{Code: 404, Message: "schedule course not found"} ErrScheduleForbidden = &ServiceError{Code: 403, Message: "forbidden schedule operation"} ErrScheduleColorDuplicated = &ServiceError{Code: 400, Message: "course color already used"} ) var hexColorRegex = regexp.MustCompile(`^#[0-9A-F]{6}$`) type CreateScheduleCourseInput struct { Name string Teacher string Location string DayOfWeek int StartSection int EndSection int Weeks []int Color string } type ScheduleService interface { ListCourses(userID string, week int) ([]*dto.ScheduleCourseResponse, error) CreateCourse(userID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error) UpdateCourse(userID, courseID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error) DeleteCourse(userID, courseID string) error } type scheduleService struct { repo repository.ScheduleRepository } func NewScheduleService(repo repository.ScheduleRepository) ScheduleService { return &scheduleService{repo: repo} } func (s *scheduleService) ListCourses(userID string, week int) ([]*dto.ScheduleCourseResponse, error) { courses, err := s.repo.ListByUserID(userID) if err != nil { return nil, err } result := make([]*dto.ScheduleCourseResponse, 0, len(courses)) for _, item := range courses { weeks := dto.ParseWeeksJSON(item.Weeks) if week > 0 && !containsWeek(weeks, week) { continue } result = append(result, dto.ConvertScheduleCourseToResponse(item, weeks)) } return result, nil } func (s *scheduleService) CreateCourse(userID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error) { entity, weeks, err := buildScheduleEntity(userID, input, nil) if err != nil { return nil, err } if err := s.ensureUniqueColor(userID, entity.Color, ""); err != nil { return nil, err } if err := s.repo.Create(entity); err != nil { return nil, err } return dto.ConvertScheduleCourseToResponse(entity, weeks), nil } func (s *scheduleService) UpdateCourse(userID, courseID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error) { existing, err := s.repo.GetByID(courseID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrScheduleCourseNotFound } return nil, err } if existing.UserID != userID { return nil, ErrScheduleForbidden } entity, weeks, err := buildScheduleEntity(userID, input, existing) if err != nil { return nil, err } if err := s.ensureUniqueColor(userID, entity.Color, entity.ID); err != nil { return nil, err } if err := s.repo.Update(entity); err != nil { return nil, err } return dto.ConvertScheduleCourseToResponse(entity, weeks), nil } func (s *scheduleService) DeleteCourse(userID, courseID string) error { existing, err := s.repo.GetByID(courseID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrScheduleCourseNotFound } return err } if existing.UserID != userID { return ErrScheduleForbidden } return s.repo.DeleteByID(courseID) } func buildScheduleEntity(userID string, input CreateScheduleCourseInput, target *model.ScheduleCourse) (*model.ScheduleCourse, []int, error) { name := strings.TrimSpace(input.Name) if name == "" || input.DayOfWeek < 0 || input.DayOfWeek > 6 || input.StartSection < 1 || input.EndSection < input.StartSection { return nil, nil, ErrInvalidSchedulePayload } weeks := normalizeWeeks(input.Weeks) if len(weeks) == 0 { return nil, nil, ErrInvalidSchedulePayload } weeksJSON, err := json.Marshal(weeks) if err != nil { return nil, nil, err } entity := target if entity == nil { entity = &model.ScheduleCourse{ UserID: userID, } } normalizedColor := normalizeHexColor(input.Color) if normalizedColor == "" || !hexColorRegex.MatchString(normalizedColor) { return nil, nil, ErrInvalidSchedulePayload } entity.Name = name entity.Teacher = strings.TrimSpace(input.Teacher) entity.Location = strings.TrimSpace(input.Location) entity.DayOfWeek = input.DayOfWeek entity.StartSection = input.StartSection entity.EndSection = input.EndSection entity.Weeks = string(weeksJSON) entity.Color = normalizedColor return entity, weeks, nil } func (s *scheduleService) ensureUniqueColor(userID, color, excludeID string) error { exists, err := s.repo.ExistsColorByUser(userID, color, excludeID) if err != nil { return err } if exists { return ErrScheduleColorDuplicated } return nil } func normalizeWeeks(source []int) []int { unique := make(map[int]struct{}, len(source)) result := make([]int, 0, len(source)) for _, w := range source { if w < 1 || w > 30 { continue } if _, exists := unique[w]; exists { continue } unique[w] = struct{}{} result = append(result, w) } sort.Ints(result) return result } func containsWeek(weeks []int, target int) bool { for _, week := range weeks { if week == target { return true } } return false } func normalizeHexColor(color string) string { trimmed := strings.TrimSpace(color) if trimmed == "" { return "" } if strings.HasPrefix(trimmed, "#") { return strings.ToUpper(trimmed) } return "#" + strings.ToUpper(trimmed) }