package services import ( "context" "encoding/base64" "errors" "fmt" "strconv" "strings" "time" "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models" "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/pointer" ) func encodeCursor(p *models.Place) string { s := p.ID + "|" + strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "|" + fmt.Sprintf("%.6f", p.Rating) return base64.RawURLEncoding.EncodeToString([]byte(s)) } func DecodeCursor(cursor string) (id string, createdAt time.Time, rating float64, err error) { b, err := base64.RawURLEncoding.DecodeString(cursor) if err != nil { return "", time.Time{}, 0, fmt.Errorf("decode cursor: %w", err) } parts := strings.SplitN(string(b), "|", 3) if len(parts) < 2 { return "", time.Time{}, 0, fmt.Errorf("invalid cursor format") } id = parts[0] createdAtUnix, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { return "", time.Time{}, 0, fmt.Errorf("parse cursor created_at: %w", err) } createdAt = time.Unix(0, createdAtUnix) if len(parts) > 2 { rating, _ = strconv.ParseFloat(parts[2], 64) } return } type ObjectStorager interface { DeleteObjects(ctx context.Context, urls []string) error } var ( ErrNotYourPlace = errors.New("not your place") ) type ModerationLogRepo interface { Create(ctx context.Context, entry *models.ModerationLog) error ListByTarget(ctx context.Context, targetType, targetID string) ([]*models.ModerationLog, error) } type PlaceRepo interface { Create(ctx context.Context, place *models.Place) error GetByID(ctx context.Context, id string) (*models.Place, error) GetByIDWithDetails(ctx context.Context, id string) (*models.Place, error) GetByIDRaw(ctx context.Context, id string) (*models.Place, error) List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error) Update(ctx context.Context, place *models.Place) error UpdateStatus(ctx context.Context, id, status, comment string) error SoftDelete(ctx context.Context, id string) error HardDelete(ctx context.Context, id string) error Restore(ctx context.Context, id string) error GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error) SetTags(ctx context.Context, placeID string, tags []models.Tag) error SetFeatures(ctx context.Context, placeID string, features []models.Feature) error GetTags(ctx context.Context, placeID string) ([]models.Tag, error) GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error) GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error) GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error) } type PlaceService struct { placeRepo PlaceRepo storage ObjectStorager moderationLogRepo ModerationLogRepo } func NewPlaceService(placeRepo PlaceRepo, storage ObjectStorager, moderationLogRepo ModerationLogRepo) *PlaceService { return &PlaceService{placeRepo: placeRepo, storage: storage, moderationLogRepo: moderationLogRepo} } type CreatePlaceInput struct { OwnerID string Type string Title string Description *string Address *string Lat float64 Lng float64 CoverImage *string AccessInfo *string HourlyRate *int Currency string MinHours int Tags []models.Tag Features []models.Feature Status string } func (s *PlaceService) Create(ctx context.Context, input CreatePlaceInput) (*models.Place, error) { status := input.Status if status == "" { status = "pending_moderation" } place := &models.Place{ Type: input.Type, OwnerID: input.OwnerID, Title: input.Title, Description: input.Description, Address: input.Address, Lat: input.Lat, Lng: input.Lng, CoverImage: input.CoverImage, AccessInfo: input.AccessInfo, Status: status, HourlyRate: input.HourlyRate, Currency: input.Currency, MinHours: input.MinHours, Tags: input.Tags, Features: input.Features, } if err := s.placeRepo.Create(ctx, place); err != nil { return nil, fmt.Errorf("create place: %w", err) } return place, nil } func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) { if fetchTags { return s.placeRepo.GetByIDWithDetails(ctx, id) } return s.placeRepo.GetByID(ctx, id) } func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*models.PaginatedPlaces, error) { places, err := s.placeRepo.List(ctx, filter) if err != nil { return nil, err } limit := filter.Limit() hasMore := len(places) > limit if hasMore { places = places[:limit] } if filter.IncludeTagsFeatures && len(places) > 0 { placeIDs := make([]string, len(places)) for i, p := range places { placeIDs[i] = p.ID } tagsMap, err := s.placeRepo.GetTagsBatch(ctx, placeIDs) if err != nil { return nil, err } featuresMap, err := s.placeRepo.GetFeaturesBatch(ctx, placeIDs) if err != nil { return nil, err } for _, p := range places { if tags, ok := tagsMap[p.ID]; ok { p.Tags = tags } if features, ok := featuresMap[p.ID]; ok { p.Features = features } } } var nextCursor string if hasMore && len(places) > 0 { nextCursor = encodeCursor(places[len(places)-1]) } return &models.PaginatedPlaces{ Data: places, NextCursor: nextCursor, HasMore: hasMore, }, nil } type UpdatePlaceInput struct { ID string OwnerID string Title *string Description *string Address *string Lat *float64 Lng *float64 CoverImage *string AccessInfo *string HourlyRate *int Currency *string MinHours *int Tags []models.Tag Features []models.Feature } func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isModerator bool) (*models.Place, error) { place, err := s.placeRepo.GetByID(ctx, input.ID) if err != nil { return nil, err } if place == nil { return nil, nil } if !isModerator && place.OwnerID != input.OwnerID { return nil, fmt.Errorf("%w: user %s tried to update place %s", ErrNotYourPlace, input.OwnerID, input.ID) } if input.Title != nil { place.Title = *input.Title } if input.Description != nil { place.Description = input.Description } if input.Address != nil { place.Address = input.Address } if input.Lat != nil { place.Lat = *input.Lat } if input.Lng != nil { place.Lng = *input.Lng } if input.CoverImage != nil { place.CoverImage = input.CoverImage } if input.AccessInfo != nil { place.AccessInfo = input.AccessInfo } if input.HourlyRate != nil { place.HourlyRate = input.HourlyRate } if input.Currency != nil { place.Currency = *input.Currency } if input.MinHours != nil { place.MinHours = *input.MinHours } if !isModerator { place.Status = "pending_moderation" } if err := s.placeRepo.Update(ctx, place); err != nil { return nil, err } if input.Tags != nil { if err := s.placeRepo.SetTags(ctx, place.ID, input.Tags); err != nil { return nil, err } place.Tags = input.Tags } if input.Features != nil { if err := s.placeRepo.SetFeatures(ctx, place.ID, input.Features); err != nil { return nil, err } place.Features = input.Features } return place, nil } func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error { place, err := s.placeRepo.GetByIDRaw(ctx, id) if err != nil { return err } if place == nil { return nil } var status string switch action { case "approve": status = "published" case "reject": status = "rejected" case "rework": status = "revision" case "revoke": status = "pending_moderation" default: return fmt.Errorf("unknown action: %s", action) } entry := &models.ModerationLog{ ModeratorID: moderatorID, TargetType: "place", TargetID: id, Action: action, OldStatus: &place.Status, NewStatus: &status, } if comment != "" { entry.Comment = &comment } if err := s.moderationLogRepo.Create(ctx, entry); err != nil { return fmt.Errorf("log moderation: %w", err) } return s.placeRepo.UpdateStatus(ctx, id, status, comment) } func (s *PlaceService) Delete(ctx context.Context, id, userID string) error { place, err := s.placeRepo.GetByIDRaw(ctx, id) if err != nil { return err } if place == nil { return nil } entry := &models.ModerationLog{ ModeratorID: userID, TargetType: "place", TargetID: id, Action: "soft_delete", OldStatus: &place.Status, NewStatus: pointer.Str("deleted"), } if err := s.moderationLogRepo.Create(ctx, entry); err != nil { return fmt.Errorf("log soft delete: %w", err) } return s.placeRepo.SoftDelete(ctx, id) } func (s *PlaceService) HardDelete(ctx context.Context, id, moderatorID string) error { place, err := s.placeRepo.GetByIDRaw(ctx, id) if err != nil { return err } if place == nil { return nil } var urls []string if place.CoverImage != nil { urls = append(urls, *place.CoverImage) } images, err := s.placeRepo.GetPlaceImages(ctx, id) if err != nil { return err } for _, img := range images { urls = append(urls, img.URL) } if len(urls) > 0 { if err := s.storage.DeleteObjects(ctx, urls); err != nil { return fmt.Errorf("delete place files: %w", err) } } entry := &models.ModerationLog{ ModeratorID: moderatorID, TargetType: "place", TargetID: id, Action: "hard_delete", OldStatus: &place.Status, } if err := s.moderationLogRepo.Create(ctx, entry); err != nil { return fmt.Errorf("log hard delete: %w", err) } return s.placeRepo.HardDelete(ctx, id) } func (s *PlaceService) Restore(ctx context.Context, id, moderatorID string) error { place, err := s.placeRepo.GetByIDRaw(ctx, id) if err != nil { return err } if place == nil { return nil } entry := &models.ModerationLog{ ModeratorID: moderatorID, TargetType: "place", TargetID: id, Action: "restore", OldStatus: &place.Status, NewStatus: pointer.Str("published"), } if err := s.moderationLogRepo.Create(ctx, entry); err != nil { return fmt.Errorf("log restore: %w", err) } return s.placeRepo.Restore(ctx, id) }