places.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  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. Restore(ctx context.Context, id string) error
  57. GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error)
  58. SetTags(ctx context.Context, placeID string, tags []models.Tag) error
  59. SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
  60. GetTags(ctx context.Context, placeID string) ([]models.Tag, error)
  61. GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error)
  62. GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error)
  63. GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error)
  64. }
  65. type PlaceService struct {
  66. placeRepo PlaceRepo
  67. storage ObjectStorager
  68. moderationLogRepo ModerationLogRepo
  69. }
  70. func NewPlaceService(placeRepo PlaceRepo, storage ObjectStorager, moderationLogRepo ModerationLogRepo) *PlaceService {
  71. return &PlaceService{placeRepo: placeRepo, storage: storage, moderationLogRepo: moderationLogRepo}
  72. }
  73. type CreatePlaceInput struct {
  74. OwnerID string
  75. Type string
  76. Title string
  77. Description *string
  78. Address *string
  79. Lat float64
  80. Lng float64
  81. CoverImage *string
  82. AccessInfo *string
  83. HourlyRate *int
  84. Currency string
  85. MinHours int
  86. Tags []models.Tag
  87. Features []models.Feature
  88. Status string
  89. }
  90. func (s *PlaceService) Create(ctx context.Context, input CreatePlaceInput) (*models.Place, error) {
  91. status := input.Status
  92. if status == "" {
  93. status = "pending_moderation"
  94. }
  95. place := &models.Place{
  96. Type: input.Type,
  97. OwnerID: input.OwnerID,
  98. Title: input.Title,
  99. Description: input.Description,
  100. Address: input.Address,
  101. Lat: input.Lat,
  102. Lng: input.Lng,
  103. CoverImage: input.CoverImage,
  104. AccessInfo: input.AccessInfo,
  105. Status: status,
  106. HourlyRate: input.HourlyRate,
  107. Currency: input.Currency,
  108. MinHours: input.MinHours,
  109. Tags: input.Tags,
  110. Features: input.Features,
  111. }
  112. if err := s.placeRepo.Create(ctx, place); err != nil {
  113. return nil, fmt.Errorf("create place: %w", err)
  114. }
  115. return place, nil
  116. }
  117. func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (*models.Place, error) {
  118. place, err := s.placeRepo.GetByID(ctx, id)
  119. if err != nil {
  120. return nil, err
  121. }
  122. if place == nil {
  123. return nil, nil
  124. }
  125. if fetchTags {
  126. tags, err := s.placeRepo.GetTags(ctx, id)
  127. if err != nil {
  128. return nil, err
  129. }
  130. place.Tags = tags
  131. features, err := s.placeRepo.GetFeatures(ctx, id)
  132. if err != nil {
  133. return nil, err
  134. }
  135. place.Features = features
  136. }
  137. return place, nil
  138. }
  139. func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*models.PaginatedPlaces, error) {
  140. places, err := s.placeRepo.List(ctx, filter)
  141. if err != nil {
  142. return nil, err
  143. }
  144. if filter.IncludeTagsFeatures && len(places) > 0 {
  145. placeIDs := make([]string, len(places))
  146. for i, p := range places {
  147. placeIDs[i] = p.ID
  148. }
  149. tagsMap, err := s.placeRepo.GetTagsBatch(ctx, placeIDs)
  150. if err != nil {
  151. return nil, err
  152. }
  153. featuresMap, err := s.placeRepo.GetFeaturesBatch(ctx, placeIDs)
  154. if err != nil {
  155. return nil, err
  156. }
  157. for _, p := range places {
  158. if tags, ok := tagsMap[p.ID]; ok {
  159. p.Tags = tags
  160. }
  161. if features, ok := featuresMap[p.ID]; ok {
  162. p.Features = features
  163. }
  164. }
  165. }
  166. limit := filter.Limit()
  167. hasMore := len(places) > limit
  168. if hasMore {
  169. places = places[:limit]
  170. }
  171. var nextCursor string
  172. if hasMore && len(places) > 0 {
  173. nextCursor = encodeCursor(places[len(places)-1])
  174. }
  175. return &models.PaginatedPlaces{
  176. Data: places,
  177. NextCursor: nextCursor,
  178. HasMore: hasMore,
  179. }, nil
  180. }
  181. type UpdatePlaceInput struct {
  182. ID string
  183. OwnerID string
  184. Title *string
  185. Description *string
  186. Address *string
  187. Lat *float64
  188. Lng *float64
  189. CoverImage *string
  190. AccessInfo *string
  191. HourlyRate *int
  192. Currency *string
  193. MinHours *int
  194. Tags []models.Tag
  195. Features []models.Feature
  196. }
  197. func (s *PlaceService) Update(ctx context.Context, input UpdatePlaceInput, isModerator bool) (*models.Place, error) {
  198. place, err := s.placeRepo.GetByID(ctx, input.ID)
  199. if err != nil {
  200. return nil, err
  201. }
  202. if place == nil {
  203. return nil, nil
  204. }
  205. if !isModerator && place.OwnerID != input.OwnerID {
  206. return nil, fmt.Errorf("%w: user %s tried to update place %s", ErrNotYourPlace, input.OwnerID, input.ID)
  207. }
  208. if input.Title != nil { place.Title = *input.Title }
  209. if input.Description != nil { place.Description = input.Description }
  210. if input.Address != nil { place.Address = input.Address }
  211. if input.Lat != nil { place.Lat = *input.Lat }
  212. if input.Lng != nil { place.Lng = *input.Lng }
  213. if input.CoverImage != nil { place.CoverImage = input.CoverImage }
  214. if input.AccessInfo != nil { place.AccessInfo = input.AccessInfo }
  215. if input.HourlyRate != nil { place.HourlyRate = input.HourlyRate }
  216. if input.Currency != nil { place.Currency = *input.Currency }
  217. if input.MinHours != nil { place.MinHours = *input.MinHours }
  218. if !isModerator {
  219. place.Status = "pending_moderation"
  220. }
  221. if err := s.placeRepo.Update(ctx, place); err != nil {
  222. return nil, err
  223. }
  224. if input.Tags != nil {
  225. if err := s.placeRepo.SetTags(ctx, place.ID, input.Tags); err != nil {
  226. return nil, err
  227. }
  228. place.Tags = input.Tags
  229. }
  230. if input.Features != nil {
  231. if err := s.placeRepo.SetFeatures(ctx, place.ID, input.Features); err != nil {
  232. return nil, err
  233. }
  234. place.Features = input.Features
  235. }
  236. return place, nil
  237. }
  238. func (s *PlaceService) Moderate(ctx context.Context, id, action, comment, moderatorID string) error {
  239. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  240. if err != nil {
  241. return err
  242. }
  243. if place == nil {
  244. return nil
  245. }
  246. var status string
  247. switch action {
  248. case "approve":
  249. status = "published"
  250. case "reject":
  251. status = "rejected"
  252. case "rework":
  253. status = "revision"
  254. case "revoke":
  255. status = "pending_moderation"
  256. default:
  257. return fmt.Errorf("unknown action: %s", action)
  258. }
  259. entry := &models.ModerationLog{
  260. ModeratorID: moderatorID,
  261. TargetType: "place",
  262. TargetID: id,
  263. Action: action,
  264. OldStatus: &place.Status,
  265. NewStatus: &status,
  266. }
  267. if comment != "" {
  268. entry.Comment = &comment
  269. }
  270. if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
  271. return fmt.Errorf("log moderation: %w", err)
  272. }
  273. return s.placeRepo.UpdateStatus(ctx, id, status, comment)
  274. }
  275. func (s *PlaceService) Delete(ctx context.Context, id, userID string) error {
  276. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  277. if err != nil {
  278. return err
  279. }
  280. if place == nil {
  281. return nil
  282. }
  283. entry := &models.ModerationLog{
  284. ModeratorID: userID,
  285. TargetType: "place",
  286. TargetID: id,
  287. Action: "soft_delete",
  288. OldStatus: &place.Status,
  289. NewStatus: pointer.Str("deleted"),
  290. }
  291. if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
  292. return fmt.Errorf("log soft delete: %w", err)
  293. }
  294. return s.placeRepo.SoftDelete(ctx, id)
  295. }
  296. func (s *PlaceService) HardDelete(ctx context.Context, id, moderatorID string) error {
  297. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  298. if err != nil {
  299. return err
  300. }
  301. if place == nil {
  302. return nil
  303. }
  304. var urls []string
  305. if place.CoverImage != nil {
  306. urls = append(urls, *place.CoverImage)
  307. }
  308. images, err := s.placeRepo.GetPlaceImages(ctx, id)
  309. if err != nil {
  310. return err
  311. }
  312. for _, img := range images {
  313. urls = append(urls, img.URL)
  314. }
  315. if len(urls) > 0 {
  316. if err := s.storage.DeleteObjects(ctx, urls); err != nil {
  317. return fmt.Errorf("delete place files: %w", err)
  318. }
  319. }
  320. entry := &models.ModerationLog{
  321. ModeratorID: moderatorID,
  322. TargetType: "place",
  323. TargetID: id,
  324. Action: "hard_delete",
  325. OldStatus: &place.Status,
  326. }
  327. if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
  328. return fmt.Errorf("log hard delete: %w", err)
  329. }
  330. return s.placeRepo.HardDelete(ctx, id)
  331. }
  332. func (s *PlaceService) Restore(ctx context.Context, id, moderatorID string) error {
  333. place, err := s.placeRepo.GetByIDRaw(ctx, id)
  334. if err != nil {
  335. return err
  336. }
  337. if place == nil {
  338. return nil
  339. }
  340. entry := &models.ModerationLog{
  341. ModeratorID: moderatorID,
  342. TargetType: "place",
  343. TargetID: id,
  344. Action: "restore",
  345. OldStatus: &place.Status,
  346. NewStatus: pointer.Str("published"),
  347. }
  348. if err := s.moderationLogRepo.Create(ctx, entry); err != nil {
  349. return fmt.Errorf("log restore: %w", err)
  350. }
  351. return s.placeRepo.Restore(ctx, id)
  352. }