package scenarios_service import ( "context" "errors" "evening_detective_server/internal/modules/cleaner" "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos" "evening_detective_server/internal/repos/scenarios_repo" "strings" ) type ScenarioService struct { scenariosRepo *scenarios_repo.ScenariosRepo cleaner cleaner.ICleaner domain string } func NewScenarioService( scenariosRepo *scenarios_repo.ScenariosRepo, cleaner cleaner.ICleaner, domain string, ) *ScenarioService { return &ScenarioService{ scenariosRepo: scenariosRepo, cleaner: cleaner, domain: domain, } } func (s *ScenarioService) AddScenario( ctx context.Context, name string, authorId int, ) (int, error) { id, err := s.scenariosRepo.AddScenario(ctx, name, authorId) if err != nil { return 0, err } return id, nil } func (s *ScenarioService) GetScenariosByAuthorID( ctx context.Context, authorId int, ) ([]*Scenario, error) { scenarios, err := s.scenariosRepo.GetScenariosByAuthorID(ctx, authorId) if err != nil { return nil, err } return mapScenariosLight(scenarios, s.domain), nil } func (s *ScenarioService) GetScenariosCatalog( ctx context.Context, ) ([]*Scenario, error) { scenarios, err := s.scenariosRepo.GetScenariosByStatus(ctx, "public") if err != nil { return nil, err } return mapScenariosLight(scenarios, s.domain), nil } func (s *ScenarioService) GetFullScenarioByID( ctx context.Context, id int, ) (*Scenario, error) { scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id) if err != nil { return nil, err } return mapScenario(scenario, s.domain) } func (s *ScenarioService) GetScenarioByID( ctx context.Context, id int, ) (*Scenario, error) { scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id) if err != nil { return nil, err } return MapScenarioLight(scenario, s.domain), nil } func (s *ScenarioService) UpdateScenarioByID( ctx context.Context, id int, name string, description string, image string, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } return s.scenariosRepo.UpdateScenarioByID(ctx, id, name, description, image) } // ErrScenarioPublished — опубликованный сценарий нельзя снять с публикации // или удалить: при публикации исключительное право отчуждается Оператору // (п. 7.2 Пользовательского соглашения, ст. 1234 ГК РФ), а право на отзыв // действует только до обнародования (ст. 1269 ГК РФ). var ErrScenarioPublished = errors.New("Опубликованный сценарий нельзя снять с публикации или удалить: исключительное право отчуждено Оператору") // ErrScenarioNotOwner — сценарий принадлежит другому пользователю. var ErrScenarioNotOwner = errors.New("Сценарий принадлежит другому пользователю") // getScenarioForChange проверяет права актора на изменение сценария: // не-админ должен быть автором сценария, сценарий не должен быть удалён. // Возвращает сценарий, чтобы вызывающий код не делал повторное чтение. func (s *ScenarioService) getScenarioForChange( ctx context.Context, id int, actorId int, isAdmin bool, ) (*repos.Scenario, error) { scenario, err := s.scenariosRepo.GetScenarioByID(ctx, id) if err != nil { return nil, err } if scenario.IsDeleted { return nil, scenarios_repo.ErrScenarioNotFound } if isAdmin { return scenario, nil } if scenario.Author == nil || scenario.Author.ID != actorId { return nil, ErrScenarioNotOwner } return scenario, nil } func (s *ScenarioService) UpdateScenarioStatusByID( ctx context.Context, id int, status string, actorId int, isAdmin bool, ) error { scenario, err := s.getScenarioForChange(ctx, id, actorId, isAdmin) if err != nil { return err } if !isAdmin && scenario.Status == "public" { // Повторная публикация уже опубликованного — идемпотентный успех // (published_at не двигается: CASE WHEN published_at IS NULL). if status == "public" { return nil } return ErrScenarioPublished } // Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не // сможет перевести в draft сценарий, опубликованный между чтением и UPDATE. updated, err := s.scenariosRepo.UpdateScenarioStatusByID(ctx, id, status, isAdmin) if err != nil { return err } if !updated { return ErrScenarioPublished } return nil } func (s *ScenarioService) DeleteScenarioByID( ctx context.Context, id int, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } // Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не // сможет удалить сценарий, опубликованный между чтением и UPDATE. updated, err := s.scenariosRepo.DeleteScenarioByID(ctx, id, isAdmin) if err != nil { return err } if !updated { return ErrScenarioPublished } return nil } func (s *ScenarioService) AddScenarioPlace( ctx context.Context, id int, place *storytelling.Place, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } story, err := s.getStory(ctx, id) if err != nil { return err } story.Places = append( []*storytelling.Place{place}, story.Places..., ) return s.updateStory(ctx, id, story) } func (s *ScenarioService) UpdateScenarioPlace( ctx context.Context, id int, code string, place *storytelling.Place, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } story, err := s.getStory(ctx, id) if err != nil { return err } for i := range story.Places { if story.Places[i].Code == code { story.Places[i] = place break } } return s.updateStory(ctx, id, story) } func (s *ScenarioService) DeleteScenarioPlace( ctx context.Context, id int, code string, actorId int, isAdmin bool, ) error { if _, err := s.getScenarioForChange(ctx, id, actorId, isAdmin); err != nil { return err } story, err := s.getStory(ctx, id) if err != nil { return err } for i := range story.Places { if story.Places[i].Code == code { story.Places = append(story.Places[:i], story.Places[i+1:]...) break } } return s.updateStory(ctx, id, story) } func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.Story, error) { storyString, err := s.scenariosRepo.GetStoryByScenarioID(ctx, id) if err != nil { return nil, err } return mapStory(storyString, s.domain) } func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error { mapCodes := map[string]struct{}{} for _, place := range story.Places { mapCodes[place.Code] = struct{}{} place.Image = strings.TrimPrefix(place.Image, s.domain) } if len(mapCodes) != len(story.Places) { return errors.New("Такой код точки уже существует") } cleanPlaces := make([]*storytelling.Place, 0, len(story.Places)) for _, place := range story.Places { if place.Code == "" { continue } cleanPlaces = append(cleanPlaces, place) } story.Places = cleanPlaces storyString, err := convertStory(story) if err != nil { return err } return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString) }