use sqlx::{PgConnection, PgPool}; use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use super::dto::{Favorite, ProductFavoriteRow, ShopFavoriteRow}; const PRODUCT_VISIBLE: &str = "p.status = 'published' AND sh.status = 'active'"; const PRODUCT_FROM: &str = "FROM favorites f JOIN products p ON p.id = f.product_id JOIN shops sh ON sh.id = p.shop_id LEFT JOIN LATERAL ( SELECT s.price_minor, s.currency FROM skus s WHERE s.product_id = p.id AND s.active = TRUE ORDER BY s.price_minor ASC, s.sku_code ASC LIMIT 1 ) sku ON TRUE"; const PRODUCT_SELECT: &str = "SELECT f.id, f.user_id, f.created_at, p.id AS product_id, p.shop_id, p.slug, p.name, CASE WHEN jsonb_typeof(p.images) = 'array' AND jsonb_array_length(p.images) > 0 THEN p.images->>0 ELSE NULL END AS image, sku.price_minor, sku.currency"; const SHOP_FROM: &str = "FROM favorites f JOIN shops s ON s.id = f.shop_id LEFT JOIN shop_profiles p ON p.shop_id = s.id"; const SHOP_SELECT: &str = "SELECT f.id, f.user_id, f.created_at, s.id AS shop_id, s.slug, s.name, p.company, p.region, p.address, p.logo, p.banner, p.notice, p.after_sale, p.score_rating, p.score_agreement, p.score_service, p.score_speed"; pub async fn lock_visible_product(tx: &mut PgConnection, product_id: Uuid) -> ApiResult { Ok(sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM products p JOIN shops sh ON sh.id = p.shop_id WHERE p.id = $1 AND p.status = 'published' AND sh.status = 'active' FOR SHARE OF p, sh )", ) .bind(product_id) .fetch_one(&mut *tx) .await?) } pub async fn lock_visible_shop(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult { Ok(sqlx::query_scalar( "SELECT EXISTS( SELECT 1 FROM shops WHERE id = $1 AND status = 'active' FOR SHARE )", ) .bind(shop_id) .fetch_one(&mut *tx) .await?) } pub async fn upsert_product( tx: &mut PgConnection, user_id: Uuid, product_id: Uuid, ) -> ApiResult { let inserted: Option = sqlx::query_scalar( "INSERT INTO favorites (user_id, product_id) VALUES ($1, $2) ON CONFLICT (user_id, product_id) WHERE product_id IS NOT NULL DO NOTHING RETURNING id", ) .bind(user_id) .bind(product_id) .fetch_optional(&mut *tx) .await?; if let Some(id) = inserted { return Ok(id); } sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND product_id = $2") .bind(user_id) .bind(product_id) .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::NotFound("favorite".into())) } pub async fn upsert_shop(tx: &mut PgConnection, user_id: Uuid, shop_id: Uuid) -> ApiResult { let inserted: Option = sqlx::query_scalar( "INSERT INTO favorites (user_id, shop_id) VALUES ($1, $2) ON CONFLICT (user_id, shop_id) WHERE shop_id IS NOT NULL DO NOTHING RETURNING id", ) .bind(user_id) .bind(shop_id) .fetch_optional(&mut *tx) .await?; if let Some(id) = inserted { return Ok(id); } sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND shop_id = $2") .bind(user_id) .bind(shop_id) .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::NotFound("favorite".into())) } pub async fn delete_product(db: &PgPool, user_id: Uuid, product_id: Uuid) -> ApiResult<()> { sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND product_id = $2") .bind(user_id) .bind(product_id) .execute(db) .await?; Ok(()) } pub async fn delete_shop(db: &PgPool, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> { sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND shop_id = $2") .bind(user_id) .bind(shop_id) .execute(db) .await?; Ok(()) } pub async fn count_products(db: &PgPool, user_id: Uuid, target_id: Option) -> ApiResult { Ok(sqlx::query_scalar(&format!( "SELECT count(*) {PRODUCT_FROM} WHERE f.user_id = $1 AND f.product_id IS NOT NULL AND {PRODUCT_VISIBLE} AND ($2::uuid IS NULL OR f.product_id = $2)" )) .bind(user_id) .bind(target_id) .fetch_one(db) .await?) } pub async fn count_shops(db: &PgPool, user_id: Uuid, target_id: Option) -> ApiResult { Ok(sqlx::query_scalar(&format!( "SELECT count(*) {SHOP_FROM} WHERE f.user_id = $1 AND f.shop_id IS NOT NULL AND s.status = 'active' AND ($2::uuid IS NULL OR f.shop_id = $2)" )) .bind(user_id) .bind(target_id) .fetch_one(db) .await?) } pub async fn list_products( db: &PgPool, user_id: Uuid, target_id: Option, limit: i64, offset: i64, ) -> ApiResult> { let rows = sqlx::query_as::<_, ProductFavoriteRow>(&format!( "{PRODUCT_SELECT} {PRODUCT_FROM} WHERE f.user_id = $1 AND f.product_id IS NOT NULL AND {PRODUCT_VISIBLE} AND ($2::uuid IS NULL OR f.product_id = $2) ORDER BY f.created_at DESC LIMIT $3 OFFSET $4" )) .bind(user_id) .bind(target_id) .bind(limit) .bind(offset) .fetch_all(db) .await?; Ok(rows .into_iter() .map(ProductFavoriteRow::into_favorite) .collect()) } pub async fn list_shops( db: &PgPool, user_id: Uuid, target_id: Option, limit: i64, offset: i64, ) -> ApiResult> { let rows = sqlx::query_as::<_, ShopFavoriteRow>(&format!( "{SHOP_SELECT} {SHOP_FROM} WHERE f.user_id = $1 AND f.shop_id IS NOT NULL AND s.status = 'active' AND ($2::uuid IS NULL OR f.shop_id = $2) ORDER BY f.created_at DESC LIMIT $3 OFFSET $4" )) .bind(user_id) .bind(target_id) .bind(limit) .bind(offset) .fetch_all(db) .await?; Ok(rows .into_iter() .map(ShopFavoriteRow::into_favorite) .collect()) } pub async fn get_product_favorite( tx: &mut PgConnection, user_id: Uuid, product_id: Uuid, ) -> ApiResult { let row = sqlx::query_as::<_, ProductFavoriteRow>(&format!( "{PRODUCT_SELECT} {PRODUCT_FROM} WHERE f.user_id = $1 AND f.product_id = $2 AND {PRODUCT_VISIBLE}" )) .bind(user_id) .bind(product_id) .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::NotFound("favorite".into()))?; Ok(row.into_favorite()) } pub async fn get_shop_favorite( tx: &mut PgConnection, user_id: Uuid, shop_id: Uuid, ) -> ApiResult { let row = sqlx::query_as::<_, ShopFavoriteRow>(&format!( "{SHOP_SELECT} {SHOP_FROM} WHERE f.user_id = $1 AND f.shop_id = $2 AND s.status = 'active'" )) .bind(user_id) .bind(shop_id) .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::NotFound("favorite".into()))?; Ok(row.into_favorite()) }