diff --git a/bun.lock b/bun.lock index 2dd24295..97f7eb95 100644 --- a/bun.lock +++ b/bun.lock @@ -124,6 +124,7 @@ "@types/react-dom": "19", "@types/react-is": "^19", "autoprefixer": "^10.4.22", + "dotenv": "^17.4.1", "eslint": "10", "eslint-config-next": "^16.0.3", "eslint-config-prettier": "^10.1.8", @@ -1648,7 +1649,7 @@ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], @@ -3238,6 +3239,8 @@ "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@expo/env/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "@expo/fingerprint/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@expo/fingerprint/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -3254,6 +3257,8 @@ "@expo/metro-config/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@expo/metro-config/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@expo/metro-config/hermes-parser": ["hermes-parser@0.29.1", "", { "dependencies": { "hermes-estree": "0.29.1" } }, "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA=="], @@ -3316,6 +3321,8 @@ "@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.5.4", "", { "dependencies": { "zod": "^4.3.5" } }, "sha512-2pEbhprh8lLGDfUI6mNm9EV104pjb3+aJsXrFaqfgOSre7r6NlgM5HcSbsLjzDAnTikjJhJ3IMal1Rz8WVwiOw=="], + "@sentry/bundler-plugin-core/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "@sentry/bundler-plugin-core/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -3370,6 +3377,8 @@ "cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "dotenv-expand/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "eslint-config-next/globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], diff --git a/packages/api-server/src/domains/rankings/dto.rs b/packages/api-server/src/domains/rankings/dto.rs index 9ccb94fb..cc5793f6 100644 --- a/packages/api-server/src/domains/rankings/dto.rs +++ b/packages/api-server/src/domains/rankings/dto.rs @@ -148,6 +148,36 @@ pub struct CategoryRank { pub points: i32, } +/// 트렌딩 아티스트 쿼리 +#[derive(Debug, Deserialize, ToSchema)] +pub struct TrendingArtistsQuery { + /// 기간 (weekly, monthly, all_time) + #[serde(default = "default_period")] + pub period: String, + + /// 반환 수 (기본 20, 최대 50) + #[serde(default = "default_trending_limit")] + pub limit: u64, +} + +fn default_trending_limit() -> u64 { + 20 +} + +/// 트렌딩 아티스트 항목 +#[derive(Debug, Serialize, ToSchema)] +pub struct TrendingArtistItem { + /// 아티스트명 + pub artist_name: String, + + /// 해당 기간 포스트 수 + pub post_count: i64, + + /// 대표 이미지 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, +} + #[cfg(test)] #[allow(clippy::disallowed_methods)] mod tests { diff --git a/packages/api-server/src/domains/rankings/handlers.rs b/packages/api-server/src/domains/rankings/handlers.rs index f14007ea..ab8cdce5 100644 --- a/packages/api-server/src/domains/rankings/handlers.rs +++ b/packages/api-server/src/domains/rankings/handlers.rs @@ -20,6 +20,7 @@ use crate::{ use super::dto::{ CategoryRankingResponse, MyRankingDetailResponse, RankingListResponse, RankingPeriodQuery, + TrendingArtistItem, TrendingArtistsQuery, }; use super::service::RankingsService; @@ -28,6 +29,7 @@ pub fn router(app_config: AppConfig) -> Router { Router::new() .route("/", get(list_rankings)) .route("/me", get(my_ranking_detail)) + .route("/artists", get(trending_artists)) .route("/{category}", get(category_rankings)) .route_layer(axum::middleware::from_fn_with_state( app_config.clone(), @@ -97,6 +99,33 @@ async fn category_rankings( } } +/// 트렌딩 아티스트 조회 (공개) +#[utoipa::path( + get, + path = "/api/v1/rankings/artists", + params( + ("period" = Option, Query, description = "Period (weekly, monthly, all_time)"), + ("limit" = Option, Query, description = "Number of artists to return (max 50)"), + ), + responses( + (status = 200, description = "Trending artists", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse) + ), + tag = "rankings" +)] +async fn trending_artists( + State(state): State, + Query(query): Query, +) -> Result { + let result = + RankingsService::get_trending_artists(&state.db, &query.period, query.limit).await; + + match result { + Ok(response) => Ok(Json(response)), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + /// 내 랭킹 상세 조회 (인증 필요) #[utoipa::path( get, diff --git a/packages/api-server/src/domains/rankings/service.rs b/packages/api-server/src/domains/rankings/service.rs index f6cc569b..2c2125e9 100644 --- a/packages/api-server/src/domains/rankings/service.rs +++ b/packages/api-server/src/domains/rankings/service.rs @@ -9,6 +9,8 @@ use sea_orm::{ use std::collections::HashMap; use uuid::Uuid; +use sea_orm::{ConnectionTrait, DbBackend, Statement}; + use crate::{ config::AppState, entities, @@ -512,6 +514,56 @@ impl RankingsService { }) } + /// 트렌딩 아티스트 조회 + pub async fn get_trending_artists( + db: &DatabaseConnection, + period: &str, + limit: u64, + ) -> AppResult> { + let limit = limit.min(50); + let now = chrono::Utc::now(); + let period_start = match period { + "monthly" => now - chrono::Duration::days(30), + "all_time" => chrono::DateTime::::MIN_UTC, + _ => now - chrono::Duration::weeks(1), // weekly default + }; + + let sql = r#" + SELECT + artist_name, + COUNT(*)::BIGINT AS post_count, + (ARRAY_AGG(image_url ORDER BY view_count DESC))[1] AS top_image_url + FROM public.posts + WHERE status = 'active' + AND artist_name IS NOT NULL + AND image_url IS NOT NULL + AND created_at >= $1 + GROUP BY artist_name + ORDER BY post_count DESC + LIMIT $2 + "#; + + let rows = db + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + sql, + [period_start.into(), (limit as i64).into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + let items = rows + .into_iter() + .map(|row| super::dto::TrendingArtistItem { + artist_name: row.try_get::("", "artist_name").unwrap_or_default(), + post_count: row.try_get::("", "post_count").unwrap_or(0), + image_url: row.try_get::>("", "top_image_url").ok().flatten(), + }) + .collect(); + + Ok(items) + } + /// 내 랭킹 상세 조회 pub async fn get_my_ranking_detail( state: &AppState, diff --git a/packages/api-server/src/domains/users/dto.rs b/packages/api-server/src/domains/users/dto.rs index c3cfb2c2..bbfdd6a4 100644 --- a/packages/api-server/src/domains/users/dto.rs +++ b/packages/api-server/src/domains/users/dto.rs @@ -43,6 +43,13 @@ pub struct UserResponse { /// 관리자 여부 pub is_admin: bool, + /// 잉크 크레딧 + pub ink_credits: i32, + + /// 스타일 DNA + #[serde(skip_serializing_if = "Option::is_none")] + pub style_dna: Option, + /// 팔로워 수 pub followers_count: i64, @@ -62,6 +69,8 @@ impl From for UserResponse { rank: user.rank, total_points: user.total_points, is_admin: user.is_admin, + ink_credits: user.ink_credits, + style_dna: user.style_dna, followers_count: 0, following_count: 0, } @@ -109,6 +118,47 @@ pub struct UserStatsResponse { pub rank: String, } +/// 소셜 계정 응답 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SocialAccountResponse { + /// 소셜 프로바이더 (google, kakao 등) + pub provider: String, + + /// 프로바이더 사용자 ID + pub provider_user_id: String, + + /// 마지막 동기화 시간 + #[serde(skip_serializing_if = "Option::is_none")] + pub last_synced_at: Option>, +} + +/// 유저 Spot 아이템 (프로필 목록용) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserSpotItem { + pub id: Uuid, + pub post_id: Uuid, + /// 포스트 이미지 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub post_image_url: Option, + pub position_left: String, + pub position_top: String, + pub status: String, + pub created_at: DateTime, +} + +/// 유저 Solution 아이템 (프로필 목록용) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserSolutionItem { + pub id: Uuid, + pub spot_id: Uuid, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option, + pub is_adopted: bool, + pub is_verified: bool, + pub created_at: DateTime, +} + /// VTON 히스토리 아이템 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TryItem { diff --git a/packages/api-server/src/domains/users/handlers.rs b/packages/api-server/src/domains/users/handlers.rs index 5a59dce5..13346e23 100644 --- a/packages/api-server/src/domains/users/handlers.rs +++ b/packages/api-server/src/domains/users/handlers.rs @@ -19,8 +19,9 @@ use crate::{ use super::{ dto::{ - SavedItem, TryItem, UpdateUserDto, UserActivitiesQuery, UserActivityItem, UserActivityType, - UserResponse, UserStatsResponse, + SavedItem, SocialAccountResponse, TryItem, UpdateUserDto, UserActivitiesQuery, + UserActivityItem, UserActivityType, UserResponse, UserSolutionItem, UserSpotItem, + UserStatsResponse, }, service, }; @@ -199,6 +200,76 @@ pub async fn get_my_saved( Ok(Json(result)) } +/// GET /api/v1/users/me/spots - 내 Spot 목록 +#[utoipa::path( + get, + path = "/api/v1/users/me/spots", + tag = "Users", + summary = "GET /api/v1/users/me/spots - 내 Spot 목록", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page (max 50)"), + ), + responses( + (status = 200, description = "Spot 목록", body = PaginatedResponse), + (status = 401, description = "인증 필요"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_my_spots( + State(state): State, + Extension(user): Extension, + Query(pagination): Query, +) -> AppResult>> { + let result = service::list_user_spots(&state.db, user.id, pagination).await?; + Ok(Json(result)) +} + +/// GET /api/v1/users/me/solutions - 내 Solution 목록 +#[utoipa::path( + get, + path = "/api/v1/users/me/solutions", + tag = "Users", + summary = "GET /api/v1/users/me/solutions - 내 Solution 목록", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page (max 50)"), + ), + responses( + (status = 200, description = "Solution 목록", body = PaginatedResponse), + (status = 401, description = "인증 필요"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_my_solutions( + State(state): State, + Extension(user): Extension, + Query(pagination): Query, +) -> AppResult>> { + let result = service::list_user_solutions(&state.db, user.id, pagination).await?; + Ok(Json(result)) +} + +/// GET /api/v1/users/me/social-accounts - 내 소셜 계정 목록 +#[utoipa::path( + get, + path = "/api/v1/users/me/social-accounts", + tag = "Users", + summary = "GET /api/v1/users/me/social-accounts - 내 소셜 계정 목록", + responses( + (status = 200, description = "소셜 계정 목록", body = Vec), + (status = 401, description = "인증 필요"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_my_social_accounts( + State(state): State, + Extension(user): Extension, +) -> AppResult>> { + let accounts = service::list_social_accounts(&state.db, user.id).await?; + Ok(Json(accounts)) +} + /// Users 도메인 라우터 pub fn router(app_config: AppConfig) -> Router { let protected_routes = Router::new() @@ -207,6 +278,9 @@ pub fn router(app_config: AppConfig) -> Router { .route("/me/stats", get(get_my_stats)) .route("/me/tries", get(get_my_tries)) .route("/me/saved", get(get_my_saved)) + .route("/me/spots", get(get_my_spots)) + .route("/me/solutions", get(get_my_solutions)) + .route("/me/social-accounts", get(get_my_social_accounts)) .route_layer(from_fn_with_state(app_config, auth_middleware)); Router::new() diff --git a/packages/api-server/src/domains/users/service.rs b/packages/api-server/src/domains/users/service.rs index d033f610..ea97cc37 100644 --- a/packages/api-server/src/domains/users/service.rs +++ b/packages/api-server/src/domains/users/service.rs @@ -3,19 +3,21 @@ //! 사용자 관련 비즈니스 로직 use sea_orm::{ - ActiveModelTrait, ConnectionTrait, DatabaseConnection, DbBackend, EntityTrait, Set, Statement, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, DbBackend, EntityTrait, + QueryFilter, QueryOrder, QuerySelect, Set, Statement, }; use uuid::Uuid; use crate::{ entities::users::{ActiveModel, Entity as Users, Model as UserModel}, + entities::user_social_accounts::Entity as UserSocialAccounts, error::{AppError, AppResult}, utils::pagination::{PaginatedResponse, Pagination}, }; use super::dto::{ - SavedItem, TryItem, UpdateUserDto, UserActivityItem, UserActivityType, UserResponse, - UserStatsResponse, + SavedItem, SocialAccountResponse, TryItem, UpdateUserDto, UserActivityItem, UserActivityType, + UserResponse, UserSolutionItem, UserSpotItem, UserStatsResponse, }; /// 사용자 ID로 프로필 조회 @@ -65,11 +67,44 @@ pub async fn get_user_stats( ) -> AppResult { let user = get_user_by_id(db, user_id).await?; + let count_query = |table: &str| { + format!( + "SELECT COUNT(*)::BIGINT AS cnt FROM public.{} WHERE user_id = $1", + table + ) + }; + + let query_count = |sql: String| async move { + db.query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + &sql, + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError) + .map(|r| r.map(|row| row.try_get::("", "cnt").unwrap_or(0)).unwrap_or(0)) + }; + + let total_posts = query_count(count_query("posts")).await?; + let total_comments = query_count(count_query("comments")).await?; + + let likes_sql = "SELECT COUNT(*)::BIGINT AS cnt FROM public.post_likes pl JOIN public.posts p ON pl.post_id = p.id WHERE p.user_id = $1"; + let total_likes_received = db + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + likes_sql, + [user_id.into()], + )) + .await + .map_err(AppError::DatabaseError)? + .map(|row| row.try_get::("", "cnt").unwrap_or(0)) + .unwrap_or(0); + Ok(UserStatsResponse { user_id, - total_posts: 0, - total_comments: 0, - total_likes_received: 0, + total_posts, + total_comments, + total_likes_received, total_points: user.total_points, rank: user.rank, }) @@ -86,6 +121,126 @@ pub async fn list_user_activities( Ok(PaginatedResponse::new(Vec::new(), pagination, 0)) } +/// 소셜 계정 목록 조회 +/// 유저의 Spot 목록 조회 +pub async fn list_user_spots( + db: &DatabaseConnection, + user_id: Uuid, + pagination: Pagination, +) -> AppResult> { + use crate::entities::spots::{Column as SpotCol, Entity as Spots}; + use sea_orm::PaginatorTrait; + + let per_page = pagination.per_page.min(50); + let total = Spots::find() + .filter(SpotCol::UserId.eq(user_id)) + .count(db) + .await + .map_err(AppError::DatabaseError)?; + + let spots = Spots::find() + .filter(SpotCol::UserId.eq(user_id)) + .order_by_desc(SpotCol::CreatedAt) + .offset(pagination.offset()) + .limit(per_page) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + + // Fetch post image_url for each spot + let post_ids: Vec = spots.iter().map(|s| s.post_id).collect(); + let posts = crate::entities::Posts::find() + .filter(crate::entities::posts::Column::Id.is_in(post_ids)) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + let post_map: std::collections::HashMap = posts + .into_iter() + .map(|p| (p.id, p.image_url)) + .collect(); + + let items = spots + .into_iter() + .map(|s| UserSpotItem { + id: s.id, + post_id: s.post_id, + post_image_url: Some(post_map.get(&s.post_id).cloned().unwrap_or_default()), + position_left: s.position_left, + position_top: s.position_top, + status: s.status, + created_at: s.created_at.with_timezone(&chrono::Utc), + }) + .collect(); + + Ok(PaginatedResponse::new(items, Pagination::new(pagination.page, per_page), total)) +} + +/// 유저의 Solution 목록 조회 +pub async fn list_user_solutions( + db: &DatabaseConnection, + user_id: Uuid, + pagination: Pagination, +) -> AppResult> { + use crate::entities::solutions::{Column as SolCol, Entity as Solutions}; + use sea_orm::PaginatorTrait; + + let per_page = pagination.per_page.min(50); + let total = Solutions::find() + .filter(SolCol::UserId.eq(user_id)) + .filter(SolCol::Status.eq("active")) + .count(db) + .await + .map_err(AppError::DatabaseError)?; + + let solutions = Solutions::find() + .filter(SolCol::UserId.eq(user_id)) + .filter(SolCol::Status.eq("active")) + .order_by_desc(SolCol::CreatedAt) + .offset(pagination.offset()) + .limit(per_page) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + + let items = solutions + .into_iter() + .map(|s| UserSolutionItem { + id: s.id, + spot_id: s.spot_id, + title: s.title, + thumbnail_url: s.thumbnail_url, + is_adopted: s.is_adopted, + is_verified: s.is_verified, + created_at: s.created_at.with_timezone(&chrono::Utc), + }) + .collect(); + + Ok(PaginatedResponse::new(items, Pagination::new(pagination.page, per_page), total)) +} + +pub async fn list_social_accounts( + db: &DatabaseConnection, + user_id: Uuid, +) -> AppResult> { + use sea_orm::ColumnTrait; + use sea_orm::QueryFilter; + + let accounts = UserSocialAccounts::find() + .filter(crate::entities::user_social_accounts::Column::UserId.eq(user_id)) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + + Ok(accounts + .into_iter() + .map(|a| SocialAccountResponse { + provider: a.provider, + provider_user_id: a.provider_user_id, + last_synced_at: a.last_synced_at.map(|dt| dt.with_timezone(&chrono::Utc)), + }) + .collect()) +} + async fn count_followers(db: &DatabaseConnection, user_id: Uuid) -> AppResult { let result = db .query_one(Statement::from_sql_and_values( diff --git a/packages/api-server/src/entities/mod.rs b/packages/api-server/src/entities/mod.rs index 4f709e1a..acf0afe5 100644 --- a/packages/api-server/src/entities/mod.rs +++ b/packages/api-server/src/entities/mod.rs @@ -31,6 +31,7 @@ pub mod subcategories; pub mod synonyms; pub mod try_spot_tags; pub mod user_badges; +pub mod user_social_accounts; pub mod user_tryon_history; pub mod users; pub mod view_logs; diff --git a/packages/api-server/src/entities/user_social_accounts.rs b/packages/api-server/src/entities/user_social_accounts.rs new file mode 100644 index 00000000..47b185f3 --- /dev/null +++ b/packages/api-server/src/entities/user_social_accounts.rs @@ -0,0 +1,27 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// User social accounts entity +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_social_accounts")] +pub struct Model { + #[sea_orm(primary_key, column_type = "Uuid")] + pub id: Uuid, + + pub user_id: Uuid, + + pub provider: String, + + pub provider_user_id: String, + + pub last_synced_at: Option, + + pub created_at: DateTimeWithTimeZone, + + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/api-server/src/entities/users.rs b/packages/api-server/src/entities/users.rs index 768606ad..de9b5648 100644 --- a/packages/api-server/src/entities/users.rs +++ b/packages/api-server/src/entities/users.rs @@ -24,6 +24,12 @@ pub struct Model { pub is_admin: bool, + #[sea_orm(column_type = "Integer", default_value = "0")] + pub ink_credits: i32, + + #[sea_orm(column_type = "Json", nullable)] + pub style_dna: Option, + pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, diff --git a/packages/api-server/src/openapi.rs b/packages/api-server/src/openapi.rs index 7ae4e789..15b9b47b 100644 --- a/packages/api-server/src/openapi.rs +++ b/packages/api-server/src/openapi.rs @@ -21,6 +21,9 @@ use utoipa::OpenApi; crate::domains::users::handlers::get_my_stats, crate::domains::users::handlers::get_my_tries, crate::domains::users::handlers::get_my_saved, + crate::domains::users::handlers::get_my_spots, + crate::domains::users::handlers::get_my_solutions, + crate::domains::users::handlers::get_my_social_accounts, crate::domains::categories::handlers::get_categories, crate::domains::subcategories::handlers::get_all_subcategories, crate::domains::subcategories::handlers::get_subcategories_by_category, @@ -74,6 +77,7 @@ use utoipa::OpenApi; crate::domains::rankings::handlers::list_rankings, crate::domains::rankings::handlers::category_rankings, crate::domains::rankings::handlers::my_ranking_detail, + crate::domains::rankings::handlers::trending_artists, // Earnings 도메인 핸들러 crate::domains::earnings::handlers::create_click, crate::domains::earnings::handlers::get_click_stats, @@ -139,6 +143,9 @@ use utoipa::OpenApi; crate::domains::users::dto::UserActivityItem, crate::domains::users::dto::UserActivityType, crate::domains::users::dto::UserStatsResponse, + crate::domains::users::dto::SocialAccountResponse, + crate::domains::users::dto::UserSpotItem, + crate::domains::users::dto::UserSolutionItem, crate::domains::users::dto::TryItem, crate::domains::users::dto::SavedItem, crate::domains::categories::dto::CategoryResponse, @@ -228,6 +235,8 @@ use utoipa::OpenApi; crate::domains::rankings::dto::RankingUser, crate::domains::rankings::dto::MyRanking, crate::domains::rankings::dto::CategoryRankingResponse, + crate::domains::rankings::dto::TrendingArtistItem, + crate::domains::rankings::dto::TrendingArtistsQuery, crate::domains::rankings::dto::CategoryRankingItem, crate::domains::rankings::dto::MyRankingDetailResponse, crate::domains::rankings::dto::SolutionStats, diff --git a/packages/web/lib/hooks/useImages.ts b/packages/web/lib/hooks/useImages.ts index f2afaa28..8f98e3f4 100644 --- a/packages/web/lib/hooks/useImages.ts +++ b/packages/web/lib/hooks/useImages.ts @@ -8,7 +8,6 @@ import { useQuery, useInfiniteQuery, - useQueryClient, keepPreviousData, } from "@tanstack/react-query"; import { @@ -20,8 +19,6 @@ import { } from "@decoded/shared/supabase/queries/images"; import { getPost, listPosts } from "@/lib/api/generated/posts/posts"; import { postDetailToImageDetail } from "@/lib/api/adapters/postDetailToImageDetail"; -import { fetchPostWithSpotsAndSolutions } from "@/lib/supabase/queries/posts"; -import { spotToItemRow } from "@/lib/components/detail/types"; import type { CategoryFilter, ImagePage, @@ -208,97 +205,20 @@ export function useInfinitePosts(params: { queryFn: async ({ pageParam }) => { const page = (pageParam as number) ?? 1; - // hasMagazine=true → REST API 사용 (Supabase posts 뷰에 magazine 컬럼 없음) - if (hasMagazine) { - const response = await listPosts({ - page, - per_page: limit, - sort, - has_magazine: true, - artist_name: mediaName ?? artistName, - group_name: castName ? undefined : groupName, - context: - contextType ?? - (category && category !== "all" ? category : undefined), - }); - - const items: PostGridItem[] = response.data.map((post) => ({ - id: post.id, - imageUrl: post.image_url, - postId: post.id, - postSource: "post" as const, - postAccount: post.artist_name ?? post.group_name ?? "", - postCreatedAt: post.created_at, - spotCount: post.spot_count ?? 0, - viewCount: post.view_count, - title: post.post_magazine_title ?? post.title ?? null, - })); - - const totalPages = response.pagination.total_pages; - const hasMore = page < totalPages; - return { items, nextPage: hasMore ? page + 1 : null, hasMore }; - } - - // 일반 모드 → Supabase 직접 쿼리 - const from = (page - 1) * limit; - const to = from + limit - 1; - - // Query posts directly with same filters as the old explore_posts view - let query = supabaseBrowserClient - .from("posts") - .select("*, post_magazines!inner(title)", { count: "exact" }) - .eq("status", "active") - .not("image_url", "is", null) - .eq("created_with_solutions", true) - .eq("post_magazines.status", "published"); - - // category filter (flat) — skip if contextType is set (hierarchical takes precedence) - if (category && category !== "all" && !contextType) { - query = query.eq("context", category); - } - if (artistName) { - query = query.ilike("artist_name", `%${artistName}%`); - } - if (groupName) { - query = query.ilike("group_name", `%${groupName}%`); - } - - // mediaName from hierarchical filter — matches group_name column - if (mediaName) { - query = query.ilike("group_name", `%${mediaName}%`); - } - // castName from hierarchical filter — matches artist_name column - if (castName) { - query = query.ilike("artist_name", `%${castName}%`); - } - // contextType from hierarchical filter — matches context column exactly - if (contextType) { - query = query.eq("context", contextType); - } - - // Sort - if (sort === "popular") { - query = query.order("view_count", { ascending: false }); - } else if (sort === "trending") { - query = query.order("trending_score", { ascending: false }); - } else { - query = query.order("created_at", { ascending: false }); - } - - query = query.range(from, to); - - const { data, count, error } = await query; - - if (error) { - throw new Error(error.message); - } - - const totalItems = count ?? 0; - const totalPages = Math.ceil(totalItems / limit); - const hasMore = page < totalPages; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items: PostGridItem[] = (data ?? []).map((post: any) => ({ + // REST API + const response = await listPosts({ + page, + per_page: limit, + sort, + has_magazine: true, + artist_name: mediaName ?? artistName, + group_name: castName ? undefined : groupName, + context: + contextType ?? + (category && category !== "all" ? category : undefined), + }); + + const items: PostGridItem[] = response.data.map((post) => ({ id: post.id, imageUrl: post.image_url, postId: post.id, @@ -310,6 +230,8 @@ export function useInfinitePosts(params: { title: post.post_magazine_title ?? post.title ?? null, })); + const totalPages = response.pagination.total_pages; + const hasMore = page < totalPages; return { items, nextPage: hasMore ? page + 1 : null, hasMore }; }, getNextPageParam: (lastPage) => lastPage.nextPage, @@ -325,129 +247,11 @@ export function useInfinitePosts(params: { * Tries REST API first (production), falls back to Supabase direct (dev without backend). */ export function usePostDetailForImage(postId: string) { - const queryClient = useQueryClient(); - return useQuery({ queryKey: ["posts", "detail", "image", postId], queryFn: async () => { - // Helper: eagerly prefetch magazine data once we have a magazine_id - const prefetchMagazine = (magazineId: string | null | undefined) => { - if (!magazineId) return; - queryClient.prefetchQuery({ - queryKey: ["post-magazines", magazineId], - queryFn: async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data, error } = await (supabaseBrowserClient as any) - .from("post_magazines") - .select("*") - .eq("id", magazineId) - .single(); - if (error || !data) return null; - return data as unknown as PostMagazineResponse; - }, - staleTime: 1000 * 60 * 5, - }); - }; - - // 1. Try REST API (works when backend is running) - try { - const response = await getPost(postId); - const detail = postDetailToImageDetail(response, postId); - // Start magazine fetch immediately (no waterfall) - prefetchMagazine((detail as any)?.post_magazine_id); - return detail; - } catch { - // Backend unavailable — fall through to Supabase - } - - // 2. Fallback: Supabase direct query - try { - const result = await fetchPostWithSpotsAndSolutions(postId); - if (!result) return null; - - const { post, spots, solutions } = result; - // DB has columns not in PostRow type (post_magazine_id, ai_summary, etc.) - const postAny = post as Record; - - // Eagerly prefetch magazine in Supabase fallback path too - prefetchMagazine(postAny.post_magazine_id as string | null); - - const items = spots.map((spot) => { - const topSolution = solutions.find((s) => s.spot_id === spot.id); - return spotToItemRow(spot, topSolution); - }); - - return { - id: post.id, - image_hash: "", - image_url: post.image_url, - status: (post.status ?? "pending") as - | "pending" - | "extracted" - | "skipped" - | "extracted_metadata", - with_items: items.length > 0, - created_at: post.created_at, - items, - posts: [ - { - id: post.id, - account: post.artist_name ?? post.group_name ?? "", - article: post.title ?? null, - created_at: post.created_at, - item_ids: null, - metadata: [], - ts: post.created_at, - } as any, - ], - postImages: [ - { - post: { - id: post.id, - account: post.artist_name ?? post.group_name ?? "", - article: post.title ?? null, - created_at: post.created_at, - } as any, - created_at: post.created_at, - item_locations: spots.map((s, idx) => ({ - item_id: idx + 1, - center: [ - Math.max( - 0, - Math.min( - 1, - parseFloat(s.position_left) > 1 - ? parseFloat(s.position_left) / 100 - : parseFloat(s.position_left) - ) - ), - Math.max( - 0, - Math.min( - 1, - parseFloat(s.position_top) > 1 - ? parseFloat(s.position_top) / 100 - : parseFloat(s.position_top) - ) - ), - ], - })), - item_locations_updated_at: post.updated_at, - } as any, - ], - // Extended fields (exist in DB but not in PostRow type) - post_owner_id: post.user_id ?? null, - post_magazine_id: (postAny.post_magazine_id as string) ?? null, - ai_summary: (postAny.ai_summary as string) ?? null, - artist_name: post.artist_name ?? null, - group_name: post.group_name ?? null, - created_with_solutions: - (postAny.created_with_solutions as boolean) ?? null, - like_count: (postAny.like_count as number) ?? 0, - } as ImageDetail; - } catch { - return null; - } + const response = await getPost(postId); + return postDetailToImageDetail(response, postId); }, enabled: !!postId, staleTime: 1000 * 60, diff --git a/packages/web/lib/hooks/usePosts.ts b/packages/web/lib/hooks/usePosts.ts index 3bb0d7d2..f2e4ac95 100644 --- a/packages/web/lib/hooks/usePosts.ts +++ b/packages/web/lib/hooks/usePosts.ts @@ -5,12 +5,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { - fetchPostWithSpotsAndSolutions, - fetchPostWithImagesAndItems, - type PostDetail, - type LegacyPostDetail, -} from "@/lib/supabase/queries/posts"; -import { + getPost, listPosts, updatePost as updatePostGenerated, deletePost as deletePostGenerated, @@ -20,7 +15,7 @@ import type { PostsListResponse, PostsListParams, } from "@/lib/api/mutation-types"; -import type { UpdatePostDto, PostResponse } from "@/lib/api/generated/models"; +import type { UpdatePostDto, PostDetailResponse } from "@/lib/api/generated/models"; // ============================================================ // Query Keys @@ -41,29 +36,15 @@ export const postKeys = { /** * React Query hook for fetching a single post with its spots and solutions + * Uses backend REST API instead of direct Supabase queries * * @param id - Post ID to fetch * @returns React Query result with data, loading, error states */ export function usePostById(id: string) { - return useQuery({ + return useQuery({ queryKey: postKeys.detail(id), - queryFn: () => fetchPostWithSpotsAndSolutions(id), - enabled: !!id, - }); -} - -/** - * React Query hook for fetching a single post in legacy format - * @deprecated Use usePostById instead - * - * @param id - Post ID to fetch - * @returns React Query result with legacy data format - */ -export function usePostByIdLegacy(id: string) { - return useQuery({ - queryKey: ["posts", "detail", "legacy", id], - queryFn: () => fetchPostWithImagesAndItems(id), + queryFn: () => getPost(id), enabled: !!id, }); } @@ -193,6 +174,5 @@ export type { Post, PostsListResponse, PostsListParams, - PostDetail, - LegacyPostDetail, + PostDetailResponse, }; diff --git a/packages/web/lib/hooks/useSolutions.ts b/packages/web/lib/hooks/useSolutions.ts index 69d9de3b..7d577e10 100644 --- a/packages/web/lib/hooks/useSolutions.ts +++ b/packages/web/lib/hooks/useSolutions.ts @@ -29,20 +29,13 @@ import type { MetadataResponse, AffiliateLinkResponse, } from "@/lib/api/generated/models"; -import { supabaseBrowserClient } from "@/lib/supabase/client"; - /** - * Cache Invalidation Boundaries (MIG-09) + * Cache Invalidation Boundaries * * REST API cache (Orval generated hooks): * Keys: solutionKeys, spotKeys, commentKeys, postKeys.lists() * Invalidated by: REST mutations below * - * Supabase direct query cache: - * Keys: postKeys.detail(id) via fetchPostWithSpotsAndSolutions - * Cross-boundary: useAdoptSolution/useUnadoptSolution explicitly - * invalidate ["posts", "detail"] — this is intentional. - * * Server-side fetches (fetchPostsServer): * No React Query cache — uses Next.js revalidate header only. */ @@ -82,39 +75,11 @@ export function useSolutions( // useAllSolutionsForSpots - Fetch solutions for multiple spots // ============================================================ -/** Solutions grouped by spot ID */ -/** Fetch solutions for a spot via Supabase (no Rust backend needed) */ -async function fetchSolutionsFromSupabase(spotId: string): Promise { - const { data, error } = await supabaseBrowserClient - .from("solutions") - .select("*, profiles:user_id(id, username, avatar_url)") - .eq("spot_id", spotId) - .order("created_at", { ascending: false }); - - if (error) throw new Error(error.message); - return (data ?? []).map((row: any) => ({ - id: row.id, - title: row.title ?? "", - thumbnail_url: row.thumbnail_url, - original_url: row.original_url, - affiliate_url: row.affiliate_url, - link_type: row.link_type, - metadata: row.metadata, - brand_id: row.brand_id, - match_type: row.match_type, - is_verified: row.is_verified ?? false, - is_adopted: row.is_adopted ?? false, - created_at: row.created_at, - vote_stats: { accurate: 0, different: 0 }, - user: { id: row.user_id, username: "", email: "", rank: "Member", total_points: 0, followers_count: 0, following_count: 0, is_admin: false }, - } as GeneratedSolutionListItem)); -} - export function useAllSolutionsForSpots(spotIds: string[]) { const results = useQueries({ queries: spotIds.map((spotId) => ({ queryKey: solutionKeys.list(spotId), - queryFn: () => fetchSolutionsFromSupabase(spotId), + queryFn: () => listSolutions(spotId), enabled: !!spotId, staleTime: 1000 * 60, })), diff --git a/packages/web/lib/stores/authStore.ts b/packages/web/lib/stores/authStore.ts index a6f30cce..475972f5 100644 --- a/packages/web/lib/stores/authStore.ts +++ b/packages/web/lib/stores/authStore.ts @@ -5,6 +5,8 @@ import { create } from "zustand"; import { type User as SupabaseUser } from "@supabase/supabase-js"; import { supabaseBrowserClient } from "@/lib/supabase/client"; +import { getMyProfile, updateMyProfile } from "@/lib/api/generated/users/users"; +import type { UpdateUserDto } from "@/lib/api/generated/models"; export type OAuthProvider = "kakao" | "google" | "apple"; @@ -243,30 +245,28 @@ export const useAuthStore = create((set, get) => ({ }, /** - * public.users 프로필 데이터 가져오기 + * 백엔드 API를 통해 프로필 데이터 가져오기 */ fetchProfile: async () => { const user = get().user; if (!user) return; try { - const { data, error } = await supabaseBrowserClient - .from("users") - .select("*") - .eq("id", user.id) - .single(); - - if (error) { - if (error.code === "PGRST116") { - console.log("[authStore] New user detected, needs onboarding"); - set({ needsOnboarding: true, profile: null }); - return; - } - console.error("Failed to fetch profile:", error); - return; - } - - const profile = data as unknown as UserProfile; + const data = await getMyProfile(); + + const profile: UserProfile = { + id: data.id, + email: data.email, + username: data.username, + display_name: data.display_name ?? null, + avatar_url: data.avatar_url ?? null, + bio: data.bio ?? null, + rank: data.rank ?? null, + total_points: data.total_points, + is_admin: data.is_admin, + created_at: "", + updated_at: "", + }; // Detect first-time user: username and display_name are both email-prefix defaults const emailPrefix = user.email.split("@")[0] || ""; @@ -279,13 +279,19 @@ export const useAuthStore = create((set, get) => ({ isAdmin: profile.is_admin === true, needsOnboarding: isDefault, }); - } catch (error) { + } catch (error: unknown) { + // 404 = new user not yet in users table + if (error && typeof error === "object" && "status" in error && (error as { status: number }).status === 404) { + console.log("[authStore] New user detected, needs onboarding"); + set({ needsOnboarding: true, profile: null }); + return; + } console.error("Profile fetch error:", error); } }, /** - * public.users 프로필 업데이트 + * 백엔드 API를 통해 프로필 업데이트 */ updateProfile: async ( updates: Partial> @@ -294,16 +300,7 @@ export const useAuthStore = create((set, get) => ({ if (!user) return false; try { - const { error } = await supabaseBrowserClient - .from("users") - .update(updates as Record) - .eq("id", user.id); - - if (error) { - console.error("Failed to update profile:", error); - return false; - } - + await updateMyProfile(updates as UpdateUserDto); // Re-fetch profile to get updated data await get().fetchProfile(); return true; diff --git a/packages/web/lib/supabase/queries/debug/posts.ts b/packages/web/lib/supabase/queries/debug/posts.ts deleted file mode 100644 index 36748374..00000000 --- a/packages/web/lib/supabase/queries/debug/posts.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * DEBUG ONLY: Query layer for posts table (client-side) - * - * This module is for debugging/reference purposes only. - * Production code should use the main queries module instead. - * - * Schema update (2026-01-29): 'post' table → 'posts' table - */ - -import { supabaseBrowserClient } from "../../client"; -import type { PostRow } from "../../types"; - -/** - * Fetches the latest posts from the database (client-side) - * - * @param limit - Maximum number of posts to fetch (default: 10) - * @returns Array of post rows, ordered by created_at descending - * @throws Error if the query fails - */ -export async function fetchLatestPosts(limit = 10): Promise { - const { data, error } = await supabaseBrowserClient - .from("posts") - .select("*") - .eq("status", "active") - .order("created_at", { ascending: false }) - .limit(limit); - - if (error) { - throw error; - } - - return data ?? []; -} diff --git a/packages/web/lib/supabase/storage.ts b/packages/web/lib/supabase/storage.ts deleted file mode 100644 index 05cd28d4..00000000 --- a/packages/web/lib/supabase/storage.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Supabase Storage 유틸리티 - * 이미지 업로드 및 URL 관리 - */ - -import { supabaseBrowserClient } from "./client"; - -const BUCKET_NAME = "request-images"; - -/** - * 고유한 파일 경로 생성 - */ -function generateFilePath(file: File): string { - const timestamp = Date.now(); - const randomId = Math.random().toString(36).slice(2, 9); - const extension = file.name.split(".").pop() || "jpg"; - return `temp/${timestamp}_${randomId}.${extension}`; -} - -/** - * Supabase Storage에 이미지 업로드 - * @param file - 업로드할 파일 - * @returns 공개 URL - */ -export async function uploadToSupabaseStorage(file: File): Promise { - const filePath = generateFilePath(file); - - const { error: uploadError } = await supabaseBrowserClient.storage - .from(BUCKET_NAME) - .upload(filePath, file, { - cacheControl: "31536000, immutable", - upsert: false, - }); - - if (uploadError) { - console.error("Supabase Storage upload error:", uploadError); - throw new Error(`이미지 업로드 실패: ${uploadError.message}`); - } - - // 공개 URL 가져오기 - const { - data: { publicUrl }, - } = supabaseBrowserClient.storage.from(BUCKET_NAME).getPublicUrl(filePath); - - return publicUrl; -} - -/** - * Supabase Storage에서 이미지 삭제 - * @param url - 삭제할 이미지의 공개 URL - */ -export async function deleteFromSupabaseStorage(url: string): Promise { - // URL에서 파일 경로 추출 - const urlParts = url.split(`${BUCKET_NAME}/`); - if (urlParts.length < 2) { - console.warn("Invalid storage URL format:", url); - return; - } - - const filePath = urlParts[1]; - - const { error } = await supabaseBrowserClient.storage - .from(BUCKET_NAME) - .remove([filePath]); - - if (error) { - console.error("Failed to delete from storage:", error); - } -} diff --git a/packages/web/package.json b/packages/web/package.json index 6101342f..a89033d3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -67,6 +67,7 @@ "@types/react-dom": "19", "@types/react-is": "^19", "autoprefixer": "^10.4.22", + "dotenv": "^17.4.1", "eslint": "10", "eslint-config-next": "^16.0.3", "eslint-config-prettier": "^10.1.8", diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts index f6b97ebf..19e3de5b 100644 --- a/packages/web/playwright.config.ts +++ b/packages/web/playwright.config.ts @@ -1,4 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; +import path from "path"; +import dotenv from "dotenv"; + +// Load .env.local for test credentials +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); /** * See https://playwright.dev/docs/test-configuration. @@ -42,14 +47,14 @@ export default defineConfig({ storageState: ".playwright/storageState.json", }, dependencies: ["setup"], - testIgnore: [/auth\.setup\.ts/, /login\.spec\.ts/], + testIgnore: [/auth\.setup\.ts/, /login\.spec\.ts/, /api-migration\.spec\.ts/], }, - // Unauthenticated tests — login flow, no storageState dependency + // Unauthenticated tests — login flow + API migration smoke tests { name: "chromium-no-auth", use: { ...devices["Desktop Chrome"] }, - testMatch: /login\.spec\.ts/, + testMatch: [/login\.spec\.ts/, /api-migration\.spec\.ts/], }, ], diff --git a/packages/web/tests/api-migration.spec.ts b/packages/web/tests/api-migration.spec.ts new file mode 100644 index 00000000..9410213f --- /dev/null +++ b/packages/web/tests/api-migration.spec.ts @@ -0,0 +1,50 @@ +/** + * E2E tests for Supabase → Backend API migration. + * + * Verifies that pages still work after removing direct Supabase queries + * and switching to backend REST API calls. + * + * Runs in "chromium-no-auth" project (no auth required). + */ +import { test, expect } from "@playwright/test"; + +test.describe("API migration — feed & posts", () => { + test("explore page loads with post grid", async ({ page }) => { + await page.goto("/explore"); + await page.waitForLoadState("networkidle"); + + // Should have visible content (images or grid items) + const content = page.locator("main"); + await expect(content).toBeVisible({ timeout: 10000 }); + }); + + test("post detail page loads directly", async ({ page }) => { + // Get a post ID from the API first + const response = await page.request.get("/api/v1/posts?page=1&per_page=1"); + const body = await response.json(); + const postId = body?.data?.[0]?.id; + + if (postId) { + await page.goto(`/posts/${postId}`); + await page.waitForLoadState("networkidle"); + + // Should render post detail with image + const img = page.locator("img").first(); + await expect(img).toBeVisible({ timeout: 10000 }); + } + }); +}); + +test.describe("API migration — backend health", () => { + test("backend posts API returns data", async ({ request }) => { + const response = await request.get("/api/v1/posts?page=1&per_page=1"); + expect(response.status()).toBeLessThan(500); + }); + + test("backend health check passes", async ({ request }) => { + const response = await request.get( + "http://localhost:8000/health" + ); + expect(response.ok()).toBeTruthy(); + }); +});