백로그
0할 일
0진행 중
0리뷰
4[리드관리 PR1] 리드 목록 + 퍼널 스코프
계획서: docs/features/admin_lead_management.md (§3, §6, §7-PR1) 목표: 운영자가 퍼널별(가입자/무료진단/유료진단/과정신청/수료자) 리드를 어드민에서 바로 조회. 읽기 전용, 위험 낮음. MVP 첫 단계. 작업: - User 퍼널 스코프 추가(app/models/user.rb): with_completed_diagnosis, with_paid_diagnosis, free_diagnosis_only, course_graduates, marketing_subscribed, not_suppressed(※PR3 전까지는 EmailSuppression 미존재 → 이 스코프는 PR3에서 추가). User#funnel_stage(도달 최상위 단계 심볼) 구현. - 인덱스 마이그레이션: diagnosis_sessions(is_report_purchased), diagnosis_sessions(user_id), expert_certificates(user_id) 확인/추가. - Admin::Marketing::LeadsController#index — 기존 admin/users#index 패턴(검색/필터/정렬/_pagination) 복제. 컬럼: 이름/이메일, 퍼널 단계 배지, 진단 수, 마지막 활동, 수신동의. 필터: 퍼널 단계, 타겟, 수신동의, 가입/진단 기간, 검색(이름/이메일/전화). #show는 기존 admin_user_path로 링크. - 라우트: namespace admin > namespace marketing > resources :leads, only: [index, show]. - 사이드바(_sidebar.html.erb) "리드 관리" 그룹 + nav_item 3개(leads/segments/campaigns) 추가 + ko.yml admin.nav 라벨 추가. (en/zh/vi 금지) 테스트(Minitest+fixtures): - free_diagnosis_only: completed 보유 & 유료 0만 포함, 유료 보유자 제외. - with_paid_diagnosis: is_report_purchased=true 세션 보유자만. - course_graduates: completion 인증서 보유자만. - funnel_stage: 수료자는 유료/무료 보유와 무관하게 graduate 반환. - LeadsController#index: 비관리자 차단, 퍼널 필터 동작, 페이지네이션, ko 강제. 완료 기준: 어드민 사이드바 "리드 관리 > 리드"에서 퍼널별 필터·검색·페이지네이션 동작.
[리드관리 PR2] 세그먼트 (조건 저장형 동적 세그먼트)
계획서: docs/features/admin_lead_management.md (§4.1, §4.5, §6, §7-PR2) 선행: PR1. 목표: 운영자가 필터 조건을 저장하면 대상이 자동 산출되는 동적 세그먼트. 캠페인 발송 대상의 기반. 작업: - 마이그레이션 marketing_segments(uuid): name(unique), description, conditions(jsonb), cached_count(int,0), count_refreshed_at, created_by_id(FK users). - MarketingSegment 모델 + 검증. - Marketing::SegmentQuery 서비스: conditions(jsonb) → AR 스코프 변환. 화이트리스트 기반(허용 키/값 외 무시 → SQL 인젝션 차단). 지원 조건(MVP): funnel_stages(OR), facts(AND: has_paid_diagnosis 등), diagnosis_target, locale, email_subscription, created_after, first_diagnosis_after, days_since_diagnosis_gte, utm_source. - Admin::Marketing::SegmentsController CRUD + member post :refresh_count. 조건 빌더 폼(select/checkbox). show: 대상 수 + 샘플 리드 20명 + "이 세그먼트로 캠페인 만들기" 버튼. 저장/refresh 시 cached_count 갱신. 테스트: - SegmentQuery: 미허용 키/값 무시(인젝션 차단), funnel_stages OR, facts AND 결합. - days_since_diagnosis_gte 경계값 정확. - refresh_count가 실제 대상 수와 일치. 주의: "진단 결과 유형(WAY/DOMAIN)" 조건은 §4.5 스파이크(결과 점수 저장 위치 확인) 후 fast-follow. 이 PR 범위에서 제외. 완료 기준: 세그먼트 생성/저장 후 show에서 자동 산출된 대상 수·샘플 노출.
[리드관리 PR3] Resend 발송 기반 (웹훅·수신거부·suppression)
계획서: docs/features/admin_lead_management.md (§5, §7-PR3) 목표: 마케팅 발송 인프라. 발송 자체(PR4) 전에 로그/웹훅/컴플라이언스 토대를 만든다. 기존 트랜잭션 메일(Gmail SMTP)은 건드리지 않음. 운영 현황: 9way.co.kr Resend 인증 완료(Tokyo). RESEND_API_KEY 설정 완료. 이 PR에서 RESEND_WEBHOOK_SECRET + MARKETING_FROM_EMAIL(marketing@9way.co.kr) 추가 필요(.env / .kamal/secrets / .env.deploy). 작업: - Gemfile gem "resend" + config/initializers/resend.rb (Resend.api_key = ENV["RESEND_API_KEY"]). delivery_method 전역 변경 금지. - 마이그레이션: email_deliveries(campaign_id, user_id, email_address, resend_email_id[unique idx], status, delivered_at/opened_at/clicked_at/bounced_at, open_count/click_count, error_message; idx [campaign_id,status], user_id), email_suppressions(email_address[unique], reason enum, suppressed_at). - EmailDelivery, EmailSuppression 모델. - User 스코프 not_suppressed 추가(PR1에서 보류분). - Webhooks::ResendController#create — POST /webhooks/resend, Svix 서명검증(RESEND_WEBHOOK_SECRET), 멱등. 이벤트→deliveries/카운터: delivered/opened(고유+open_count)/clicked/bounced(→suppression hard_bounce)/complained(→suppression complaint + user.email_subscription=false). - EmailSubscriptionsController#unsubscribe — GET /email/unsubscribe?token=, message_verifier(:unsubscribe)로 user id 서명(신규 컬럼 불필요). 처리: email_subscription=false + EmailSuppression(unsubscribe). 확인 페이지(ko). - 브랜드 이메일 레이아웃 partial + unsubscribe 링크 + List-Unsubscribe/List-Unsubscribe-Post 헤더. - Marketing::TokenRenderer — 화이트리스트 토큰만 치환({{name}},{{funnel_stage}} 우선; {{primary_way}}/{{primary_domain}}는 스파이크 후), HTML 이스케이프. 테스트: - 웹훅 서명 실패 401 / 성공 200. - 각 이벤트가 delivery·카운터 갱신, 같은 이벤트 재수신 멱등. - complained → email_subscription=false + suppression. - unsubscribe 유효 토큰 해제 / 위조 토큰 거부. - TokenRenderer 화이트리스트·이스케이프. 완료 기준: 웹훅/unsubscribe/suppression 동작(실제 발송은 PR4). 외부 호출은 테스트에서 stub.
[리드관리 PR4] 캠페인 작성 + 수동 발송 (Resend 배치)
계획서: docs/features/admin_lead_management.md (§4.2, §5.2, §6, §7-PR4) 선행: PR2(세그먼트), PR3(발송 기반). MVP 마지막 단계. 목표: 운영자가 세그먼트를 골라 메일을 작성·미리보기·테스트 후 Resend로 발송하고 성과를 확인. 작업: - 마이그레이션 email_campaigns(uuid): name, subject, preheader, body, from_name(default "9WAY"), reply_to, segment_id(FK), status enum(draft/scheduled/sending/sent/failed), scheduled_at(컬럼만), sent_at, recipients_count + delivered/opened/clicked/bounced/unsubscribed_count, created_by_id. - EmailCampaign 모델 + 상태 전이. - Admin::Marketing::CampaignsController CRUD(index/new/create/show) + member post :send_test, post :deliver. 작성 폼(제목/본문 + 토큰 도우미 + 미리보기), 테스트 발송(본인), 발송 트리거. show: status + 성과(발송/도달/오픈/클릭/바운스/수신거부) + deliveries 테이블. - Marketing::CampaignSender 서비스: 세그먼트 대상 산출 → 게이트 필터(marketing_subscribed & not_suppressed & email 존재) → 수신자별 토큰 렌더 + 레이아웃 래핑 + unsubscribe 주입 → 100명 단위 Resend::Batch.send → 반환 id로 email_deliveries 생성(status=sent). Resend 클라이언트는 주입 가능하게 분리(테스트 mock). - Marketing::SendCampaignJob (Solid Queue) 비동기. 배치 일부 실패는 delivery=failed + error_message, 캠페인 부분성공 허용. 테스트: - CampaignSender 게이트: 수신거부·suppression·이메일 없음 제외. - 100명 초과 시 배치 분할. - 발송 후 deliveries 생성 + status=sent/recipients_count 일치. - send_test는 본인에게만. - 배치 일부 실패 시 해당 delivery=failed, 캠페인 부분성공. - Resend 호출 stub(테스트에서 실제 발송 금지). 완료 기준: 어드민에서 세그먼트 선택→작성→테스트→발송, show에서 웹훅 기반 성과 갱신 확인. = MVP 완성.