places.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. // Package repository
  2. package repository
  3. import (
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "github.com/jackc/pgx/v5"
  8. "github.com/jackc/pgx/v5/pgxpool"
  9. "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
  10. )
  11. type PlaceRepo struct {
  12. pool *pgxpool.Pool
  13. }
  14. func NewPlaceRepo(pool *pgxpool.Pool) *PlaceRepo {
  15. return &PlaceRepo{pool: pool}
  16. }
  17. func (r *PlaceRepo) Create(ctx context.Context, p *models.Place) error {
  18. err := r.pool.QueryRow(ctx,
  19. `INSERT INTO places (type, owner_id, title, description, address, coordinates,
  20. cover_image, access_info, status, hourly_rate, currency, min_hours, booking_url)
  21. VALUES ($1, $2, $3, $4, $5, ST_SetSRID(ST_MakePoint($6, $7), 4326),
  22. $8, $9, $10, $11, $12, $13, $14)
  23. RETURNING id, created_at, updated_at`,
  24. p.Type, p.OwnerID, p.Title, p.Description, p.Address,
  25. p.Lng, p.Lat,
  26. p.CoverImage, p.AccessInfo, p.Status,
  27. p.HourlyRate, p.Currency, p.MinHours, p.BookingURL,
  28. ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
  29. if err != nil {
  30. return fmt.Errorf("create place: %w", err)
  31. }
  32. if len(p.Tags) > 0 {
  33. if err := r.SetTags(ctx, p.ID, p.Tags); err != nil {
  34. return err
  35. }
  36. }
  37. if len(p.Features) > 0 {
  38. if err := r.SetFeatures(ctx, p.ID, p.Features); err != nil {
  39. return err
  40. }
  41. }
  42. return nil
  43. }
  44. func (r *PlaceRepo) GetByID(ctx context.Context, id string) (*models.Place, error) {
  45. row := r.pool.QueryRow(ctx,
  46. `SELECT id, type, owner_id, title, description, address,
  47. ST_Y(coordinates::geometry), ST_X(coordinates::geometry),
  48. cover_image, access_info, status, moderation_comment,
  49. rating, reviews_count, hourly_rate, currency, min_hours, booking_url,
  50. version, created_at, updated_at, published_at, deleted_at
  51. FROM places WHERE id = $1 AND deleted_at IS NULL`, id)
  52. return scanPlace(row)
  53. }
  54. func (r *PlaceRepo) GetByIDWithDetails(ctx context.Context, id string) (*models.Place, error) {
  55. query := `
  56. SELECT p.id, p.type, p.owner_id, p.title, p.description, p.address,
  57. ST_Y(p.coordinates::geometry), ST_X(p.coordinates::geometry),
  58. p.cover_image, p.access_info, p.status, p.moderation_comment,
  59. p.rating, p.reviews_count, p.hourly_rate, p.currency, p.min_hours, p.booking_url,
  60. p.version, p.created_at, p.updated_at, p.published_at, p.deleted_at,
  61. COALESCE(json_agg(DISTINCT jsonb_build_object('id', t.id, 'name', t.name, 'category', t.category))
  62. FILTER (WHERE t.id IS NOT NULL), '[]'::json) as tags,
  63. COALESCE(json_agg(DISTINCT jsonb_build_object('id', f.id, 'name', f.name, 'category', f.category, 'icon', f.icon))
  64. FILTER (WHERE f.id IS NOT NULL), '[]'::json) as features
  65. FROM places p
  66. LEFT JOIN place_tags pt ON pt.place_id = p.id
  67. LEFT JOIN tags t ON t.id = pt.tag_id
  68. LEFT JOIN place_features pf ON pf.place_id = p.id
  69. LEFT JOIN features f ON f.id = pf.feature_id
  70. WHERE p.id = $1 AND p.deleted_at IS NULL
  71. GROUP BY p.id`
  72. row := r.pool.QueryRow(ctx, query, id)
  73. var p models.Place
  74. var tagsJSON, featuresJSON []byte
  75. err := row.Scan(
  76. &p.ID, &p.Type, &p.OwnerID, &p.Title, &p.Description, &p.Address,
  77. &p.Lat, &p.Lng,
  78. &p.CoverImage, &p.AccessInfo, &p.Status, &p.ModerationComment,
  79. &p.Rating, &p.ReviewsCount, &p.HourlyRate, &p.Currency, &p.MinHours, &p.BookingURL,
  80. &p.Version,
  81. &p.CreatedAt, &p.UpdatedAt, &p.PublishedAt, &p.DeletedAt,
  82. &tagsJSON, &featuresJSON,
  83. )
  84. if err != nil {
  85. if err == pgx.ErrNoRows {
  86. return nil, nil
  87. }
  88. return nil, fmt.Errorf("get place with details: %w", err)
  89. }
  90. if err := json.Unmarshal(tagsJSON, &p.Tags); err != nil {
  91. return nil, fmt.Errorf("unmarshal tags: %w", err)
  92. }
  93. if err := json.Unmarshal(featuresJSON, &p.Features); err != nil {
  94. return nil, fmt.Errorf("unmarshal features: %w", err)
  95. }
  96. return &p, nil
  97. }
  98. func (r *PlaceRepo) List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error) {
  99. q := `SELECT p.id, p.type, p.owner_id, p.title, p.description, p.address,
  100. ST_Y(p.coordinates::geometry), ST_X(p.coordinates::geometry),
  101. p.cover_image, p.access_info, p.status, p.moderation_comment,
  102. p.rating, p.reviews_count, p.hourly_rate, p.currency, p.min_hours, p.booking_url,
  103. p.version, p.created_at, p.updated_at, p.published_at, p.deleted_at
  104. FROM places p`
  105. args := pgx.NamedArgs{}
  106. joins := ""
  107. if len(filter.TagIDs) > 0 {
  108. joins += ` JOIN place_tags pt ON pt.place_id = p.id`
  109. }
  110. if len(filter.FeatureIDs) > 0 {
  111. joins += ` JOIN place_features pf ON pf.place_id = p.id`
  112. }
  113. // Если есть JOIN-ы, добавляем DISTINCT, чтобы избежать дубликатов мест при нескольких совпадениях
  114. if joins != "" {
  115. q = "SELECT DISTINCT" + q[6:] // заменяем "SELECT" на "SELECT DISTINCT"
  116. }
  117. if filter.Status != "deleted" {
  118. q += ` WHERE p.deleted_at IS NULL`
  119. } else {
  120. q += ` WHERE p.deleted_at IS NOT NULL`
  121. }
  122. if filter.Status != "" { q += ` AND p.status = @status`; args["status"] = filter.Status }
  123. if filter.Type != "" { q += ` AND p.type = @type`; args["type"] = filter.Type }
  124. if filter.OwnerID != "" { q += ` AND p.owner_id = @owner_id`; args["owner_id"] = filter.OwnerID }
  125. if filter.MinRating > 0 { q += ` AND p.rating >= @min_rating`; args["min_rating"] = filter.MinRating }
  126. if filter.PriceMin != nil { q += ` AND p.hourly_rate >= @price_min`; args["price_min"] = *filter.PriceMin }
  127. if filter.PriceMax != nil { q += ` AND p.hourly_rate <= @price_max`; args["price_max"] = *filter.PriceMax }
  128. if filter.Bounds != nil {
  129. q += ` AND ST_Intersects(p.coordinates, ST_MakeEnvelope(@sw_lng, @sw_lat, @ne_lng, @ne_lat, 4326))`
  130. args["sw_lng"] = filter.Bounds.SWLng; args["sw_lat"] = filter.Bounds.SWLat
  131. args["ne_lng"] = filter.Bounds.NELng; args["ne_lat"] = filter.Bounds.NELat
  132. }
  133. if len(filter.TagIDs) > 0 { q += ` AND pt.tag_id = ANY(@tag_ids)`; args["tag_ids"] = filter.TagIDs }
  134. if len(filter.FeatureIDs) > 0 { q += ` AND pf.feature_id = ANY(@feature_ids)`; args["feature_ids"] = filter.FeatureIDs }
  135. // Cursor-based pagination
  136. if filter.Cursor != "" && filter.CursorCreatedAt != nil {
  137. if filter.Sort == "rating" && filter.CursorRating != nil {
  138. q += ` AND (p.rating, p.created_at, p.id) < (@cursor_rating, @cursor_created_at, @cursor_id)`
  139. args["cursor_rating"] = *filter.CursorRating
  140. args["cursor_created_at"] = *filter.CursorCreatedAt
  141. args["cursor_id"] = filter.Cursor
  142. } else {
  143. q += ` AND (p.created_at, p.id) < (@cursor_created_at, @cursor_id)`
  144. args["cursor_created_at"] = *filter.CursorCreatedAt
  145. args["cursor_id"] = filter.Cursor
  146. }
  147. }
  148. if filter.Sort == "rating" {
  149. q += ` ORDER BY p.rating DESC, p.created_at DESC, p.id`
  150. } else if filter.Sort == "distance" && filter.UserLat != nil && filter.UserLng != nil {
  151. q += ` ORDER BY p.coordinates <-> ST_SetSRID(ST_MakePoint(@user_lng, @user_lat), 4326), p.created_at DESC, p.id`
  152. args["user_lng"] = *filter.UserLng; args["user_lat"] = *filter.UserLat
  153. } else {
  154. q += ` ORDER BY p.created_at DESC, p.id`
  155. }
  156. q += ` LIMIT @lim`
  157. args["lim"] = filter.Limit() + 1 // fetch one extra to detect hasMore
  158. rows, err := r.pool.Query(ctx, q, args)
  159. if err != nil {
  160. return nil, fmt.Errorf("list places: %w", err)
  161. }
  162. defer rows.Close()
  163. var places []*models.Place
  164. for rows.Next() {
  165. p, err := scanPlace(rows)
  166. if err != nil {
  167. return nil, err
  168. }
  169. places = append(places, p)
  170. }
  171. return places, nil
  172. }
  173. func (r *PlaceRepo) Update(ctx context.Context, p *models.Place) error {
  174. _, err := r.pool.Exec(ctx,
  175. `UPDATE places SET title=$1, description=$2, address=$3,
  176. coordinates=ST_SetSRID(ST_MakePoint($4, $5), 4326),
  177. cover_image=$6, access_info=$7, status=$8, hourly_rate=$9, currency=$10, min_hours=$11,
  178. booking_url=$12, updated_at=now(), version=version+1
  179. WHERE id=$13 AND deleted_at IS NULL`,
  180. p.Title, p.Description, p.Address,
  181. p.Lng, p.Lat,
  182. p.CoverImage, p.AccessInfo, p.Status,
  183. p.HourlyRate, p.Currency, p.MinHours, p.BookingURL,
  184. p.ID)
  185. return err
  186. }
  187. func (r *PlaceRepo) UpdateStatus(ctx context.Context, id, status, comment string) error {
  188. _, err := r.pool.Exec(ctx,
  189. `UPDATE places SET status=$1, moderation_comment=$2, updated_at=now(),
  190. published_at = CASE WHEN $3 THEN now() ELSE published_at END
  191. WHERE id=$4 AND deleted_at IS NULL`,
  192. status, comment, status == "published", id)
  193. return err
  194. }
  195. func (r *PlaceRepo) SoftDelete(ctx context.Context, id string) error {
  196. _, err := r.pool.Exec(ctx,
  197. `UPDATE places SET status='deleted', deleted_at=now() WHERE id=$1 AND deleted_at IS NULL`, id)
  198. return err
  199. }
  200. func (r *PlaceRepo) HardDelete(ctx context.Context, id string) error {
  201. _, err := r.pool.Exec(ctx, `DELETE FROM places WHERE id=$1`, id)
  202. return err
  203. }
  204. func (r *PlaceRepo) Restore(ctx context.Context, id string) error {
  205. _, err := r.pool.Exec(ctx,
  206. `UPDATE places SET status='published', deleted_at=NULL, published_at=now(), updated_at=now()
  207. WHERE id=$1 AND deleted_at IS NOT NULL`, id)
  208. return err
  209. }
  210. func (r *PlaceRepo) GetByIDRaw(ctx context.Context, id string) (*models.Place, error) {
  211. row := r.pool.QueryRow(ctx,
  212. `SELECT id, type, owner_id, title, description, address,
  213. ST_Y(coordinates::geometry), ST_X(coordinates::geometry),
  214. cover_image, access_info, status, moderation_comment,
  215. rating, reviews_count, hourly_rate, currency, min_hours, booking_url,
  216. version, created_at, updated_at, published_at, deleted_at
  217. FROM places WHERE id = $1`, id)
  218. return scanPlace(row)
  219. }
  220. func (r *PlaceRepo) GetPlaceImages(ctx context.Context, placeID string) ([]models.PlaceImage, error) {
  221. rows, err := r.pool.Query(ctx,
  222. `SELECT id, place_id, url, alt, sort_order, is_cover FROM place_images WHERE place_id = $1`,
  223. placeID)
  224. if err != nil {
  225. return nil, err
  226. }
  227. defer rows.Close()
  228. var images []models.PlaceImage
  229. for rows.Next() {
  230. var img models.PlaceImage
  231. if err := rows.Scan(&img.ID, &img.PlaceID, &img.URL, &img.Alt, &img.SortOrder, &img.IsCover); err != nil {
  232. return nil, err
  233. }
  234. images = append(images, img)
  235. }
  236. return images, nil
  237. }
  238. func (r *PlaceRepo) SetTags(ctx context.Context, placeID string, tags []models.Tag) error {
  239. tx, err := r.pool.Begin(ctx)
  240. if err != nil {
  241. return err
  242. }
  243. defer tx.Rollback(ctx)
  244. _, err = tx.Exec(ctx, `DELETE FROM place_tags WHERE place_id = $1`, placeID)
  245. if err != nil {
  246. return err
  247. }
  248. for _, tag := range tags {
  249. _, err = tx.Exec(ctx,
  250. `INSERT INTO place_tags (place_id, tag_id) VALUES ($1, $2)`,
  251. placeID, tag.ID)
  252. if err != nil {
  253. return err
  254. }
  255. }
  256. return tx.Commit(ctx)
  257. }
  258. func (r *PlaceRepo) SetFeatures(ctx context.Context, placeID string, features []models.Feature) error {
  259. tx, err := r.pool.Begin(ctx)
  260. if err != nil {
  261. return err
  262. }
  263. defer tx.Rollback(ctx)
  264. _, err = tx.Exec(ctx, `DELETE FROM place_features WHERE place_id = $1`, placeID)
  265. if err != nil {
  266. return err
  267. }
  268. for _, f := range features {
  269. _, err = tx.Exec(ctx,
  270. `INSERT INTO place_features (place_id, feature_id) VALUES ($1, $2)`,
  271. placeID, f.ID)
  272. if err != nil {
  273. return err
  274. }
  275. }
  276. return tx.Commit(ctx)
  277. }
  278. func (r *PlaceRepo) GetTags(ctx context.Context, placeID string) ([]models.Tag, error) {
  279. rows, err := r.pool.Query(ctx,
  280. `SELECT t.id, t.name, t.category FROM tags t
  281. JOIN place_tags pt ON pt.tag_id = t.id
  282. WHERE pt.place_id = $1`, placeID)
  283. if err != nil {
  284. return nil, err
  285. }
  286. defer rows.Close()
  287. var tags []models.Tag
  288. for rows.Next() {
  289. var t models.Tag
  290. if err := rows.Scan(&t.ID, &t.Name, &t.Category); err != nil {
  291. return nil, err
  292. }
  293. tags = append(tags, t)
  294. }
  295. return tags, nil
  296. }
  297. func (r *PlaceRepo) GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error) {
  298. rows, err := r.pool.Query(ctx,
  299. `SELECT f.id, f.name, f.category, f.icon FROM features f
  300. JOIN place_features pf ON pf.feature_id = f.id
  301. WHERE pf.place_id = $1`, placeID)
  302. if err != nil {
  303. return nil, err
  304. }
  305. defer rows.Close()
  306. var features []models.Feature
  307. for rows.Next() {
  308. var f models.Feature
  309. if err := rows.Scan(&f.ID, &f.Name, &f.Category, &f.Icon); err != nil {
  310. return nil, err
  311. }
  312. features = append(features, f)
  313. }
  314. return features, nil
  315. }
  316. func (r *PlaceRepo) GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error) {
  317. if len(placeIDs) == 0 {
  318. return map[string][]models.Tag{}, nil
  319. }
  320. rows, err := r.pool.Query(ctx,
  321. `SELECT pt.place_id, t.id, t.name, t.category FROM tags t
  322. JOIN place_tags pt ON pt.tag_id = t.id
  323. WHERE pt.place_id = ANY($1)`, placeIDs)
  324. if err != nil {
  325. return nil, err
  326. }
  327. defer rows.Close()
  328. result := make(map[string][]models.Tag)
  329. for rows.Next() {
  330. var placeID string
  331. var t models.Tag
  332. if err := rows.Scan(&placeID, &t.ID, &t.Name, &t.Category); err != nil {
  333. return nil, err
  334. }
  335. result[placeID] = append(result[placeID], t)
  336. }
  337. return result, nil
  338. }
  339. func (r *PlaceRepo) GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error) {
  340. if len(placeIDs) == 0 {
  341. return map[string][]models.Feature{}, nil
  342. }
  343. rows, err := r.pool.Query(ctx,
  344. `SELECT pf.place_id, f.id, f.name, f.category, f.icon FROM features f
  345. JOIN place_features pf ON pf.feature_id = f.id
  346. WHERE pf.place_id = ANY($1)`, placeIDs)
  347. if err != nil {
  348. return nil, err
  349. }
  350. defer rows.Close()
  351. result := make(map[string][]models.Feature)
  352. for rows.Next() {
  353. var placeID string
  354. var f models.Feature
  355. if err := rows.Scan(&placeID, &f.ID, &f.Name, &f.Category, &f.Icon); err != nil {
  356. return nil, err
  357. }
  358. result[placeID] = append(result[placeID], f)
  359. }
  360. return result, nil
  361. }
  362. func scanPlace(row interface{ Scan(dest ...any) error }) (*models.Place, error) {
  363. var p models.Place
  364. err := row.Scan(
  365. &p.ID, &p.Type, &p.OwnerID, &p.Title, &p.Description, &p.Address,
  366. &p.Lat, &p.Lng,
  367. &p.CoverImage, &p.AccessInfo, &p.Status, &p.ModerationComment,
  368. &p.Rating, &p.ReviewsCount, &p.HourlyRate, &p.Currency, &p.MinHours, &p.BookingURL,
  369. &p.Version,
  370. &p.CreatedAt, &p.UpdatedAt, &p.PublishedAt, &p.DeletedAt,
  371. )
  372. if err != nil {
  373. if err == pgx.ErrNoRows {
  374. return nil, nil
  375. }
  376. return nil, err
  377. }
  378. return &p, nil
  379. }