places.go 9.9 KB

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