208 lines
5.4 KiB
Go
208 lines
5.4 KiB
Go
|
|
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)
|
||
|
|
}
|