places.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/pointer"
  12. )
  13. func encodeCursor(p *models.Place) string {
  14. s := p.ID + "|" + strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "|" + fmt.Sprintf("%.6f", p.Rating)
  15. return base64.RawURLEncoding.EncodeToString([]byte(s))
  16. }
  17. func DecodeCursor(cursor string) (id string, createdAt time.Time, rating float64, err error) {
  18. b, err := base64.RawURLEncoding.DecodeString(cursor)
  19. if err != nil {
  20. return "", time.Time{}, 0, fmt.Errorf("decode cursor: %w", err)
  21. }
  22. parts := strings.SplitN(string(b), "|", 3)
  23. if len(parts) < 2 {
  24. return "", time.Time{}, 0, fmt.Errorf("invalid cursor format")
  25. }
  26. id = parts[0]
  27. createdAtUnix, err := strconv.ParseInt(parts[1], 10, 64)
  28. if err != nil {
  29. return "", time.Time{}, 0, fmt.Errorf("parse cursor created_at: %w", err)
  30. }
  31. createdAt = time.Unix(0, createdAtUnix)
  32. if len(parts) > 2 {
  33. rating, _ = strconv.ParseFloat(parts[2], 64)
  34. }
  35. return
  36. }
  37. type ObjectStorager interface {
  38. DeleteObjects(ctx context.Context, urls []string) error
  39. }
  40. var (
  41. ErrNotYourPlace = errors.New("not your place")
  42. )
  43. type ModerationLogRepo interface {
  44. Create(ctx context.Context, entry *models.ModerationLog) error
  45. ListByTarget(ctx context.Context, targetType, targetID string) ([]*models.ModerationLog, error)
  46. }
  47. type PlaceRepo interface {
  48. Create(ctx context.Context, place *models.Place) error
  49. GetByID(ctx context.Context, id string) (*models.Place, error)
  50. GetByIDRaw(ctx context.Context, id string) (*models.Place, error)
  51. List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error)
  52. Update(ctx context.Context, place *models.Place) error
  53. UpdateStatus(ctx context.Context, id, status, comment string) error
  54. SoftDelete(ctx context.Context, id string) error
  55. HardDelete(ctx context.Context, id string) error
  56. GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error)
  57. SetTags(ctx context.Context, placeID string, tags []models.Tag) error
  58. SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
  59. GetTags(ctx context.Context, placeID string) ([]models.Tag, error)
  60. GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error)
  61. GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error)
  62. GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error)
  63. }
  64. type PlaceService struct {
  65. placeRepo PlaceRepo
  66. storage ObjectStorager
  67. moderationLogRepo ModerationLogRepo
  68. }
  69. func NewPlaceService(placeRepo PlaceRepo, storage ObjectStorager, moderationLogRepo ModerationLogRepo) *PlaceService {
  70. return &PlaceService{placeRepo: placeRepo, storage: storage, moderationLogRepo: moderationLogRepo}
  71. }
  72. type CreatePlaceInput struct {
  73. OwnerID string
  74. Type string
  75. Title string
  76. Description *string
  77. Address *string
  78. Lat float64
  79. Lng float64
  80. CoverImage *string
  81. AccessInfo *string
  82. HourlyRate *int
  83. Currency string
  84. MinHours int
  85. Tags []models.Tag
  86. Features []models.Feature
  87. Status string
  88. }
  89. func (s *PlaceService) Create(ctx context.Context, input CreatePlaceInput) (*models.Place, error) {
  90. status := input.Status
  91. if status == "" {
  92. status = "pending_moderation"
  93. }
  94. place := &models.Place{
  95. Type: input.Type,
  96. OwnerID: input.OwnerID,
  97. Title: input.Title,
  98. Description: input.Description,
  99. Address: input.Address,
  100. Lat: input.Lat,
  101. Lng: input.Lng,
  102. CoverImage: input.CoverImage,
  103. AccessInfo: input.AccessInfo,
  104. Status: status,
  105. HourlyRate: input.HourlyRate,
  106. Currency: input.Currency,
  107. MinHours: input.MinHours,
  108. Tags: input.Tags,
  109. Features: input.Features,
  110. }
  111. if err := s.placeRepo.Create(ctx, place); err != nil {
  112. return nil, fmt.Errorf("create place: %w", err)
  113. }
  114. return place, nil
  115. }
  116. func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
  117. place, err := s.placeRepo.GetByID(ctx, id)
  118. if err != nil {
  119. return nil, err
  120. }
  121. if place == nil {
  122. return nil, nil
  123. }
  124. if fetchTags {
  125. tags, err := s.placeRepo.GetTags(ctx, id)
  126. if err != nil {
  127. return nil, err
  128. }
  129. place.Tags = tags
  130. features, err := s.placeRepo.GetFeatures(ctx, id)
  131. if err != nil {
  132. return nil, err
  133. }
  134. place.Features = features
  135. }
  136. return place, nil
  137. }
  138. func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*models.PaginatedPlaces, error) {
  139. places, err := s.placeRepo.List(ctx, filter)
  140. if err != nil {
  141. return nil, err
  142. }
  143. if filter.IncludeTagsFeatures && len(places) > 0 {
  144. placeIDs := make([]string, len(places))
  145. for i, p := range places {
  146. placeIDs[i] = p.ID
  147. }
  148. tagsMap, err := s.placeRepo.GetTagsBatch(ctx, placeIDs)
  149. if err != nil {
  150. return nil, err
  151. }
  152. featuresMap, err := s.placeRepo.GetFeaturesBatch(ctx, placeIDs)
  153. if err != nil {
  154. return nil, err
  155. }
  156. for _, p := range places {
  157. if tags, ok := tagsMap[p.ID]; ok {
  158. p.Tags = tags
  159. }
  160. if features, ok := featuresMap[p.ID]; ok {
  161. p.Features = features
  162. }
  163. }
  164. }
  165. limit := filter.Limit()
  166. hasMore := len(places) > limit
  167. if hasMore {
  168. places = places[:limit]
  169. }
  170. var nextCursor string
  171. if hasMore && len(places) > 0 {
  172. nextCursor = encodeCursor(places[len(places)-1])
  173. }
  174. return &models.PaginatedPlaces{
  175. Data: places,
  176. NextCursor: nextCursor,
  177. HasMore: hasMore,
  178. }, nil
  179. }
  180. type UpdatePlaceInput struct {
  181. ID string
  182. OwnerID string
  183. Title *string
  184. Description *string
  185. Address *string
  186. Lat *float64
  187. Lng *float64
  188. CoverImage *string
  189. AccessInfo *string
  190. HourlyRate *int
  191. Currency *string
  192. MinHours *int
  193. Tags []models.Tag
  194. Features []models.Feature
  195. }
  196. func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isModerator bool) (*models.Place, error) {
  197. place, err := s.placeRepo.GetByID(ctx, input.ID)
  198. if err != nil {
  199. return nil, err
  200. }
  201. if place == nil {
  202. return nil, nil
  203. }
  204. if !isModerator && place.OwnerID != input.OwnerID {
  205. return nil, fmt.Errorf("%w: user %s tried to update place %s", ErrNotYourPlace, input.OwnerID, input.ID)
  206. }
  207. if input.Title != nil { place.Title = *input.Title }
  208. if input.Description != nil { place.Description = input.Description }
  209. if input.Address != nil { place.Address = input.Address }
  210. if input.Lat != nil { place.Lat = *input.Lat }
  211. if input.Lng != nil { place.Lng = *input.Lng }
  212. if input.CoverImage != nil { place.CoverImage = input.CoverImage }
  213. if input.AccessInfo != nil { place.AccessInfo = input.AccessInfo }
  214. if input.HourlyRate != nil { place.HourlyRate = input.HourlyRate }
  215. if input.Currency != nil { place.Currency = *input.Currency }
  216. if input.MinHours != nil { place.MinHours = *input.MinHours }
  217. if !isModerator {
  218. place.Status = "pending_moderation"
  219. }
  220. if err := s.placeRepo.Update(ctx, place); err != nil {
  221. return nil, err
  222. }
  223. if input.Tags != nil {
  224. if err := s.placeRepo.SetTags(ctx, place.ID, input.Tags); err != nil {
  225. return nil, err
  226. }
  227. place.Tags = input.Tags
  228. }
  229. if input.Features != nil {
  230. if err := s.placeRepo.SetFeatures(ctx, place.ID, input.Features); err != nil {
  231. return nil, err
  232. }
  233. place.Features = input.Features
  234. }
  235. return place, nil
  236. }
  237. func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error {
  238. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  239. if err != nil {
  240. return err
  241. }
  242. if place == nil {
  243. return nil
  244. }
  245. var status string
  246. switch action {
  247. case "approve":
  248. status = "published"
  249. case "reject":
  250. status = "rejected"
  251. case "rework":
  252. status = "revision"
  253. case "revoke":
  254. status = "pending_moderation"
  255. default:
  256. return fmt.Errorf("unknown action: %s", action)
  257. }
  258. entry := &models.ModerationLog{
  259. ModeratorID: moderatorID,
  260. TargetType: "place",
  261. TargetID: id,
  262. Action: action,
  263. OldStatus: &place.Status,
  264. NewStatus: &status,
  265. }
  266. if comment != "" {
  267. entry.Comment = &comment
  268. }
  269. if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
  270. return fmt.Errorf("log moderation: %w", err)
  271. }
  272. return s.placeRepo.UpdateStatus(ctx, id, status, comment)
  273. }
  274. func (s *PlaceService) Delete(ctx context.Context, id, userID string) error {
  275. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  276. if err != nil {
  277. return err
  278. }
  279. if place == nil {
  280. return nil
  281. }
  282. entry := &models.ModerationLog{
  283. ModeratorID: userID,
  284. TargetType: "place",
  285. TargetID: id,
  286. Action: "soft_delete",
  287. OldStatus: &place.Status,
  288. NewStatus: pointer.Str("deleted"),
  289. }
  290. if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
  291. return fmt.Errorf("log soft delete: %w", err)
  292. }
  293. return s.placeRepo.SoftDelete(ctx, id)
  294. }
  295. func (s *PlaceService) HardDelete(ctx context.Context, id, moderatorID string) error {
  296. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  297. if err != nil {
  298. return err
  299. }
  300. if place == nil {
  301. return nil
  302. }
  303. var urls []string
  304. if place.CoverImage != nil {
  305. urls = append(urls, *place.CoverImage)
  306. }
  307. images, err := s.placeRepo.GetPlaceImages(ctx, id)
  308. if err != nil {
  309. return err
  310. }
  311. for _, img := range images {
  312. urls = append(urls, img.URL)
  313. }
  314. if len(urls) > 0 {
  315. if err := s.storage.DeleteObjects(ctx, urls); err != nil {
  316. return fmt.Errorf("delete place files: %w", err)
  317. }
  318. }
  319. entry := &models.ModerationLog{
  320. ModeratorID: moderatorID,
  321. TargetType: "place",
  322. TargetID: id,
  323. Action: "hard_delete",
  324. OldStatus: &place.Status,
  325. }
  326. if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
  327. return fmt.Errorf("log hard delete: %w", err)
  328. }
  329. return s.placeRepo.HardDelete(ctx, id)
  330. }