places.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  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. type ObjectStorager interface {
  37. DeleteObjects(ctx context.Context, urls []string) error
  38. }
  39. var (
  40. ErrNotYourPlace = errors.New("not your place")
  41. ErrPlaceNotFound = errors.New("place not found")
  42. )
  43. type PlaceRepo interface {
  44. Create(ctx context.Context, place *models.Place) error
  45. GetByID(ctx context.Context, id string) (*models.Place, error)
  46. GetByIDRaw(ctx context.Context, id string) (*models.Place, error)
  47. List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error)
  48. Update(ctx context.Context, place *models.Place) error
  49. UpdateStatus(ctx context.Context, id, status, comment string) error
  50. SoftDelete(ctx context.Context, id string) error
  51. HardDelete(ctx context.Context, id string) error
  52. GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error)
  53. SetTags(ctx context.Context, placeID string, tags []models.Tag) error
  54. SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
  55. GetTags(ctx context.Context, placeID string) ([]models.Tag, error)
  56. GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error)
  57. GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error)
  58. GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error)
  59. }
  60. type PlaceService struct {
  61. placeRepo PlaceRepo
  62. storage ObjectStorager
  63. }
  64. func NewPlaceService(placeRepo PlaceRepo, storage ObjectStorager) *PlaceService {
  65. return &PlaceService{placeRepo: placeRepo, storage: storage}
  66. }
  67. type CreatePlaceInput struct {
  68. OwnerID string
  69. Type string
  70. Title string
  71. Description *string
  72. Address *string
  73. Lat float64
  74. Lng float64
  75. CoverImage *string
  76. AccessInfo *string
  77. HourlyRate *int
  78. Currency string
  79. MinHours int
  80. Tags []models.Tag
  81. Features []models.Feature
  82. Status string
  83. }
  84. func (s *PlaceService) Create(ctx context.Context, input CreatePlaceInput) (*models.Place, error) {
  85. status := input.Status
  86. if status == "" {
  87. status = "pending_moderation"
  88. }
  89. place := &models.Place{
  90. Type: input.Type,
  91. OwnerID: input.OwnerID,
  92. Title: input.Title,
  93. Description: input.Description,
  94. Address: input.Address,
  95. Lat: input.Lat,
  96. Lng: input.Lng,
  97. CoverImage: input.CoverImage,
  98. AccessInfo: input.AccessInfo,
  99. Status: status,
  100. HourlyRate: input.HourlyRate,
  101. Currency: input.Currency,
  102. MinHours: input.MinHours,
  103. Tags: input.Tags,
  104. Features: input.Features,
  105. }
  106. if err := s.placeRepo.Create(ctx, place); err != nil {
  107. return nil, fmt.Errorf("create place: %w", err)
  108. }
  109. return place, nil
  110. }
  111. func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
  112. place, err := s.placeRepo.GetByID(ctx, id)
  113. if err != nil {
  114. return nil, err
  115. }
  116. if place == nil {
  117. return nil, nil
  118. }
  119. if fetchTags {
  120. tags, err := s.placeRepo.GetTags(ctx, id)
  121. if err != nil {
  122. return nil, err
  123. }
  124. place.Tags = tags
  125. features, err := s.placeRepo.GetFeatures(ctx, id)
  126. if err != nil {
  127. return nil, err
  128. }
  129. place.Features = features
  130. }
  131. return place, nil
  132. }
  133. func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*models.PaginatedPlaces, error) {
  134. places, err := s.placeRepo.List(ctx, filter)
  135. if err != nil {
  136. return nil, err
  137. }
  138. if filter.IncludeTagsFeatures && len(places) > 0 {
  139. placeIDs := make([]string, len(places))
  140. for i, p := range places {
  141. placeIDs[i] = p.ID
  142. }
  143. tagsMap, err := s.placeRepo.GetTagsBatch(ctx, placeIDs)
  144. if err != nil {
  145. return nil, err
  146. }
  147. featuresMap, err := s.placeRepo.GetFeaturesBatch(ctx, placeIDs)
  148. if err != nil {
  149. return nil, err
  150. }
  151. for _, p := range places {
  152. if tags, ok := tagsMap[p.ID]; ok {
  153. p.Tags = tags
  154. }
  155. if features, ok := featuresMap[p.ID]; ok {
  156. p.Features = features
  157. }
  158. }
  159. }
  160. limit := filter.Limit()
  161. hasMore := len(places) > limit
  162. if hasMore {
  163. places = places[:limit]
  164. }
  165. var nextCursor string
  166. if hasMore && len(places) > 0 {
  167. nextCursor = encodeCursor(places[len(places)-1])
  168. }
  169. return &models.PaginatedPlaces{
  170. Data: places,
  171. NextCursor: nextCursor,
  172. HasMore: hasMore,
  173. }, nil
  174. }
  175. type UpdatePlaceInput struct {
  176. ID string
  177. OwnerID string
  178. Title *string
  179. Description *string
  180. Address *string
  181. Lat *float64
  182. Lng *float64
  183. CoverImage *string
  184. AccessInfo *string
  185. HourlyRate *int
  186. Currency *string
  187. MinHours *int
  188. Tags []models.Tag
  189. Features []models.Feature
  190. }
  191. func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isModerator bool) (*models.Place, error) {
  192. place, err := s.placeRepo.GetByID(ctx, input.ID)
  193. if err != nil {
  194. return nil, err
  195. }
  196. if place == nil {
  197. return nil, nil
  198. }
  199. if !isModerator && place.OwnerID != input.OwnerID {
  200. return nil, fmt.Errorf("%w: user %s tried to update place %s", ErrNotYourPlace, input.OwnerID, input.ID)
  201. }
  202. if input.Title != nil { place.Title = *input.Title }
  203. if input.Description != nil { place.Description = input.Description }
  204. if input.Address != nil { place.Address = input.Address }
  205. if input.Lat != nil { place.Lat = *input.Lat }
  206. if input.Lng != nil { place.Lng = *input.Lng }
  207. if input.CoverImage != nil { place.CoverImage = input.CoverImage }
  208. if input.AccessInfo != nil { place.AccessInfo = input.AccessInfo }
  209. if input.HourlyRate != nil { place.HourlyRate = input.HourlyRate }
  210. if input.Currency != nil { place.Currency = *input.Currency }
  211. if input.MinHours != nil { place.MinHours = *input.MinHours }
  212. if !isModerator {
  213. place.Status = "pending_moderation"
  214. }
  215. if err := s.placeRepo.Update(ctx, place); err != nil {
  216. return nil, err
  217. }
  218. if input.Tags != nil {
  219. if err := s.placeRepo.SetTags(ctx, place.ID, input.Tags); err != nil {
  220. return nil, err
  221. }
  222. place.Tags = input.Tags
  223. }
  224. if input.Features != nil {
  225. if err := s.placeRepo.SetFeatures(ctx, place.ID, input.Features); err != nil {
  226. return nil, err
  227. }
  228. place.Features = input.Features
  229. }
  230. return place, nil
  231. }
  232. func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error {
  233. var status string
  234. switch action {
  235. case "approve":
  236. status = "published"
  237. case "reject":
  238. status = "rejected"
  239. case "rework":
  240. status = "revision"
  241. case "revoke":
  242. status = "pending_moderation"
  243. default:
  244. return fmt.Errorf("unknown action: %s", action)
  245. }
  246. return s.placeRepo.UpdateStatus(ctx, id, status, comment)
  247. }
  248. func (s *PlaceService) Delete(ctx context.Context, id string) error {
  249. return s.placeRepo.SoftDelete(ctx, id)
  250. }
  251. func (s *PlaceService) HardDelete(ctx context.Context, id string) error {
  252. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  253. if err != nil {
  254. return err
  255. }
  256. if place == nil {
  257. return nil
  258. }
  259. var urls []string
  260. if place.CoverImage != nil {
  261. urls = append(urls, *place.CoverImage)
  262. }
  263. images, err := s.placeRepo.GetPlaceImages(ctx, id)
  264. if err != nil {
  265. return err
  266. }
  267. for _, img := range images {
  268. urls = append(urls, img.URL)
  269. }
  270. if len(urls) > 0 {
  271. if err := s.storage.DeleteObjects(ctx, urls); err != nil {
  272. return fmt.Errorf("delete place files: %w", err)
  273. }
  274. }
  275. return s.placeRepo.HardDelete(ctx, id)
  276. }