package services import ( "context" "encoding/base64" "errors" "fmt" "strconv" "strings" "time" "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models" ) 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 } var ( ErrNotYourPlace = errors.New("not your place") ErrPlaceNotFound = errors.New("place not found") ) type PlaceRepo interface { Create(ctx context.Context, place *models.Place) error GetByID(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 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 } func NewPlaceService(placeRepo PlaceRepo) *PlaceService { return &PlaceService{placeRepo: placeRepo} } 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 } func (s *PlaceService) Create(ctx context.Context, input CreatePlaceInput) (*models.Place, error) { 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: "pending_moderation", 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) { place, err := s.placeRepo.GetByID(ctx, id) if err != nil { return nil, err } if place == nil { return nil, nil } if fetchTags { tags, err := s.placeRepo.GetTags(ctx, id) if err != nil { return nil, err } place.Tags = tags features, err := s.placeRepo.GetFeatures(ctx, id) if err != nil { return nil, err } place.Features = features } return place, nil } 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 } 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 } } } limit := filter.Limit() hasMore := len(places) > limit if hasMore { places = places[:limit] } 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 } 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 } return place, nil } func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error { var status string switch action { case "approve": status = "published" case "reject": status = "rejected" default: return fmt.Errorf("unknown action: %s", action) } return s.placeRepo.UpdateStatus(ctx, id, status, comment) } func (s *PlaceService) Delete(ctx context.Context, id string) error { return s.placeRepo.SoftDelete(ctx, id) }