places.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. package services
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "errors"
  6. "fmt"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
  11. )
  12. func encodeCursor(p *models.Place) string {
  13. s := p.ID + "|" + strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "|" + fmt.Sprintf("%.6f", p.Rating)
  14. return base64.RawURLEncoding.EncodeToString([]byte(s))
  15. }
  16. func DecodeCursor(cursor string) (id string, createdAt time.Time, rating float64, err error) {
  17. b, err := base64.RawURLEncoding.DecodeString(cursor)
  18. if err != nil {
  19. return "", time.Time{}, 0, fmt.Errorf("decode cursor: %w", err)
  20. }
  21. parts := strings.SplitN(string(b), "|", 3)
  22. if len(parts) < 2 {
  23. return "", time.Time{}, 0, fmt.Errorf("invalid cursor format")
  24. }
  25. id = parts[0]
  26. createdAtUnix, err := strconv.ParseInt(parts[1], 10, 64)
  27. if err != nil {
  28. return "", time.Time{}, 0, fmt.Errorf("parse cursor created_at: %w", err)
  29. }
  30. createdAt = time.Unix(0, createdAtUnix)
  31. if len(parts) > 2 {
  32. rating, _ = strconv.ParseFloat(parts[2], 64)
  33. }
  34. return
  35. }
  36. var (
  37. ErrNotYourPlace = errors.New("not your place")
  38. ErrPlaceNotFound = errors.New("place not found")
  39. )
  40. type PlaceRepo interface {
  41. Create(ctx context.Context, place *models.Place) error
  42. GetByID(ctx context.Context, id string) (*models.Place, error)
  43. List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error)
  44. Update(ctx context.Context, place *models.Place) error
  45. UpdateStatus(ctx context.Context, id, status, comment string) error
  46. SoftDelete(ctx context.Context, id string) error
  47. SetTags(ctx context.Context, placeID string, tags []models.Tag) error
  48. SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
  49. GetTags(ctx context.Context, placeID string) ([]models.Tag, error)
  50. GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error)
  51. GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error)
  52. GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error)
  53. }
  54. type PlaceService struct {
  55. placeRepo PlaceRepo
  56. }
  57. func NewPlaceService(placeRepo PlaceRepo) *PlaceService {
  58. return &PlaceService{placeRepo: placeRepo}
  59. }
  60. type CreatePlaceInput struct {
  61. OwnerID string
  62. Type string
  63. Title string
  64. Description *string
  65. Address *string
  66. Lat float64
  67. Lng float64
  68. CoverImage *string
  69. AccessInfo *string
  70. HourlyRate *int
  71. Currency string
  72. MinHours int
  73. Tags []models.Tag
  74. Features []models.Feature
  75. }
  76. func (s *PlaceService) Create(ctx context.Context, input CreatePlaceInput) (*models.Place, error) {
  77. place := &models.Place{
  78. Type: input.Type,
  79. OwnerID: input.OwnerID,
  80. Title: input.Title,
  81. Description: input.Description,
  82. Address: input.Address,
  83. Lat: input.Lat,
  84. Lng: input.Lng,
  85. CoverImage: input.CoverImage,
  86. AccessInfo: input.AccessInfo,
  87. Status: "pending_moderation",
  88. HourlyRate: input.HourlyRate,
  89. Currency: input.Currency,
  90. MinHours: input.MinHours,
  91. Tags: input.Tags,
  92. Features: input.Features,
  93. }
  94. if err := s.placeRepo.Create(ctx, place); err != nil {
  95. return nil, fmt.Errorf("create place: %w", err)
  96. }
  97. return place, nil
  98. }
  99. func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
  100. place, err := s.placeRepo.GetByID(ctx, id)
  101. if err != nil {
  102. return nil, err
  103. }
  104. if place == nil {
  105. return nil, nil
  106. }
  107. if fetchTags {
  108. tags, err := s.placeRepo.GetTags(ctx, id)
  109. if err != nil {
  110. return nil, err
  111. }
  112. place.Tags = tags
  113. features, err := s.placeRepo.GetFeatures(ctx, id)
  114. if err != nil {
  115. return nil, err
  116. }
  117. place.Features = features
  118. }
  119. return place, nil
  120. }
  121. func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*models.PaginatedPlaces, error) {
  122. places, err := s.placeRepo.List(ctx, filter)
  123. if err != nil {
  124. return nil, err
  125. }
  126. if filter.IncludeTagsFeatures && len(places) > 0 {
  127. placeIDs := make([]string, len(places))
  128. for i, p := range places {
  129. placeIDs[i] = p.ID
  130. }
  131. tagsMap, err := s.placeRepo.GetTagsBatch(ctx, placeIDs)
  132. if err != nil {
  133. return nil, err
  134. }
  135. featuresMap, err := s.placeRepo.GetFeaturesBatch(ctx, placeIDs)
  136. if err != nil {
  137. return nil, err
  138. }
  139. for _, p := range places {
  140. if tags, ok := tagsMap[p.ID]; ok {
  141. p.Tags = tags
  142. }
  143. if features, ok := featuresMap[p.ID]; ok {
  144. p.Features = features
  145. }
  146. }
  147. }
  148. limit := filter.Limit()
  149. hasMore := len(places) > limit
  150. if hasMore {
  151. places = places[:limit]
  152. }
  153. var nextCursor string
  154. if hasMore && len(places) > 0 {
  155. nextCursor = encodeCursor(places[len(places)-1])
  156. }
  157. return &models.PaginatedPlaces{
  158. Data: places,
  159. NextCursor: nextCursor,
  160. HasMore: hasMore,
  161. }, nil
  162. }
  163. type UpdatePlaceInput struct {
  164. ID string
  165. OwnerID string
  166. Title *string
  167. Description *string
  168. Address *string
  169. Lat *float64
  170. Lng *float64
  171. CoverImage *string
  172. AccessInfo *string
  173. HourlyRate *int
  174. Currency *string
  175. MinHours *int
  176. }
  177. func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isModerator bool) (*models.Place, error) {
  178. place, err := s.placeRepo.GetByID(ctx, input.ID)
  179. if err != nil {
  180. return nil, err
  181. }
  182. if place == nil {
  183. return nil, nil
  184. }
  185. if !isModerator && place.OwnerID != input.OwnerID {
  186. return nil, fmt.Errorf("%w: user %s tried to update place %s", ErrNotYourPlace, input.OwnerID, input.ID)
  187. }
  188. if input.Title != nil { place.Title = *input.Title }
  189. if input.Description != nil { place.Description = input.Description }
  190. if input.Address != nil { place.Address = input.Address }
  191. if input.Lat != nil { place.Lat = *input.Lat }
  192. if input.Lng != nil { place.Lng = *input.Lng }
  193. if input.CoverImage != nil { place.CoverImage = input.CoverImage }
  194. if input.AccessInfo != nil { place.AccessInfo = input.AccessInfo }
  195. if input.HourlyRate != nil { place.HourlyRate = input.HourlyRate }
  196. if input.Currency != nil { place.Currency = *input.Currency }
  197. if input.MinHours != nil { place.MinHours = *input.MinHours }
  198. if !isModerator {
  199. place.Status = "pending_moderation"
  200. }
  201. if err := s.placeRepo.Update(ctx, place); err != nil {
  202. return nil, err
  203. }
  204. return place, nil
  205. }
  206. func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error {
  207. var status string
  208. switch action {
  209. case "approve":
  210. status = "published"
  211. case "reject":
  212. status = "rejected"
  213. default:
  214. return fmt.Errorf("unknown action: %s", action)
  215. }
  216. return s.placeRepo.UpdateStatus(ctx, id, status, comment)
  217. }
  218. func (s *PlaceService) Delete(ctx context.Context, id string) error {
  219. return s.placeRepo.SoftDelete(ctx, id)
  220. }