Replace mall fixture favorites with customer-scoped endpoints, and send signed-out shoppers back to the page they left after sign-in. Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
7.0 KiB
Rust
244 lines
7.0 KiB
Rust
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<bool> {
|
|
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<bool> {
|
|
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<Uuid> {
|
|
let inserted: Option<Uuid> = 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<Uuid> {
|
|
let inserted: Option<Uuid> = 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<Uuid>) -> ApiResult<i64> {
|
|
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<Uuid>) -> ApiResult<i64> {
|
|
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<Uuid>,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> ApiResult<Vec<Favorite>> {
|
|
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<Uuid>,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> ApiResult<Vec<Favorite>> {
|
|
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<Favorite> {
|
|
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<Favorite> {
|
|
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())
|
|
}
|