From 85cb7d294043170d2e8b59d612612efa2325edd8 Mon Sep 17 00:00:00 2001 From: Kiyori <113906780+thxforall@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:05:09 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs(git):=20dev=E2=86=92main=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EC=B9=98=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=A0=95=EB=A6=BD=20(#127)=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): solution 이미지 미표시 수정 (#97) (#98) * fix(web): use Supabase direct query for solutions instead of Rust proxy - Replace listSolutions (Orval REST → Rust backend) with direct Supabase query in useAllSolutionsForSpots hook - Make API_BASE_URL optional in server-env.ts to prevent module crash when Rust backend is not configured Fixes #97 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(web): enrich hero posts with spots/solutions data Hero posts were created with items: [] since the Supabase fallback was added. Now fetches spots+solutions for hero posts server-side so item overlay markers appear when a hero card is focused. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * fix(web): Korean search fallback + published filter in search (#102) - Trigger Supabase fallback when backend returns empty results for non-empty query (enables synonym expansion for Korean→English) - Add post_magazines inner join + created_with_solutions filter to search fallback to match browse mode behavior Fixes #99 Co-authored-by: Claude Opus 4.6 (1M context) * docs(git): dev→main 브랜치 워크플로우 정립 (#127) - GIT-WORKFLOW.md에 브랜치 전략 섹션 추가 (feature→dev→main) - 긴급 hotfix 예외 플로우 문서화 - CLAUDE.md git workflow 요약 업데이트 Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- docs/GIT-WORKFLOW.md | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b59242f..24f64e42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ When adding a new API endpoint: ## Git workflow -요약: `main` 직접 push 금지, PR로만 머지. 브랜치 접두사·커밋·리뷰 절차는 **[docs/GIT-WORKFLOW.md](docs/GIT-WORKFLOW.md)**. +요약: `feature/*` → `dev` → `main` 플로우. `main` 직접 push 금지, `dev`→`main` PR 머지만 허용. 긴급 시 `hotfix/*`→`main` 예외. 상세는 **[docs/GIT-WORKFLOW.md](docs/GIT-WORKFLOW.md)**. ## Codebase documentation diff --git a/docs/GIT-WORKFLOW.md b/docs/GIT-WORKFLOW.md index 56fccfd8..878fdded 100644 --- a/docs/GIT-WORKFLOW.md +++ b/docs/GIT-WORKFLOW.md @@ -105,20 +105,47 @@ bun run ci:local # 전체 (push 훅과 동일) just ci-web # web만 ``` +## 브랜치 전략 + +``` +feature/* ──PR──▶ dev ──PR──▶ main (production) +fix/* ──PR──▶ dev ──PR──▶ main +hotfix/* ──PR──▶ main (긴급 시에만) +``` + +| 브랜치 | 역할 | push 정책 | +|--------|------|-----------| +| `main` | 프로덕션. Vercel 자동 배포 | **직접 push 금지**, PR 머지만 허용 | +| `dev` | 통합 개발 브랜치 | 팀원 push 허용, feature 브랜치 PR 머지 | +| `feature/*`, `fix/*` 등 | 작업 브랜치 | dev에서 분기, dev로 PR | + +### 워크플로우 + +1. `dev`에서 작업 브랜치 생성: `git checkout -b feat/xxx dev` +2. 작업 완료 후 `dev`로 PR 생성 +3. 리뷰 통과 후 `dev`에 머지 +4. 릴리스 준비 시 `dev` → `main` PR 생성 +5. CI 체크 통과 + 리뷰 후 `main`에 머지 → Vercel 자동 배포 + +### 긴급 핫픽스 + +프로덕션 장애 시에만 `hotfix/*` → `main` 직접 PR 허용. 머지 후 `dev`에도 반영 필수. + ## Main 브랜치 보호 - **직접 push 금지**: pre-push 훅이 `main`/`master` push를 차단 -- **PR 머지만 허용**: 브랜치 → PR → 리뷰 → 머지 +- **PR 머지만 허용**: `dev` → `main` PR만 허용 (긴급 hotfix 제외) +- **GitHub branch protection**: CI checks required ## 리뷰 프로세스 -1. 브랜치에서 작업 완료 +1. 작업 브랜치에서 개발 완료 2. `/review` 실행 — Claude code-reviewer로 자동 리뷰 3. P0(차단 이슈) 모두 해결 -4. PR 생성 -5. 팀원 리뷰 -6. 승인 후 머지 +4. `dev`로 PR 생성 +5. 팀원 리뷰 + 승인 후 머지 +6. 릴리스 시 `dev` → `main` PR 생성 및 머지 ## 릴리스 플로우 -현재는 main 머지 시 자동 배포 (Vercel). 별도 릴리스 브랜치 없음. +`dev` → `main` PR 머지 시 Vercel 자동 배포. 별도 릴리스 브랜치 없음. From 052efa5983dfd054f1b02678c69477c99708ed3f Mon Sep 17 00:00:00 2001 From: Raf Date: Thu, 9 Apr 2026 23:51:15 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=ED=99=98=EA=B2=BD=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A8=EC=9D=BC=20.env.backend.{env}=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api-server/.env.{env} + ai-server/.{env}.env 2개 파일을 루트 .env.backend.{env} 1개로 통합하여 관리 포인트를 줄임. - .env.backend.example 추가 (통합 템플릿) - docker-compose 3개 (dev/staging/prod) env_file 경로 통합 - deploy-backend.sh env 파일 체크 단순화 - 충돌 키 LOG_FORMAT은 compose environment에서 서비스별 오버라이드 Closes #118 Co-authored-by: Claude Opus 4.6 (1M context) --- .env.backend.example | 94 +++++++++++++++++++ .gitignore | 3 + packages/api-server/docker/stack/README.md | 6 +- .../docker/stack/docker-compose.prod.yml | 12 ++- .../docker/stack/docker-compose.staging.yml | 12 ++- .../docker/stack/docker-compose.yml | 12 ++- scripts/deploy-backend.sh | 37 +++----- 7 files changed, 134 insertions(+), 42 deletions(-) create mode 100644 .env.backend.example diff --git a/.env.backend.example b/.env.backend.example new file mode 100644 index 00000000..45be526b --- /dev/null +++ b/.env.backend.example @@ -0,0 +1,94 @@ +# Decoded Backend — unified env file for Docker stack (api + ai + meilisearch + redis + searxng) +# Usage: cp .env.backend.example .env.backend.prod (or .env.backend.dev / .env.backend.staging) +# +# Docker Compose overrides internal service URLs automatically (MEILISEARCH_URL, REDIS_HOST, etc.) +# You only need to fill in external service credentials below. + +# ─── Shared ─────────────────────────────────────────────── +API_SERVER_GRPC_PORT=50053 + +# ─── API Server (Rust/Axum) ────────────────────────────── +ENV=production +HOST=0.0.0.0 +PORT=8000 +RUST_LOG=info +LOG_FORMAT=text +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Database (Supabase PostgreSQL) +DATABASE_URL=postgresql://postgres:[PASSWORD]@[PROJECT-REF].supabase.co:5432/postgres +DB_MAX_CONNECTIONS=100 +DB_MIN_CONNECTIONS=5 +DB_CONNECT_TIMEOUT=30 +DB_IDLE_TIMEOUT=600 + +# Supabase Auth +SUPABASE_URL=https://[PROJECT-REF].supabase.co +SUPABASE_ANON_KEY=eyJhbGc... +SUPABASE_SERVICE_ROLE_KEY=eyJhbGc... +SUPABASE_JWT_SECRET=your-jwt-secret + +# Meilisearch +MEILISEARCH_URL=http://localhost:7700 +MEILISEARCH_MASTER_KEY=your-master-key + +# Cloudflare R2 +R2_ACCOUNT_ID=your-account-id +R2_ACCESS_KEY_ID=your-access-key +R2_SECRET_ACCESS_KEY=your-secret-key +R2_BUCKET_NAME=decoded-images +R2_PUBLIC_URL=https://pub-xxxxx.r2.dev + +# Affiliate +RAKUTEN_API_KEY=your-rakuten-key +RAKUTEN_PUBLISHER_ID=your-publisher-id + +# AI Queue gRPC (API -> ai-server) +AI_SERVER_GRPC_URL=http://localhost:50052 + +# Vector Search (OpenAI Embeddings) +OPENAI_API_KEY=sk-... +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +OPENAI_EMBEDDING_DIMENSIONS=256 + +# ─── AI Server (Python/gRPC) ───────────────────────────── +APP_ENV=prod + +JWT_SECRET_KEY=your-jwt-secret-change-me +JWT_ALGORITHM=HS256 + +LOG_LEVEL=INFO +# Note: LOG_FORMAT is set per-service in docker-compose (api=text, ai=json) + +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=your-redis-password +REDIS_DB=0 + +QUEUE_BATCH_SIZE=10 + +API_SERVER_HTTP_URL=http://localhost:8000 +API_SERVER_ACCESS_TOKEN=your_backend_token + +SELENIUM_URL=http://localhost:4444 + +API_SERVER_GRPC_HOST=localhost + +PERPLEXITY_API_KEY= +PERPLEXITY_API_URL=https://api.perplexity.ai +PERPLEXITY_MODEL=sonar +PERPLEXITY_MAX_RETRIES=3 +PERPLEXITY_REQUEST_TIMEOUT=30 + +SEARXNG_API_URL=http://localhost:4000 +SEARXNG_MAX_RETRIES=3 +SEARXNG_REQUEST_TIMEOUT=10 + +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +TELEGRAM_ENABLED=false + +BATCH_SIZE=10 +MAX_CONCURRENT_REQUESTS=5 +REQUEST_TIMEOUT=30 +MAX_RETRIES=3 diff --git a/.gitignore b/.gitignore index e5dc1603..3c2967c3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ .env .env.local .env.*.local +.env.backend.dev +.env.backend.staging +.env.backend.prod # Next.js .next/ diff --git a/packages/api-server/docker/stack/README.md b/packages/api-server/docker/stack/README.md index ac3c125a..e5f33222 100644 --- a/packages/api-server/docker/stack/README.md +++ b/packages/api-server/docker/stack/README.md @@ -4,7 +4,7 @@ ## Build / run (모노레포 루트) -1. `packages/api-server/.env.dev` + `packages/ai-server/.dev.env` 준비 (staging/prod는 각각 `.env.staging` / `.staging.env`, `.env.prod` / `.prod.env`). +1. 모노레포 루트에 `.env.backend.dev` 준비 (staging: `.env.backend.staging`, prod: `.env.backend.prod`). 템플릿: `.env.backend.example`. 2. 배포 스크립트: ```bash @@ -35,13 +35,13 @@ Compose `environment`로 덮어쓰는 값: `MEILISEARCH_URL`, `AI_SERVER_GRPC_UR ## 수동 compose ```bash -docker compose --env-file packages/api-server/.env.dev \ +docker compose --env-file .env.backend.dev \ -f packages/api-server/docker/stack/docker-compose.yml up --build ``` ## Meilisearch 키 -`deploy-backend.sh`는 API env를 `--env-file`로 넘겨 `${MEILISEARCH_MASTER_KEY}` 보간에 사용합니다. prod는 `packages/api-server/.env.prod`에 키가 있어야 합니다. +`deploy-backend.sh`는 통합 env를 `--env-file`로 넘겨 `${MEILISEARCH_MASTER_KEY}` 보간에 사용합니다. prod는 `.env.backend.prod`에 키가 있어야 합니다. ## 환경 변수 이름 diff --git a/packages/api-server/docker/stack/docker-compose.prod.yml b/packages/api-server/docker/stack/docker-compose.prod.yml index b1184a0f..63c7f8d6 100644 --- a/packages/api-server/docker/stack/docker-compose.prod.yml +++ b/packages/api-server/docker/stack/docker-compose.prod.yml @@ -1,7 +1,7 @@ # Production-style: api + ai + Meilisearch + Redis + SearXNG (Meili/Redis/SearXNG not published to host). -# Env: packages/api-server/.env.prod + packages/ai-server/.prod.env +# Env: .env.backend.prod (repo root, unified) # From repo root: bash scripts/deploy-backend.sh prod up -# Set MEILISEARCH_MASTER_KEY in packages/api-server/.env.prod (used with deploy-backend.sh --env-file). +# Set MEILISEARCH_MASTER_KEY in .env.backend.prod (used with deploy-backend.sh --env-file). name: decoded-backend-prod @@ -15,10 +15,11 @@ services: ports: - "8080:8080" env_file: - - ../../.env.prod + - ../../../../.env.backend.prod environment: PORT: "8080" HOST: "0.0.0.0" + LOG_FORMAT: text MEILISEARCH_URL: http://meilisearch:7700 AI_SERVER_GRPC_URL: http://ai:50051 API_SERVER_GRPC_PORT: "50052" @@ -44,8 +45,9 @@ services: expose: - "50051" env_file: - - ../../../ai-server/.prod.env + - ../../../../.env.backend.prod environment: + LOG_FORMAT: json REDIS_HOST: redis REDIS_PORT: "6379" SEARXNG_API_URL: http://searxng:8080 @@ -95,7 +97,7 @@ services: networks: - decoded-backend-prod env_file: - - ../../../ai-server/.prod.env + - ../../../../.env.backend.prod command: - /bin/sh - -c diff --git a/packages/api-server/docker/stack/docker-compose.staging.yml b/packages/api-server/docker/stack/docker-compose.staging.yml index 31bb0dbc..0839aa93 100644 --- a/packages/api-server/docker/stack/docker-compose.staging.yml +++ b/packages/api-server/docker/stack/docker-compose.staging.yml @@ -1,5 +1,5 @@ # Staging: api + ai + Meilisearch + Redis + SearXNG -# Env: packages/api-server/.env.staging + packages/ai-server/.staging.env +# Env: .env.backend.staging (repo root, unified) # From repo root: bash scripts/deploy-backend.sh staging up name: decoded-backend-staging @@ -14,10 +14,11 @@ services: ports: - "8081:8080" env_file: - - ../../.env.staging + - ../../../../.env.backend.staging environment: PORT: "8080" HOST: "0.0.0.0" + LOG_FORMAT: text MEILISEARCH_URL: http://meilisearch:7700 AI_SERVER_GRPC_URL: http://ai:50051 API_SERVER_GRPC_PORT: "50052" @@ -43,8 +44,9 @@ services: expose: - "50051" env_file: - - ../../../ai-server/.staging.env + - ../../../../.env.backend.staging environment: + LOG_FORMAT: json REDIS_HOST: redis REDIS_PORT: "6379" SEARXNG_API_URL: http://searxng:8080 @@ -52,7 +54,7 @@ services: API_SERVER_GRPC_HOST: api API_SERVER_GRPC_PORT: "50052" AI_GRPC_LISTEN_PORT: "50051" - APP_ENV: prod + APP_ENV: staging ENV: staging depends_on: api: @@ -98,7 +100,7 @@ services: networks: - decoded-backend-staging env_file: - - ../../../ai-server/.staging.env + - ../../../../.env.backend.staging command: - /bin/sh - -c diff --git a/packages/api-server/docker/stack/docker-compose.yml b/packages/api-server/docker/stack/docker-compose.yml index 9c2c5e16..79159536 100644 --- a/packages/api-server/docker/stack/docker-compose.yml +++ b/packages/api-server/docker/stack/docker-compose.yml @@ -1,6 +1,6 @@ # Decoded backend: api + ai + Meilisearch + Redis + SearXNG (multi-container). # From repo root: bash scripts/deploy-backend.sh dev up --build -# Requires packages/api-server/.env.dev + packages/ai-server/.dev.env +# Requires .env.backend.dev (repo root, unified) name: decoded-backend @@ -14,10 +14,11 @@ services: ports: - "8080:8080" env_file: - - ../../.env.dev + - ../../../../.env.backend.dev environment: PORT: "8080" HOST: "0.0.0.0" + LOG_FORMAT: text MEILISEARCH_URL: http://meilisearch:7700 AI_SERVER_GRPC_URL: http://ai:50051 API_SERVER_GRPC_PORT: "50052" @@ -44,8 +45,9 @@ services: expose: - "50051" env_file: - - ../../../ai-server/.dev.env + - ../../../../.env.backend.dev environment: + LOG_FORMAT: json REDIS_HOST: redis REDIS_PORT: "6379" SEARXNG_API_URL: http://searxng:8080 @@ -53,7 +55,7 @@ services: API_SERVER_GRPC_HOST: api API_SERVER_GRPC_PORT: "50052" AI_GRPC_LISTEN_PORT: "50051" - APP_ENV: prod + APP_ENV: dev depends_on: api: condition: service_healthy @@ -98,7 +100,7 @@ services: networks: - decoded-backend env_file: - - ../../../ai-server/.dev.env + - ../../../../.env.backend.dev command: - /bin/sh - -c diff --git a/scripts/deploy-backend.sh b/scripts/deploy-backend.sh index a34a8b9b..a6d2b4b3 100755 --- a/scripts/deploy-backend.sh +++ b/scripts/deploy-backend.sh @@ -11,13 +11,13 @@ # 액션: up | down | build | pull | ps | logs | restart | config # 기본 액션: up -d # -# 필수 env 파일: -# dev: packages/api-server/.env.dev + packages/ai-server/.dev.env -# staging: packages/api-server/.env.staging + packages/ai-server/.staging.env -# prod: packages/api-server/.env.prod + packages/ai-server/.prod.env +# 필수 env 파일 (모노레포 루트): +# dev: .env.backend.dev +# staging: .env.backend.staging +# prod: .env.backend.prod # -# Meilisearch: compose의 ${MEILISEARCH_MASTER_KEY}는 API env 파일로 보간됨 (--env-file). -# prod는 .env.prod에 MEILISEARCH_MASTER_KEY 필수(:?). +# Meilisearch: compose의 ${MEILISEARCH_MASTER_KEY}는 env 파일로 보간됨 (--env-file). +# prod는 .env.backend.prod에 MEILISEARCH_MASTER_KEY 필수(:?). set -euo pipefail @@ -44,18 +44,15 @@ EXTRA=("$@") case "$ENV" in dev) COMPOSE="$STACK/docker-compose.yml" - API_ENV="$ROOT/packages/api-server/.env.dev" - AI_ENV="$ROOT/packages/ai-server/.dev.env" + ENV_FILE="$ROOT/.env.backend.dev" ;; staging) COMPOSE="$STACK/docker-compose.staging.yml" - API_ENV="$ROOT/packages/api-server/.env.staging" - AI_ENV="$ROOT/packages/ai-server/.staging.env" + ENV_FILE="$ROOT/.env.backend.staging" ;; prod) COMPOSE="$STACK/docker-compose.prod.yml" - API_ENV="$ROOT/packages/api-server/.env.prod" - AI_ENV="$ROOT/packages/ai-server/.prod.env" + ENV_FILE="$ROOT/.env.backend.prod" ;; *) usage @@ -63,16 +60,8 @@ case "$ENV" in esac require_env_files() { - local missing=0 - if [[ ! -f "$API_ENV" ]]; then - echo "Missing: $API_ENV (copy from packages/api-server/.env.dev.example or sibling)" >&2 - missing=1 - fi - if [[ ! -f "$AI_ENV" ]]; then - echo "Missing: $AI_ENV (copy from packages/ai-server/.dev.env.example or sibling)" >&2 - missing=1 - fi - if [[ "$missing" -ne 0 ]]; then + if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing: $ENV_FILE (copy from .env.backend.example)" >&2 exit 1 fi } @@ -81,8 +70,8 @@ compose() { # --env-file: ${MEILISEARCH_MASTER_KEY} 등 compose 파일 내 보간용 (컨테이너 전체에 노출되지 않음) # set -u + 빈 EXTRA[@]는 일부 bash(예: macOS 3.2)에서 실패하므로 배열로 합쳐서 실행 local -a cmd - if [[ -f "$API_ENV" ]]; then - cmd=(docker compose --env-file "$API_ENV" -f "$COMPOSE" "$@") + if [[ -f "$ENV_FILE" ]]; then + cmd=(docker compose --env-file "$ENV_FILE" -f "$COMPOSE" "$@") else cmd=(docker compose -f "$COMPOSE" "$@") fi From 7e4ee13aae13846fd899daa9f1585e3c747a2813 Mon Sep 17 00:00:00 2001 From: Raf Date: Thu, 9 Apr 2026 23:55:40 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20env=20compose=20hotfix=20(Meilisearc?= =?UTF-8?q?h=20=EA=B2=BD=EB=A1=9C=20+=20dev=20ENV)=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Meilisearch 에러 메시지 경로 수정 + dev compose ENV 오버라이드 추가 - docker-compose.prod.yml: MEILISEARCH_MASTER_KEY 에러 메시지에서 옛 경로(packages/api-server/.env.prod) → .env.backend.prod로 수정 - docker-compose.yml: dev api 서비스에 ENV=development 명시하여 .env.backend.example 복사 시 production 모드로 뜨는 문제 방지 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: staging Meilisearch MEILI_ENV를 production으로 변경 staging에서 MEILI_ENV=development로 되어있던 것을 production으로 수정. Meilisearch는 development/production 두 모드만 지원. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/api-server/docker/stack/docker-compose.prod.yml | 2 +- packages/api-server/docker/stack/docker-compose.staging.yml | 2 +- packages/api-server/docker/stack/docker-compose.yml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api-server/docker/stack/docker-compose.prod.yml b/packages/api-server/docker/stack/docker-compose.prod.yml index 63c7f8d6..8f6f2f7b 100644 --- a/packages/api-server/docker/stack/docker-compose.prod.yml +++ b/packages/api-server/docker/stack/docker-compose.prod.yml @@ -83,7 +83,7 @@ services: - decoded-backend-prod environment: MEILI_ENV: production - MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY:?set MEILISEARCH_MASTER_KEY in packages/api-server/.env.prod (used with deploy-backend.sh --env-file)} + MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY:?set MEILISEARCH_MASTER_KEY in .env.backend.prod (used with deploy-backend.sh --env-file)} healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:7700/health"] interval: 10s diff --git a/packages/api-server/docker/stack/docker-compose.staging.yml b/packages/api-server/docker/stack/docker-compose.staging.yml index 0839aa93..ff6f70a4 100644 --- a/packages/api-server/docker/stack/docker-compose.staging.yml +++ b/packages/api-server/docker/stack/docker-compose.staging.yml @@ -83,7 +83,7 @@ services: networks: - decoded-backend-staging environment: - MEILI_ENV: development + MEILI_ENV: production MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY:-staging-meili-key} healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:7700/health"] diff --git a/packages/api-server/docker/stack/docker-compose.yml b/packages/api-server/docker/stack/docker-compose.yml index 79159536..3138ed04 100644 --- a/packages/api-server/docker/stack/docker-compose.yml +++ b/packages/api-server/docker/stack/docker-compose.yml @@ -19,6 +19,7 @@ services: PORT: "8080" HOST: "0.0.0.0" LOG_FORMAT: text + ENV: development MEILISEARCH_URL: http://meilisearch:7700 AI_SERVER_GRPC_URL: http://ai:50051 API_SERVER_GRPC_PORT: "50052" From a461c52a6f8bbf8e5822286bedbd1d8fe62c2cdb Mon Sep 17 00:00:00 2001 From: Raf Date: Fri, 10 Apr 2026 00:14:17 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(ci):=20=EB=B0=B1=EC=97=94=EB=93=9C=20C?= =?UTF-8?q?D=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=E2=80=94?= =?UTF-8?q?=20self-hosted=20runner=20=EC=9E=90=EB=8F=99=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main 머지 시 Mac Mini self-hosted runner에서 자동 배포: - path filter: api-server, ai-server, deploy script 변경 시 트리거 - workflow_dispatch로 수동 배포 지원 - 롤백: 빌드 전 :prev 태깅, 실패 시 자동 복원 - health check: api(:8080) + ai(:10000) 최대 5분 대기 - Telegram 알림 (성공/실패) Closes #119 Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-backend.yml | 128 +++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/deploy-backend.yml diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 00000000..7eb4e204 --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,128 @@ +name: Deploy backend (prod) + +on: + push: + branches: [main] + paths: + - 'packages/api-server/**' + - 'packages/ai-server/**' + - 'scripts/deploy-backend.sh' + workflow_dispatch: + +concurrency: + group: deploy-backend-prod + cancel-in-progress: false + +permissions: + contents: read + +env: + API_IMAGE: decoded-backend-prod-api + AI_IMAGE: decoded-backend-prod-ai + +jobs: + deploy: + runs-on: self-hosted + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Tag current images as :prev + run: | + for img in "$API_IMAGE" "$AI_IMAGE"; do + if docker image inspect "$img:latest" >/dev/null 2>&1; then + docker tag "$img:latest" "$img:prev" + echo "Tagged $img:latest -> $img:prev" + else + echo "No existing $img:latest to tag" + fi + done + + - name: Deploy + run: bash scripts/deploy-backend.sh prod up --build + + - name: Health check — api + run: | + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/health; then + echo "" + echo "api healthy" + exit 0 + fi + echo "Waiting for api... ($i/30)" + sleep 10 + done + echo "::error::api health check failed after 300s" + exit 1 + + - name: Health check — ai + run: | + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:10000/health; then + echo "" + echo "ai healthy" + exit 0 + fi + echo "Waiting for ai... ($i/30)" + sleep 10 + done + echo "::error::ai health check failed after 300s" + exit 1 + + - name: Rollback on failure + if: failure() + run: | + echo "Rolling back to :prev images..." + for img in "$API_IMAGE" "$AI_IMAGE"; do + if docker image inspect "$img:prev" >/dev/null 2>&1; then + docker tag "$img:prev" "$img:latest" + echo "Restored $img:prev -> $img:latest" + fi + done + bash scripts/deploy-backend.sh prod up + echo "Rollback complete" + + - name: Notify Telegram + if: always() + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: | + if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then + echo "Telegram secrets not set, skipping notification" + exit 0 + fi + + SHORT_SHA="${GITHUB_SHA:0:7}" + COMMIT_MSG=$(git log -1 --pretty=%s | head -c 200) + + if [ "${{ job.status }}" = "success" ]; then + MSG="🚀 Backend deployed to prod + ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ + ${SHORT_SHA} ${COMMIT_MSG} + by ${{ github.actor }} + + ${{ github.server_url }}/${{ github.repository }}/commit/${GITHUB_SHA}" + else + MSG="💥 Backend deploy FAILED (rolled back) + ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ + ${SHORT_SHA} ${COMMIT_MSG} + by ${{ github.actor }} + + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + fi + + MSG=$(echo "$MSG" | sed 's/^[[:space:]]*//') + + PAYLOAD=$(jq -n \ + --arg chat_id "$TELEGRAM_CHAT_ID" \ + --arg text "$MSG" \ + '{chat_id: $chat_id, text: ($text | if length > 4096 then .[0:4096] else . end), disable_web_page_preview: true}') + + RESP=$(curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + + echo "$RESP" | jq . + echo "$RESP" | jq -e '.ok == true' >/dev/null || echo "::warning::Telegram notification failed" From 1f690d1568dc2cd10b48376ae93a458bd58a6524 Mon Sep 17 00:00:00 2001 From: Raf Date: Fri, 10 Apr 2026 00:18:28 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(ci):=20CD=20workflow=EC=97=90=EC=84=9C?= =?UTF-8?q?=20.env.backend.prod=20=EB=B3=B5=EC=82=AC=20=EC=8A=A4=ED=85=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runner 작업 디렉토리에 env 파일이 없어 배포 실패. Mac Mini의 고정 경로에서 checkout 디렉토리로 복사. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-backend.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 7eb4e204..a41cbb16 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -28,6 +28,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Copy env file + run: cp /Users/decoded/dev/decoded/.env.backend.prod .env.backend.prod + - name: Tag current images as :prev run: | for img in "$API_IMAGE" "$AI_IMAGE"; do From 567fd6ffee298814c96639837ba1e604c8bd2909 Mon Sep 17 00:00:00 2001 From: Raf Date: Fri, 10 Apr 2026 00:27:06 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(ci):=20ai=20health=20check=EB=A5=BC=20d?= =?UTF-8?q?ocker=20exec=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai 컨테이너는 포트 10000을 호스트에 publish하지 않아 호스트에서 curl이 실패함. docker exec로 컨테이너 내부에서 체크. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/deploy-backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index a41cbb16..ed60e003 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -62,7 +62,7 @@ jobs: - name: Health check — ai run: | for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:10000/health; then + if docker exec decoded-backend-ai-prod curl -sf http://127.0.0.1:10000/health; then echo "" echo "ai healthy" exit 0 From 8b33c32b470a2601ac464df27e97557c73341a39 Mon Sep 17 00:00:00 2001 From: Raf Date: Fri, 10 Apr 2026 10:40:30 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(api-server):=20Phase=202=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20API=20=EC=B6=94=EA=B0=80=20(B1+B2)=20(#161?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(web): Phase 1 — 클라이언트 Supabase 직접 호출을 백엔드 API로 전환 - authStore.ts: .from("users") select/update → getMyProfile()/updateMyProfile() - useImages.ts: Supabase 직접 쿼리 + fallback 제거 → listPosts() REST API - usePosts.ts: fetchPostWithSpotsAndSolutions → getPost() REST API - useSolutions.ts: fetchSolutionsFromSupabase fallback → listSolutions() REST API Part of #158 Co-Authored-By: Claude Opus 4.6 (1M context) * chore(web): 미사용 Supabase 파일 삭제 - lib/supabase/storage.ts — 정의만 있고 어디서도 import 안 됨 - lib/supabase/queries/debug/posts.ts — 개발용, production 미사용 Part of #158 Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api-server): UserResponse에 ink_credits/style_dna 추가 + social accounts 엔드포인트 B1: users 엔티티와 UserResponse에 ink_credits, style_dna 필드 추가 B2: GET /users/me/social-accounts 엔드포인트 신규 추가 - user_social_accounts 엔티티 추가 - SocialAccountResponse DTO - 서비스 + 핸들러 + OpenAPI 등록 Part of #160 Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api-server): B3 user stats 실제 구현 + B5 트렌딩 아티스트 엔드포인트 B3: GET /users/me/stats — stub(0) → 실제 posts/comments/likes count SQL 쿼리 B5: GET /rankings/artists — 트렌딩 아티스트 서버 사이드 SQL 집계 - period: weekly/monthly/all_time - limit: 최대 50 - artist_name별 post_count + 대표 이미지 Part of #160 Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api-server): B6 유저 Spots + B7 유저 Solutions 엔드포인트 추가 - GET /users/me/spots — 유저의 Spot 목록 (post image_url 포함, 페이지네이션) - GET /users/me/solutions — 유저의 Solution 목록 (active만, 페이지네이션) - UserSpotItem, UserSolutionItem DTO 추가 - OpenAPI 등록 Part of #160 Co-Authored-By: Claude Opus 4.6 (1M context) * test(web): Supabase→API 마이그레이션 E2E 테스트 추가 - 메인 피드 로딩 확인 - Explore 무한스크롤 확인 - 포스트 상세 페이지 확인 - 프로필 페이지 확인 - 백엔드 API health 확인 Part of #158 Co-Authored-By: Claude Opus 4.6 (1M context) * test(web): E2E 테스트 수정 — 비인증 환경 대응 + dotenv 로딩 - playwright.config.ts: dotenv 로딩 + api-migration을 chromium-no-auth에서 실행 - api-migration.spec.ts: 비인증 환경에 맞게 테스트 재작성 - 4/4 테스트 통과 확인 Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- bun.lock | 11 +- .../api-server/src/domains/rankings/dto.rs | 30 +++ .../src/domains/rankings/handlers.rs | 29 +++ .../src/domains/rankings/service.rs | 52 ++++ packages/api-server/src/domains/users/dto.rs | 50 ++++ .../api-server/src/domains/users/handlers.rs | 78 +++++- .../api-server/src/domains/users/service.rs | 167 ++++++++++++- packages/api-server/src/entities/mod.rs | 1 + .../src/entities/user_social_accounts.rs | 27 ++ packages/api-server/src/entities/users.rs | 6 + packages/api-server/src/openapi.rs | 9 + packages/web/lib/hooks/useImages.ts | 232 ++---------------- packages/web/lib/hooks/usePosts.ts | 32 +-- packages/web/lib/hooks/useSolutions.ts | 39 +-- packages/web/lib/stores/authStore.ts | 57 ++--- .../web/lib/supabase/queries/debug/posts.ts | 33 --- packages/web/lib/supabase/storage.ts | 69 ------ packages/web/package.json | 1 + packages/web/playwright.config.ts | 11 +- packages/web/tests/api-migration.spec.ts | 50 ++++ 20 files changed, 563 insertions(+), 421 deletions(-) create mode 100644 packages/api-server/src/entities/user_social_accounts.rs delete mode 100644 packages/web/lib/supabase/queries/debug/posts.ts delete mode 100644 packages/web/lib/supabase/storage.ts create mode 100644 packages/web/tests/api-migration.spec.ts 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(); + }); +}); From 807d751cabd2f603cf3c345dffa7229d9059f427 Mon Sep 17 00:00:00 2001 From: Raf Date: Fri, 10 Apr 2026 10:40:34 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor(web):=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20Supabase=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0=20(Phase=201+3)=20(#159?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(web): Phase 1 — 클라이언트 Supabase 직접 호출을 백엔드 API로 전환 - authStore.ts: .from("users") select/update → getMyProfile()/updateMyProfile() - useImages.ts: Supabase 직접 쿼리 + fallback 제거 → listPosts() REST API - usePosts.ts: fetchPostWithSpotsAndSolutions → getPost() REST API - useSolutions.ts: fetchSolutionsFromSupabase fallback → listSolutions() REST API Part of #158 Co-Authored-By: Claude Opus 4.6 (1M context) * chore(web): 미사용 Supabase 파일 삭제 - lib/supabase/storage.ts — 정의만 있고 어디서도 import 안 됨 - lib/supabase/queries/debug/posts.ts — 개발용, production 미사용 Part of #158 Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context)