백로그
0할 일
11WorkspaceEngagementSurvey 마이그레이션 + 모델 + DiagnosisSession 확장
## 작업 내용 ### 1. 마이그레이션: create_workspace_engagement_surveys ``` - id: uuid, primary_key - workspace_id: uuid, NOT NULL, FK(workspaces), index - created_by_id: uuid, NOT NULL, FK(users) - title: string, NOT NULL (예: "2026 Q1") - year: integer, NOT NULL - quarter: integer, NOT NULL (1-4) - status: integer, NOT NULL, default: 0 (enum: draft=0, active=1, closed=2) - access_token: string, NOT NULL, unique index - started_at: datetime - ended_at: datetime - timestamps - 복합 unique 인덱스: [workspace_id, year, quarter] ``` ### 2. 마이그레이션: add_engagement_survey_to_diagnosis_sessions ``` - workspace_engagement_survey_id: uuid, FK(workspace_engagement_surveys), index (nullable) - workspace_group_id: uuid, FK(workspace_groups), index (nullable) ``` ### 3. WorkspaceEngagementSurvey 모델 - belongs_to :workspace - belongs_to :created_by, class_name: "User" - has_many :diagnosis_sessions (workspace_engagement_survey_id) - enum :status, { draft: 0, active: 1, closed: 2 } - validates: workspace, title, year, quarter, access_token presence - validates: quarter, inclusion: 1..4 - validates: [workspace_id, year, quarter] uniqueness (scope) - before_validation: access_token 자동 생성 (SecureRandom.alphanumeric(8).upcase) - scope :for_workspace → where(workspace_id:) - scope :active → where(status: :active) - scope :by_period → order(year: :desc, quarter: :desc) ### 4. DiagnosisSession 확장 - belongs_to :workspace_engagement_survey, optional: true - belongs_to :workspace_group, class_name: "WorkspaceGroup", optional: true ### 5. Workspace 모델 확장 - has_many :engagement_surveys, class_name: "WorkspaceEngagementSurvey" ### 6. fixture 생성 - test/fixtures/workspace_engagement_surveys.yml (2~3개) ### 7. 테스트 - test/models/workspace_engagement_survey_test.rb - 유효성 검사 (필수 필드, quarter 범위, uniqueness) - 연관관계 - enum - access_token 자동 생성 - 스코프 - DiagnosisSession 연관관계 테스트 ## 중요 사항 - 모든 rails 명령은 `docker compose exec web` 으로 실행 - TDD: 테스트 먼저 작성 → 구현 - DB 안전: test 환경에서만 마이그레이션 테스트
EngagementSurveys 서비스 3개 TDD 구현
## 작업 내용: 3개 서비스 객체 구현 ### 참조 스킬 - `.claude/skills/service-objects/SKILL.md` — Result 패턴, 네이밍 컨벤션 - `.claude/skills/rails-testing/SKILL.md` — Minitest 테스트 패턴 ### 기존 코드 참조 (반드시 읽기) - `app/services/diagnosis/scoring/engagement_calculator.rb` — 기존 채점 로직 (FACTOR_DIMENSION_MAP 등) - `app/services/diagnosis/engagement_result_formatter.rb` — 결과 포맷 - `app/services/diagnosis/score_calculator_factory.rb` — 팩토리 - `app/models/workspace_engagement_survey.rb` — PR1에서 생성한 모델 - `app/models/diagnosis_session.rb` — DiagnosisSession 구조 - `db/seeds/engagement/engagement_questions_b2b.csv` — B2B 10문항 구조 ### 1. EngagementSurveys::CreateService 파일: `app/services/engagement_surveys/create_service.rb` ```ruby # 호출: EngagementSurveys::CreateService.call(workspace:, user:, year:, quarter:) # 반환: Result (success/failure) ``` - workspace + year + quarter로 WorkspaceEngagementSurvey 생성 - access_token은 모델에서 자동 생성 - 동일 workspace+year+quarter 중복 시 실패 반환 - title은 자동 생성: "#{year} Q#{quarter}" ### 2. EngagementSurveys::SubmitResponseService 파일: `app/services/engagement_surveys/submit_response_service.rb` ```ruby # 호출: EngagementSurveys::SubmitResponseService.call( # survey:, workspace_group:, responses:, user: nil # ) # responses: [{ question_no: 1, dimension: "belonging", factor: "psychological_safety", value: 5 }, ...] # 반환: Result (success/failure) ``` - DiagnosisSession을 생성하여 응답 저장 - context_type: "b2b" - workspace_engagement_survey_id: survey.id - workspace_group_id: workspace_group.id - user_id: user&.id (로그인 시 참고용, nil 허용) - scoring_framework: "engagement_3factor" - 설문이 active 상태가 아니면 실패 반환 - responses를 DiagnosisSession의 response_data(또는 적절한 필드)에 저장 - 기존 EngagementCalculator로 채점하여 결과 저장 - 주의: 기존 DiagnosisSession이 어떤 필드에 응답/결과를 저장하는지 확인 후 동일 패턴 사용 ### 3. EngagementSurveys::ResultAggregator 파일: `app/services/engagement_surveys/result_aggregator.rb` ```ruby # 호출: EngagementSurveys::ResultAggregator.call(survey:, group: nil) # group이 nil이면 전체 집계, 있으면 해당 그룹만 # 반환: Result with data: # { # total_responses: Integer, # overall_score: Float, # 10번 문항(overall) 전체 평균 # engagement_types: { # 10번 문항 기준 비율 # active: Float, # 6,7점 비율 (%) # passive: Float, # 4,5점 비율 (%) # disengaged: Float # 1,2,3점 비율 (%) # }, # factor_scores: { # 3요인 평균 # psychological_safety: Float, # self_efficacy: Float, # flow_experience: Float # }, # dimension_scores: { # 9요인 평균 # belonging: Float, autonomy: Float, meaning: Float, # recognition: Float, achievement: Float, growth: Float, # vitality: Float, strength_use: Float, positive_affect: Float # }, # group_results: [ # 그룹별 결과 (group이 nil일 때) # { group: WorkspaceGroup, ...위와 동일 구조... } # 3명 이하는 nil # ] # } ``` - 해당 survey의 모든 DiagnosisSession을 조회 - 3명 이하 그룹은 결과를 nil로 반환 (insufficient_responses: true 표시) - overall_score: 각 세션의 overall(10번 문항) 점수 평균 - engagement_types: overall 문항 기준 6,7점=active / 4,5점=passive / 1,2,3점=disengaged 비율 - factor_scores: 3요인별 평균 (EngagementCalculator의 FACTOR_DIMENSION_MAP 참조) - dimension_scores: 9개 dimension별 평균 - 강약점: 가장 높은/낮은 factor, dimension 표시 ### 테스트 - `test/services/engagement_surveys/create_service_test.rb` - 정상 생성, 중복 방지, 필수 필드 누락 - `test/services/engagement_surveys/submit_response_service_test.rb` - 정상 응답 저장, 비활성 설문 거부, user_id 기록, 채점 결과 확인 - `test/services/engagement_surveys/result_aggregator_test.rb` - 전체 집계, 그룹별 집계, 3명 이하 필터링, 몰입 유형 비율, 3요인/9요인 분석 ## 중요 사항 - 모든 rails 명령은 `docker compose exec web` 으로 실행 - TDD: 테스트 먼저 작성 → 구현 - 기존 EngagementCalculator 로직을 최대한 재사용 - Result 패턴 사용 (service-objects 스킬 참조)
Workspaces::EngagementSurveysController 구현
## 작업: 워크스페이스 설문 관리 컨트롤러 ### 참조 (반드시 읽기) - `.claude/skills/rails-core/SKILL.md` - `.claude/skills/rails-testing/SKILL.md` - 기존 패턴 참고: `app/controllers/workspaces/team_strengths_controller.rb`, `app/controllers/workspaces/settings_controller.rb` - 권한 concern: `app/controllers/concerns/workspace_authorization.rb` - PR2 서비스: `app/services/engagement_surveys/create_service.rb`, `app/services/engagement_surveys/result_aggregator.rb` ### 컨트롤러: `app/controllers/workspaces/engagement_surveys_controller.rb` ```ruby module Workspaces class EngagementSurveysController < ApplicationController include WorkspaceAuthorization # index, show → admin or leader # new, create, toggle_status → admin only end end ``` #### 액션 - **index**: 해당 워크스페이스의 설문 목록 (by_period 스코프) - **new**: 새 설문 생성 폼 (연도/분기 선택) - **create**: EngagementSurveys::CreateService 호출, 성공 시 show로 redirect - **show**: EngagementSurveys::ResultAggregator로 결과 조회, @survey + @result 세팅 - **toggle_status**: draft→active→closed 순서로 상태 전환 #### 권한 매트릭스 | 액션 | member | leader | admin | |------|--------|--------|-------| | index | - | O | O | | new/create | - | - | O | | show | - | O | O | | toggle_status | - | - | O | ### 라우트 추가 (`config/routes.rb`) 기존 workspaces 블록 안에 추가: ```ruby resources :engagement_surveys, only: [:index, :new, :create, :show], controller: "workspaces/engagement_surveys" do member do patch :toggle_status end end ``` ### 테스트: `test/controllers/workspaces/engagement_surveys_controller_test.rb` - index 접근 (leader O, member X, 비로그인 X) - create (admin O, leader X) - show 결과 조회 - toggle_status 상태 전환 - 권한 거부 시 적절한 응답 ### 주의사항 - 뷰 파일은 최소한의 placeholder만 생성 (PR5에서 본격 구현) - 기존 WorkspaceAuthorization 패턴 확인 후 동일하게 적용 - 모든 rails 명령: `docker compose exec web`
EngagementSurveyResponsesController 공개 응답 플로우 구현
## 작업: 비인증 설문 응답 컨트롤러 + 뷰 ### 참조 (반드시 읽기) - `.claude/skills/rails-core/SKILL.md` - `.claude/skills/rails-testing/SKILL.md` - `.claude/skills/hotwire-patterns/SKILL.md` — Turbo Streams 문항 전환 - `.claude/skills/ui-design/SKILL.md` — 디자인 시스템 - 기존 진단 플로우 참고: `app/controllers/diagnosis_sessions_controller.rb` - 기존 뷰 패턴: `app/views/diagnosis_sessions/engagement_show.html.erb`, `_engagement_question.html.erb`, `_engagement_guide.html.erb` - PR2 서비스: `app/services/engagement_surveys/submit_response_service.rb` - 문항 데이터: `db/seeds/engagement/engagement_questions_b2b.csv` - QR 헬퍼: `app/helpers/qr_code_helper.rb` ### 컨트롤러: `app/controllers/engagement_survey_responses_controller.rb` ```ruby class EngagementSurveyResponsesController < ApplicationController allow_unauthenticated_access layout "focus" # 또는 기존 진단 플로우에서 사용하는 레이아웃 확인 end ``` #### 라우트 (`config/routes.rb`에 추가, workspaces 밖) ```ruby scope "engage/:access_token", controller: "engagement_survey_responses" do get "/", action: :show, as: :engagement_survey_response post "start", action: :start, as: :start_engagement_survey post "submit", action: :submit, as: :submit_engagement_survey get "complete", action: :complete, as: :complete_engagement_survey end ``` #### 액션 - **show** (GET `/engage/:access_token`): - access_token으로 survey 조회, active 상태 확인 - 워크스페이스의 그룹 목록 표시 - 그룹 선택 UI - **start** (POST): - 그룹 선택 확인 - 안내 메시지 표시: - "약 5분이 소요됩니다" - "3명 이하 그룹의 결과는 측정되지 않습니다" - "응답자의 정보는 기록되지 않습니다" - 세션에 그룹 ID 저장 - 문항 페이지로 이동 - **submit** (POST): - 10문항 7점 척도 응답 수집 - EngagementSurveys::SubmitResponseService 호출 - user: Current.user (로그인 시 참고용, nil도 OK) - complete로 redirect - **complete** (GET): - 감사 메시지 + 브라우저 닫기 안내 #### 뷰 파일 (실제 구현) - `app/views/engagement_survey_responses/show.html.erb` — 그룹 선택 - `app/views/engagement_survey_responses/start.html.erb` — 안내 + 문항 시작 - `app/views/engagement_survey_responses/submit.html.erb` — 10문항 7점 척도 (기존 _engagement_question 패턴 참고) - 또는 start 뷰에서 문항까지 한 페이지로 처리 (기존 engagement_show 패턴 확인) - `app/views/engagement_survey_responses/complete.html.erb` — 완료 ### B2B 10문항 데이터 ``` 1. belonging / psychological_safety / "팀에서 소속감을 느꼈다" 2. autonomy / psychological_safety / "업무 방식을 스스로 결정할 수 있었다" 3. meaning / psychological_safety / "내 업무가 의미 있다고 느꼈다" 4. recognition / self_efficacy / "나의 기여를 인정받았다" 5. achievement / self_efficacy / "업무 목표를 달성하고 있다고 느꼈다" 6. growth / self_efficacy / "업무를 통해 성장하고 있다고 느꼈다" 7. vitality / flow_experience / "일할 때 활력이 넘쳤다" 8. strength_use / flow_experience / "업무에서 나의 강점을 발휘했다" 9. positive_affect / flow_experience / "일하면서 긍정적인 감정을 느꼔다" 10. overall / overall / "현재 나의 직장에 만족한다" ``` - 문항은 DB(SurveyQuestion)에서 가져오거나, 하드코딩 (question_type="engagement_b2b") ### 테스트: `test/controllers/engagement_survey_responses_controller_test.rb` - show: access_token으로 접근 가능 - show: 비활성 설문 접근 차단 - start: 그룹 선택 후 안내 표시 - submit: 10문항 응답 제출 성공 - complete: 완료 페이지 렌더링 - 비인증 접근 가능 확인 ### 주의사항 - **파일 충돌 방지**: developer-1이 `workspaces/engagement_surveys_controller.rb`와 라우트를 수정하므로, 이 에이전트는 `engagement_survey_responses_controller.rb`만 생성 - 라우트 추가 시 developer-1과 겹치지 않는 위치에 추가 (workspaces 블록 밖) - 모바일 최적화 (QR 스캔이 주 진입점) - 모든 rails 명령: `docker compose exec web`
성능 P0: Admin 사용자 목록 N+1 쿼리 수정
admin/users_controller의 diagnosis_sessions N+1 문제 수정. - includes에 diagnosis_sessions: :diagnosis_type 추가 - 또는 counter_cache 도입 후 모달은 AJAX lazy load로 변경 - diagnosis_sessions 전체 eager load는 메모리 폭발 위험
성능 P0: 통계 페이지 GROUP BY 전환
admin/statistics_controller의 calculate_completion_rate_by_type에서 DiagnosisType별 개별 COUNT → 단일 GROUP BY 쿼리로 전환. - DiagnosisSession.group(:diagnosis_type_id).count - .where(status: "completed").group(:diagnosis_type_id).count
성능 P0: Engagement Survey trend N+1 제거
workspaces/engagement_surveys_controller의 build_trend_data에서 서베이별 ResultAggregator 개별 호출 → diagnosis_sessions eager load + 결과 캐싱
성능 P1: 복합 인덱스 6개 추가
마이그레이션으로 복합 인덱스 추가: 1. payments [status, approved_at] 2. payments [status, created_at] 3. blog_posts [status, published_at] 4. diagnosis_sessions [status, end_time] 5. diagnosis_sessions [user_id, status, created_at] 6. workspace_engagement_surveys [workspace_id, status]
성능 P1: DiagnosisSession 반복 pluck 캐싱
diagnosis_sessions_controller에서 responses.pluck(:survey_question_id)가 요청당 최대 3~6회 반복 호출됨. - @answered_ids ||= @session.responses.pluck(:survey_question_id).to_set 패턴 적용
성능 P2: Admin 대시보드/통계 캐싱 적용
admin/dashboard 8개 COUNT, admin/statistics 6+ 집계 쿼리에 Rails.cache.fetch 적용 (5분 TTL). - DiagnosisTarget.find_by(slug:) 등 정적 데이터도 캐싱
성능 P2: counter_cache 도입 (diagnosis_sessions_count)
users 테이블에 diagnosis_sessions_count 컬럼 추가. - DiagnosisSession에 counter_cache: true 설정 - Admin 사용자 목록에서 .count 쿼리 제거
진행 중
0리뷰
18[조직문화 PR1] DB: WorkspaceEngagementSurvey 모델 + DiagnosisSession 확장
## 설명 워크스페이스별 분기 설문 관리 테이블 생성 및 DiagnosisSession에 B2B 연결 FK 추가. ### 새 모델: WorkspaceEngagementSurvey - workspace_id: uuid, NOT NULL, FK(workspaces), index - created_by_id: uuid, NOT NULL, FK(users) - title: string, NOT NULL (예: "2026 Q1") - year: integer, NOT NULL - quarter: integer, NOT NULL (1-4) - status: integer, NOT NULL, default: 0 (draft/active/closed) - access_token: string, NOT NULL, unique index - started_at, ended_at: datetime - 복합 인덱스: [workspace_id, year, quarter] unique ### DiagnosisSession 확장 - workspace_engagement_survey_id: uuid, FK(workspace_engagement_surveys), index - workspace_group_id: uuid, FK(workspace_groups), index - 기존 user_id(optional) 활용: 로그인 사용자는 참고용으로 기록, 비로그인도 허용 - context_type = "b2b"로 설정 ### 설계 결정 - Option A 채택: DiagnosisSession 재사용 (기존 EngagementCalculator/ResultFormatter 완전 재사용) - 익명성: 결과 집계에서 user_id 사용하지 않음. 3명 이하 그룹 결과 비공개 ## 완료 기준 - [ ] 마이그레이션 실행/롤백/재실행 성공 - [ ] WorkspaceEngagementSurvey 모델: 유효성, 연관관계, 스코프 테스트 통과 - [ ] DiagnosisSession 연관관계 추가 테스트 통과 - [ ] fixture 생성 - [ ] engagement_b2b DiagnosisType seed 확인 (없으면 생성) ## 의존성 없음
[조직문화 PR2] 서비스: 설문 생성/응답 제출/결과 집계
## 설명 3개 서비스 객체 구현. ### EngagementSurveys::CreateService - 워크스페이스 + 연도/분기로 설문 생성 - access_token 자동 발급 (SecureRandom.alphanumeric(8).upcase) - 동일 워크스페이스+분기 중복 방지 ### EngagementSurveys::SubmitResponseService - 그룹 선택 + 10문항 응답을 DiagnosisSession으로 저장 - context_type = "b2b", workspace_engagement_survey_id, workspace_group_id 설정 - 로그인 사용자는 user_id 참고용 기록, 비로그인도 허용 - 기존 EngagementCalculator로 채점 ### EngagementSurveys::ResultAggregator - 전체/그룹별 집계 (3명 이하 그룹 결과 nil 반환) - overall 점수 = 10번 문항 전체 평균 - 몰입 유형 비율 = 10번 문항 기준: 적극적(6,7) / 소극적(4,5) / 비몰입(1,2,3) - 3요인 분석: psychological_safety, self_efficacy, flow_experience 평균 - 9요인 분석: 9개 dimension별 평균 - 강약점 해석 포함 ## 완료 기준 - [ ] 각 서비스 단위 테스트 통과 - [ ] 3명 이하 그룹 비공개 로직 테스트 - [ ] 몰입 유형 비율 계산 정확성 테스트 - [ ] 기존 EngagementCalculator와 정상 연동 확인 ## 의존성 PR1 완료 후
[조직문화 PR3] 컨트롤러: 워크스페이스 설문 관리 (CRUD + 결과)
## 설명 Workspaces::EngagementSurveysController 구현 (인증 필요, admin/leader 권한). ### 액션 - index: 분기 리스트 (연도/분기, 상태, 응답 수) - new/create: 연도+분기 선택하여 설문 생성 → QR코드+링크 자동 생성 - show: 결과 대시보드 (전체/그룹별) - toggle_status: draft→active→closed 전환 ### 라우트 ```ruby resources :workspaces do resources :engagement_surveys, only: [:index, :new, :create, :show], controller: "workspaces/engagement_surveys" do member { patch :toggle_status } end end ``` ### 권한 매트릭스 | 액션 | member | leader | admin | |------|--------|--------|-------| | index | - | O | O | | create | - | - | O | | show(결과) | - | O | O | | toggle_status | - | - | O | ## 완료 기준 - [ ] 컨트롤러 테스트 통과 (각 액션 + 권한) - [ ] 라우트 정상 동작 - [ ] WorkspaceAuthorization concern 활용 ## 의존성 PR1, PR2 완료 후
[조직문화 PR4] 공개 응답 플로우: 비인증 설문 응답 컨트롤러
## 설명 EngagementSurveyResponsesController 구현 (인증 불필요, access_token 기반). ### 라우트 - GET `/engage/:access_token` → 그룹 선택 화면 - POST `/engage/:access_token/start` → 안내 확인 후 문항 시작 - POST `/engage/:access_token/submit` → 10문항 응답 제출 - GET `/engage/:access_token/complete` → 완료 화면 ### 플로우 1. QR/링크로 접속 → 해당 워크스페이스 그룹 목록 표시 2. 그룹 선택 시 안내: - "약 5분 소요" - "3명 이하 그룹은 결과가 측정되지 않습니다" - "응답자 정보는 기록되지 않습니다" (심리적 안정감) 3. 10문항 7점 리커트 척도 응답 4. 완료 화면 (감사 메시지) ### 특이사항 - allow_unauthenticated_access 적용 - 로그인 사용자는 user_id를 참고용으로 기록 (Current.user 체크) - 설문 status가 active일 때만 응답 가능 - 기존 _engagement_question.html.erb 패턴 참고 ## 완료 기준 - [ ] 비인증 접근 테스트 통과 - [ ] 그룹 선택 → 문항 응답 → 완료 플로우 테스트 - [ ] active 상태가 아닌 설문 접근 차단 테스트 - [ ] 로그인/비로그인 모두 응답 가능 확인 ## 의존성 PR1, PR2 완료 후
[일괄다운로드 PR1] BulkReportDownload 모델 + 마이그레이션
## 설명 일괄 리포트 다운로드 비동기 처리를 위한 DB 레이어. ### 모델: BulkReportDownload - `workspace_id` (references, not null, index) - `user_id` (references, not null) — 요청한 관리자 - `status` (string, default: "pending") — pending/processing/completed/failed - `member_count` (integer) — 선택된 멤버 수 - `processed_count` (integer, default: 0) — 진행 상황 - `error_message` (text, nullable) - `member_ids` (text[]) — 선택된 workspace_member IDs - `has_one_attached :file` (ZIP) ### TDD 1. validation: workspace, user 필수 2. status enum: pending → processing → completed/failed 3. scope: expired (created_at < 7.days.ago) 4. association: belongs_to workspace/user, has_one_attached file ### 참고 패턴 - Active Storage: `Coaching::Recording`, `Study::Assignment` 등 4개 모델에서 사용 중 ## 완료 기준 - [ ] 마이그레이션 정상 실행 - [ ] 모델 validation, scope, enum 테스트 통과 - [ ] fixture 생성
[크레딧통합 PR1] DB 마이그레이션 — balance decimal 변환 + 데이터 이관
## 설명 개인 크레딧(UserCredit)의 balance를 integer → decimal(10,1)로 변환하고, diagnosis_balance 잔액을 balance로 이관합니다. ### 마이그레이션 내용 1. `user_credits.balance`: integer → decimal(10,1), default: 0.0 2. `credit_transactions.amount`: integer → decimal(10,1) 3. `credit_transactions.balance_after`: integer → decimal(10,1) 4. 데이터 이관: `UPDATE user_credits SET balance = balance + (diagnosis_balance * 0.2)` 5. `UserCredit`에 `self.ignored_columns += ["diagnosis_balance"]` 추가 (2단계 제거 패턴) ### 주의사항 - migrate/rollback/migrate 3단계 검증 필수 - development DB 카운트 변동 없음 확인 - 기존 구매자 없으므로 이관 리스크 낮음 ## 완료 기준 - [ ] 마이그레이션 migrate/rollback/migrate 통과 - [ ] `balance` 컬럼이 decimal(10,1) 타입 - [ ] `credit_transactions.amount/balance_after`가 decimal(10,1) - [ ] 기존 데이터 이관 완료 (balance에 diagnosis_balance * 0.2 합산) - [ ] `ignored_columns`에 diagnosis_balance 추가 - [ ] development DB 카운트 변동 없음 ## 의존성 없음 (첫 번째 작업) ## 관련 파일 - `db/migrate/YYYYMMDD_unify_credit_balance.rb` (신규) - `app/models/user_credit.rb`
[리드관리 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] BulkReportGenerateJob + CleanupJob
## 설명 백그라운드 PDF→ZIP 생성 Job + 7일 만료 삭제 Job. ### BulkReportGenerateJob - BulkReportDownload를 find → status "processing" - member_ids로 멤버 + completed/purchased 세션 조회 - 멤버별 순차 PDF 생성 (Diagnosis::PdfGenerator 재사용) - Tempfile ZIP → Active Storage attach - processed_count 갱신 (진행률 추적) - 완료 시 status "completed" + BulkReportMailer 발송 - 실패 시 개별 skip, 전부 실패면 status "failed" - 메모리 관리: 10건마다 GC.start ### BulkReportCleanupJob - 7일 경과 BulkReportDownload 레코드 + 첨부파일 삭제 - config/recurring.yml에 매일 2am 등록 ### TDD - Generate: 정상(completed), 부분실패(skip), 전체실패(failed), processed_count - Cleanup: 7일 경과 삭제, 7일 이내 유지 ### 참고 패턴 - `AiInsightGenerateJob`: status 추적 패턴 - `CleanupPageViewsJob`: recurring 패턴 ## 의존성 PR1 완료 후 ## 완료 기준 - [ ] Generate job: PDF→ZIP→attach 플로우 테스트 통과 - [ ] Cleanup job: 만료 레코드 삭제 테스트 통과 - [ ] recurring.yml에 cleanup 등록
[크레딧통합 PR2] UserCredit + CreditTransaction 모델 통합
## 설명 UserCredit 모델에서 diagnosis_balance 관련 메서드를 모두 제거하고 단일 크레딧 체계로 통합합니다. ### 변경 내용 - `UserCredit`: diagnosis_sufficient?, deduct_diagnosis!, add_diagnosis! 제거 - `UserCredit`: sufficient?, deduct!, add! — decimal 호환으로 변경, credit_type "unified" 사용 - `CreditTransaction`: credit_type 유효값에 "unified" 추가 - `DiagnosisType`: `requires_diagnosis_credit?` → `credit_cost` 메서드로 변경 - strength 카테고리: nil (리포트 구매로 별도 처리) - big5/happiness/growth_mindset: 0.2 ### TDD 순서 1. RED: 기존 테스트를 통합 크레딧 기준으로 수정 + 0.2 단위 차감 테스트 2. GREEN: 모델 수정 3. REFACTOR: 불필요 메서드 정리 ## 완료 기준 - [ ] `credit.deduct!(0.2, ...)` 정상 동작 - [ ] `credit.sufficient?(0.2)` 정상 동작 - [ ] diagnosis 관련 메서드 제거됨 (NoMethodError) - [ ] CreditTransaction credit_type "unified" 지원 - [ ] DiagnosisType#credit_cost 동작 - [ ] 모델 테스트 100% 통과 ## 의존성 PR1 (DB 마이그레이션) 완료 후 ## 관련 파일 - `app/models/user_credit.rb` - `app/models/credit_transaction.rb` - `app/models/diagnosis_type.rb` - `test/models/user_credit_test.rb` - `test/fixtures/user_credits.yml`
[리드관리 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] BulkReportMailer (완료/실패 이메일)
## 설명 ZIP 생성 완료/실패 시 요청한 관리자에게 이메일 발송. ### BulkReportMailer - `completed(bulk_report_download:)`: 워크스페이스명, 멤버수, 다운로드 링크 포함 - `failed(bulk_report_download:)`: 에러 메시지 포함 ### 뷰 - `app/views/bulk_report_mailer/completed.html.erb` - `app/views/bulk_report_mailer/failed.html.erb` ### i18n - `bulk_report_mailer.completed.subject`: "워크스페이스 리포트가 준비되었습니다" - `bulk_report_mailer.failed.subject`: "리포트 생성에 실패했습니다" - 4개 언어 (ko, en, zh, vi) ### 참고 패턴 - GiftMailer: keyword args, t(".subject"), ApplicationMailer 상속 ## 의존성 PR1 완료 후 (PR2와 병렬 가능) ## 완료 기준 - [ ] completed 메일 발송 테스트 통과 - [ ] failed 메일 발송 테스트 통과 - [ ] 4개 언어 i18n 키 추가
[크레딧통합 PR3] 서비스 객체 통합 (구매/결제/선물/추천)
## 설명 크레딧 관련 서비스 객체를 단일 크레딧 체계에 맞게 통합합니다. ### 변경 내용 1. **DiagnosisCreditPurchaseService** — 삭제 2. **ConfirmationService** — diagnosis_credit 결제 타입 분기 제거 3. **CancellationService** — revoke_diagnosis_credits!, validate_diagnosis_credit_unused! 제거 4. **Referrals::RewardService** — `add_diagnosis!(1)` → `add!(0.2)` 변경 5. **GiftService** — GIFT_TYPES 재설계: - strength_diagnosis + advanced_diagnosis → "credit" (cost: 1.0, 개인에게 1크레딧 선물) - career_points 유지 (cost: 0.5 → 20P) - grant_strength_credits! + grant_diagnosis_credits! → grant_credits! 통합 - 자기 선물 허용 (워크스페이스 크레딧 → 본인 개인 크레딧) ### TDD 순서 1. RED: 서비스 테스트 작성 (통합 크레딧 기준) 2. GREEN: 서비스 수정/삭제 3. REFACTOR ## 완료 기준 - [ ] DiagnosisCreditPurchaseService 삭제됨 - [ ] ConfirmationService에서 diagnosis_credit 분기 없음 - [ ] CancellationService에서 diagnosis_credit 분기 없음 - [ ] 추천 보상이 0.2 크레딧 지급 - [ ] GiftService "credit" 선물 타입 동작 (1크레딧) - [ ] 자기 선물 가능 (admin == workspace_member.user) - [ ] career_points 선물 기존대로 동작 - [ ] 서비스 테스트 100% 통과 ## 의존성 PR2 (모델 통합) 완료 후 ## 관련 파일 - `app/services/payments/diagnosis_credit_purchase_service.rb` — 삭제 - `app/services/payments/confirmation_service.rb` - `app/services/payments/cancellation_service.rb` - `app/services/referrals/reward_service.rb` - `app/services/workspaces/gift_service.rb` - `test/services/` 관련 테스트
[리드관리 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] Controller + Routes 비동기 전환
## 설명 handle_bulk_download를 비동기로 전환하고 상태 조회/파일 다운로드 엔드포인트 추가. ### Controller 변경 (workspaces/members_controller.rb) - `handle_bulk_download`: BulkReportDownload.create! + Job enqueue + redirect with notice - 중복 방지: 같은 workspace에 pending/processing 있으면 거부 - 새 액션 `bulk_download_status`: JSON (status, processed_count, member_count, download_url) - 새 액션 `bulk_download_file`: Active Storage redirect (workspace 소속 + 요청자 확인) ### Routes 추가 ```ruby collection do get :bulk_download_status get :bulk_download_file end ``` ### TDD - handle_bulk_download: 레코드 생성 + Job enqueue + redirect - bulk_download_status: 각 status별 JSON 응답 - bulk_download_file: completed면 redirect, 아니면 404 - 인가: admin/leader만 ### 참고 패턴 - `diagnoses#ai_analysis_status`: JSON 폴링 엔드포인트 ## 의존성 PR1, PR2 완료 후 ## 완료 기준 - [ ] 비동기 요청 → 폴링 → 다운로드 플로우 테스트 통과 - [ ] 중복 요청 거부 테스트 통과 - [ ] 인가 테스트 통과
[크레딧통합 PR4] 컨트롤러 + 라우트 통합
## 설명 크레딧 관련 컨트롤러와 라우트를 단일 크레딧 체계에 맞게 통합합니다. ### 변경 내용 1. **DiagnosisCreditsController** — 삭제 2. **config/routes.rb** — `resources :diagnosis_credits` 제거 3. **DiagnosesController** (L404-431): - `requires_diagnosis_credit?` → `credit_cost` 사용 - `diagnosis_sufficient?` → `sufficient?(0.2)` - `deduct_diagnosis!(1)` → `deduct!(0.2)` 4. **Admin::UsersController** (L110-142): - credit_type 분기 제거, 단일 크레딧으로 처리 5. **PaymentsController** (L164-166): - diagnosis_credit redirect 분기 제거 ### TDD 순서 1. RED: 컨트롤러 테스트 작성 (통합 크레딧 기준) 2. GREEN: 컨트롤러 수정 3. REFACTOR ## 완료 기준 - [ ] DiagnosisCreditsController 삭제됨 - [ ] 라우트에서 diagnosis_credits 제거됨 - [ ] 심화 진단 시작 시 0.2 크레딧 차감 - [ ] 관리자 크레딧 지급/차감이 단일 타입 - [ ] PaymentsController redirect 정상 동작 - [ ] 컨트롤러 테스트 100% 통과 ## 의존성 PR3 (서비스 통합) 완료 후 ## 관련 파일 - `app/controllers/diagnosis_credits_controller.rb` — 삭제 - `app/controllers/diagnoses_controller.rb` - `app/controllers/admin/users_controller.rb` - `app/controllers/payments_controller.rb` - `config/routes.rb` - `test/controllers/` 관련 테스트
[리드관리 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 완성.
[일괄다운로드 PR5] View + Stimulus 폴링 배너 UI
## 설명 멤버 목록 페이지에 다운로드 상태 배너 + Stimulus 폴링 컨트롤러. ### Stimulus Controller: bulk_download_status_controller.js - connect: status endpoint 폴링 (3초 간격) - pending/processing: 프로그레스 배너 (processed_count / member_count) - completed: 다운로드 링크 배너 + 폴링 중지 - failed: 에러 배너 + 폴링 중지 - 활성 다운로드 없으면 배너 숨김 + 폴링 중지 ### Partial: _bulk_download_banner.html.erb - index.html.erb의 `<div id="members-list">` 바로 아래 삽입 - data-controller="bulk-download-status" - data-bulk-download-status-url-value로 폴링 URL 전달 ### i18n (4개 언어) - workspaces.members.bulk_download.pending/processing/completed/failed - workspaces.members.bulk_download.download_ready - workspaces.members.flash.bulk_download_started ## 의존성 PR4 완료 후 ## 완료 기준 - [ ] 배너 렌더링 + 폴링 동작 - [ ] 상태별 UI 전환 (pending → processing → completed/failed) - [ ] 다운로드 링크 클릭 시 ZIP 다운로드 - [ ] i18n 4개 언어
[크레딧통합 PR5] 뷰/UI 통합 + i18n + diagnosis_balance 컬럼 제거
## 설명 모든 뷰에서 진단 크레딧 관련 UI를 제거하고 단일 크레딧 표시로 통합합니다. diagnosis_balance 컬럼도 최종 제거합니다. ### 변경 내용 (뷰 11개) 1. `diagnosis_credits/new.html.erb` — 삭제 2. `credits/index.html.erb` — 2열 그리드 → 단일 잔액 카드, credit_type 뱃지 단순화 3. `diagnoses/index.html.erb` — diagnosis_credit 관련 표시/링크 통합 4. `diagnoses/_diagnosis_type_card.html.erb` — 크레딧 안내 변경 5. `diagnoses/_insufficient_credits.html.erb` — 메시지 통합 ("0.2 크레딧 필요") 6. `admin/users/show.html.erb` — 진단/리포트 분리 표시 → 단일 크레딧 7. `shared/_sidebar.html.erb` — "NR"+"D" 2개 뱃지 → 단일 뱃지 8. `workspaces/members/_gift_modal.html.erb` — 3종 → "credit"(1크레딧) + career_points 9. `report_credits/new.html.erb` — "크레딧 구매"로 텍스트 통합 ### 기타 - `diagnosis_credit_purchase_controller.js` — 삭제 - `credits_helper.rb` — credit_type 관련 메서드 단순화 - i18n 4개 언어: diagnosis_credits 키 제거, 통합 메시지 - `db/migrate/YYYYMMDD_remove_diagnosis_balance.rb` — 컬럼 최종 제거 - `UserCredit`에서 `ignored_columns` 제거 ## 완료 기준 - [ ] 모든 뷰에서 단일 크레딧 잔액만 표시 - [ ] diagnosis_credits 뷰/JS 삭제됨 - [ ] 사이드바 단일 뱃지 표시 - [ ] 선물 모달 "credit" + "career_points" 2종만 표시 - [ ] i18n 4개 언어 정리 완료 - [ ] diagnosis_balance 컬럼 DB에서 제거됨 - [ ] bin/ci 통과 ## 의존성 PR4 (컨트롤러 통합) 완료 후 ## 관련 파일 - `app/views/diagnosis_credits/` — 삭제 - `app/views/credits/index.html.erb` - `app/views/diagnoses/index.html.erb`, `_diagnosis_type_card.html.erb`, `_insufficient_credits.html.erb` - `app/views/admin/users/show.html.erb` - `app/views/shared/_sidebar.html.erb` - `app/views/workspaces/members/_gift_modal.html.erb` - `app/javascript/controllers/diagnosis_credit_purchase_controller.js` — 삭제 - `app/helpers/credits_helper.rb` - `config/locales/{ko,en,zh,vi}.yml`, `diagnosis/*.yml`
완료 (전체)
167Backend - 멤버 Top 10 DNA 데이터 확장
## 목표 TeamStrengthAnalyzer에서 멤버별 Top 10 DNA 데이터를 제공하여 개요 탭의 WAY×DNA 강점 표를 지원한다. ## 변경 파일 - `app/services/workspaces/team_strength_analyzer.rb` ## 상세 내용 1. `build_member_data` (L72-73): `first(5)` → `first(10)`으로 확장 2. `build_member_strength_map` (L197-219): 각 DNA에 `:tier` 필드 추가 - `:top5` (rank 1-5), `:top10` (rank 6-10), `nil` (그 외) - 기존 `top5` 필드는 하위호환을 위해 유지 3. `compute_team_dna_ranking` (L148-180): 이미 올바른 카운팅 로직 → 변경 불필요 ## 완료 기준 - [ ] 멤버 데이터에 Top 10 DNA가 포함됨 - [ ] strength_map의 각 DNA에 tier 정보가 정확히 표시됨 - [ ] 기존 matrix 탭이 정상 동작함 (하위호환) - [ ] 테스트 통과
Domain 상세 페이지 - 1위 중심 레이아웃 + 비율 바차트
## 목표 `app/views/diagnoses/pdf/_domain_detail.html.erb`를 개선하여 1위 도메인 중심 레이아웃 + 3영역 비율 바차트를 추가 ## 상세 작업 ### A. 3영역 비율 바차트 추가 (페이지 상단) - `result[:domain_chart]`에서 3개 domain의 score를 사용 - 비율 계산: `(각 domain score / 전체 합) × 100` → 소수점 반올림 - 정규화: 최대 비율을 50%로 정규화하여 바 너비 결정 - 수평 바차트로 렌더링 (각 행: 도메인명 | 바 | 실제%) - 색상: Thinking #3B82F6, Relationship #10B981, Execution #F59E0B (ChartHelper::DOMAIN_HEX 사용) ### B. 1위 Domain 중심 레이아웃 - 현재: 3개 domain이 동등하게 표시됨 (is_top만 highlight-card) - 변경: 1위 Domain을 상단에 크게 표시, 나머지 2개는 하단에 작게 - 1위 Domain에 상세 설명 추가: `I18n.t("scoring.9way_27dna.domains.{key}.detailed_description", default: nil)` - 1위 domain의 sub-ways는 유지 ### C. 나머지 2개 Domain 축소 - 2, 3위 domain은 compact 레이아웃으로 가로 배치 - 점수바 + way breakdown만 간략히 표시 ## 사용 가능한 데이터 - `result[:domain_chart]` → [{key: "thinking", name: "사고", score: 32.1}, ...] - `result[:top_domain]` → {key: "thinking", name: "사고", score: 15.0} - `result[:way_chart]` → [{key: "ideation", name: "발상", score: 12.5}, ...] - `ChartHelper::DOMAIN_HEX` → 색상 맵 - `ChartHelper::WAY_DOMAIN_MAP` → WAY→Domain 매핑 ## 스타일 참고 - 기존 PDF CSS 클래스 사용: highlight-card, card, bar-bg, bar-fill, bar-fill-{domain}, flex-between, text-md, text-xs 등 - PDF 레이아웃이므로 print 호환 필요 (Grover로 PDF 생성) ## 완료 기준 - 1위 Domain이 눈에 띄게 강조됨 - 3영역 비율 바차트가 상단에 렌더링됨 - 상세 설명 문단이 1위 Domain에 포함됨 - 기존 페이지 번호/footer 유지
PDF 리포트 재설계 - 표지 (1p)
## 섹션 1: 표지 (1페이지) 기존 `_cover.html.erb` 정제. 불필요한 장식 제거, 전문적 인쇄물 느낌. ### 포함 요소 - 9WAY 로고/브랜드 - 수검자 이름 - 진단 유형 (성인/대학생/학생/리더/부모) - 진단 완료일 - 퍼스널 브랜딩 문구 ("나는 ~한 ~한 사람입니다") - FUTURE TALENT INSTITUTE ### 디자인 원칙 - 박스/컬러 최소화 - navy + slate 타이포그래피 중심 - 깔끔한 구분선으로 구조화 ### 완료 기준 - 기존 cover 파트셜 리팩토링 - 4개 언어 i18n 지원 (ko/en/zh/vi)
PDF 리포트 재설계 - 오케스트레이터 + 스타일 정리
## pdf.html.erb 오케스트레이터 재구성 + CSS 정제 ### 오케스트레이터 (pdf.html.erb) 새 섹션 순서로 재구성: 1. 표지 (1p) 2. 강점 해석 가이드 (1p) 3. 요약 (1p) 4. DNA 차별성 Top5 (10p, loop) 5. 강점 프로파일 (2p) 6. 탁월한 역할 (2p) 7. AI 분석 (2p, 조건부) ### CSS 정제 (layouts/pdf.html.erb) - highlight-card, card-navy 등 불필요한 박스 스타일 제거 - 전문적 리포트 스타일 강화 - 구분선(border-bottom) + 타이포그래피 위계 중심 - 페이지 번호(page_num) 자동 계산 로직 ### 미사용 파트셜 정리 - `_way_detail.html.erb` (27 DNA 랭킹 테이블) → 삭제 또는 보관 판단 - `_way_map.html.erb` → 강점 프로파일에 통합되면 삭제 ### 완료 기준 - pdf.html.erb가 새 구조 반영 - CSS에서 미사용 클래스 제거 - 전체 PDF 생성 테스트 통과
강점 브랜딩: DB 마이그레이션 + 모델
## 목표 DiagnosisSession에 custom_branding JSONB 필드를 추가하여 사용자 커스텀 브랜딩 데이터를 저장합니다. ## 작업 내용 1. **마이그레이션 생성**: `add_custom_branding_to_diagnosis_sessions` - `custom_branding` JSONB 컬럼 추가 (default: {}) 2. **모델 업데이트**: `DiagnosisSession` - `custom_branding` 접근자 메서드 - 브랜딩 데이터 구조 검증 (selected_dna_keys, modifiers, selected_way_key, aspiration, ai_sentence) 3. **헬퍼 메서드**: - `branding_sentence`: AI 생성 문장 또는 기본 브랜딩 반환 - `has_custom_branding?`: 커스텀 브랜딩 존재 여부 ## JSONB 구조 ```json { "selected_dna_keys": ["creativity", "flexibility"], "modifiers": { "creativity": "혁신적인", "flexibility": "유연한" }, "selected_way_key": "ideation", "aspiration": "새로운 가능성을 여는 사람", "ai_sentence": "AI가 다듬은 최종 브랜딩 문장", "updated_at": "2026-02-27T..." } ``` ## 완료 기준 - [ ] 마이그레이션 성공 (docker compose exec web rails db:migrate) - [ ] DiagnosisSession#branding_sentence 동작 - [ ] 기존 세션 데이터에 영향 없음 확인 - [ ] 테스트 작성
사용자 진단
http://localhost:3000/admin/users/96c25ee8-34ba-4a3a-89af-d26f4ac0d653 사용자 진단 이력에서 진단 유형과 DNA 5개 결과, 결제 상태, 메모 등이 보여야해. 유료를 결제 상태로 바꿀 수도 있어야해.
사용자 상세 - 진단 이력 확장 (DNA 5개, 결제상태 변경, 메모)
## 목표 admin/users/show 페이지의 진단 이력 섹션을 확장합니다. ## 현재 상태 - `app/views/admin/users/show.html.erb` 55-80행에 기본 진단 이력 있음 (slug, 날짜, status만 표시) - `app/controllers/admin/users_controller.rb` show 액션에서 `@diagnosis_sessions` 로드 ## 변경 사항 ### 1. 컨트롤러 수정 (`app/controllers/admin/users_controller.rb`) - `show`에서 `includes(:diagnosis_type, :payment)` 추가 - `includes`에 `diagnosis_type: [:category, :target]`도 포함 ### 2. 뷰 수정 (`app/views/admin/users/show.html.erb`) 진단 이력 테이블에 다음 컬럼 추가: - **진단 유형**: `session.diagnosis_type.slug` (예: "strength_adult") - **Top 5 DNA**: `session.json_data["scored_dna"]`에서 상위 5개 DNA name 표시 - `json_data["scored_dna"]`가 배열로 [{key, name, score}, ...] 형태 - 없으면 `json_data.dig("top_dna")` 확인 - 각 DNA name을 작은 badge로 표시 - **결제 상태**: 결제 여부 badge + 변경 버튼 - `session.report_purchased?` → "유료" (green badge) - 미결제 → "무료" (gray badge) + "유료로 변경" 버튼 - 유료 → "무료로 변경" 버튼 (결제 해제) - **메모**: `session.memo` 표시 (truncate 50) - **완료일**: `session.end_time` 표시 ### 3. 결제 상태 변경 API - `admin/users_controller.rb`에 `toggle_payment` 액션 추가 - 라우트: `patch :toggle_payment` (member) - 로직: - 유료→무료: `session.update!(payment: nil)` (payment_id를 nil로) - 무료→유료: 새 Payment 생성 (free payment) ```ruby payment = Payment.create!( user: session.user, order_id: "admin_free_#{session.id}_#{Time.current.to_i}", amount: 0, status: "completed", payment_type: "report", currency: "KRW", payment_key: "admin_granted", approved_at: Time.current ) session.update!(payment: payment) ``` - Turbo를 사용하거나 단순 redirect_to로 처리 ### 4. 라우트 추가 (`config/routes.rb`) admin namespace 안에: ```ruby resources :users, only: %i[index show edit update] do member { patch :toggle_payment } end ``` 주의: 기존 `resources :users, only: %i[index show edit update]`를 block 형태로 변경 ## 스타일 참고 - 기존 admin 뷰 패턴 따르기 (Tailwind CSS) - badge: `inline-flex items-center px-2 py-0.5 rounded text-xs font-medium` - green: `bg-status-success-light text-status-success-text` - gray: `bg-gray-100 text-gray-600` - 버튼: `text-xs text-admin-primary hover:underline` ## 완료 기준 - 진단 이력에 유형, DNA 5개, 결제상태, 메모, 완료일 표시 - 결제 상태 토글 동작 (유료↔무료) - N+1 쿼리 없음
메인페이지(랜딩) 다국어 번역 오류 수정
## 문제 메인페이지(랜딩 페이지)의 번역이 제대로 적용되지 않음. ## 완료 기준 - 메인페이지의 모든 텍스트가 locale별(ko/en/zh/vi)로 올바르게 번역되어 표시됨 - 번역 누락된 키가 없음 - locale 전환 시 모든 섹션이 정상적으로 번역됨
관리자 메뉴
관리자는 사이드 메뉴에 관리자 페이지 이동하는 메뉴가 필요해.
사이드바에 관리자 메뉴 링크 추가
## 작업 내용 관리자(admin) 사용자에게만 보이는 "관리자" 메뉴 링크를 일반 사이드바에 추가합니다. ## 상세 요구사항 ### 1. sidebar_nav_item에 admin 아이콘 추가 - `app/views/shared/_sidebar_nav_item.html.erb`의 icons 해시에 "admin" 키 추가 - 톱니바퀴(settings/cog) 아이콘 SVG path 사용 ### 2. i18n 번역 추가 - `config/locales/ko.yml`의 `nav.sidebar` 아래에 `admin: "관리자"` 추가 - `config/locales/en.yml`의 `nav.sidebar` 아래에 `admin: "Admin"` 추가 - `config/locales/zh.yml`의 `nav.sidebar` 아래에 `admin: "管理员"` 추가 - `config/locales/vi.yml`의 `nav.sidebar` 아래에 `admin: "Quản trị"` 추가 ### 3. 사이드바에 관리자 메뉴 조건부 렌더링 - `app/views/shared/_sidebar.html.erb`에서 nav 섹션 끝(</nav> 직전), 나의 결과 아래에 추가 - `Current.user&.admin?` 조건으로 감싸기 - admin_root_path로 이동 - active 조건: `controller_path.start_with?("admin")` ### 4. 테스트 작성 - 관리자 사용자가 사이드바에서 "관리자" 링크를 볼 수 있는지 테스트 - 일반 사용자에게는 "관리자" 링크가 보이지 않는지 테스트 ## 완료 기준 - admin 사용자로 로그인 시 사이드바에 "관리자" 메뉴가 보임 - 일반 사용자로 로그인 시 "관리자" 메뉴가 보이지 않음 - "관리자" 클릭 시 /admin으로 이동 - 모든 테스트 통과
워크스페이스 메뉴 권한
사용자 권한이 코치 베이직~ 코치 마스터, 조직 베이직 ~ 조직 마스터, 관리자 ... 멤버를 제외한 모든 권한은 워크스페이스 메뉴 보여줘야해. 속한 워크스페이스가 없으면 워크스페이스 목록에 + 워크스페이스 생성이 있어야해. 그리고 소속된 워크스페이스 있어도 워크스페이스 생성은 모든 목록 밑에 보여야해.
사이드바 워크스페이스 섹션 권한 조건 변경 및 생성 링크 추가
## 작업 내용 ### 1. `_sidebar.html.erb` 수정 - 워크스페이스 섹션 표시 조건을 `@sidebar_workspaces&.any?` → `Current.user&.staff?`로 변경 - `staff?`는 `admin? || coach? || org_admin?`으로 이미 정의되어 있음 (멤버=regular 제외) - 워크스페이스가 없을 때와 있을 때 모두 워크스페이스 섹션이 보이도록 ### 2. `_sidebar_workspace_section.html.erb` 수정 - 워크스페이스 목록이 비어있을 때: "워크스페이스" 섹션 헤더 + "+ 워크스페이스 생성" 링크만 표시 - 워크스페이스 목록이 있을 때: 기존 목록 하단에 "+ 워크스페이스 생성" 링크 추가 - 생성 링크는 `new_workspace_path`로 연결 - 기존 사이드바 디자인 패턴(아이콘, 색상, padding)과 일관되게 구현 ### 3. 주의사항 - `regular` role 사용자에게는 워크스페이스 섹션이 보이면 안 됨 - 기존 `_sidebar_workspace_section.html.erb`의 워크스페이스 내 메뉴 권한 로직(admin/leader 조건)은 그대로 유지 - `set_sidebar_workspaces` 메서드는 변경 불필요 (빈 배열이 와도 섹션은 표시해야 함) ### 완료 기준 - staff 권한 사용자: 소속 워크스페이스 없어도 "워크스페이스" 섹션 + "워크스페이스 생성" 링크 표시 - staff 권한 사용자: 소속 워크스페이스 있으면 기존 목록 + 하단에 "워크스페이스 생성" 링크 표시 - regular 사용자: 워크스페이스 섹션 안 보임 (기존과 동일)
이메일 초대
http://localhost:3000/ko/workspaces/2dee7a1b-57c3-47b4-a40c-741d1aaccbbc/invitations 이메일 초대할 때, 해당 링크로 이메일을 실제로 발송되게 해줘. 그리고 이메일을 벌크로도 등록하고 발송할 수 있게 해줘. (그리고 해당 멤버가 멤버로 승인했는지도 표시해줘)
Admin Payments 컨트롤러 + 라우팅 + 테스트
## 작업 내용 Admin::PaymentsController를 생성하고, 결제 목록 조회 + 취소 기능을 구현합니다. ## 완료 기준 ### 1. 라우팅 (config/routes.rb) admin 네임스페이스에 추가: ```ruby resources :payments, only: [:index] do member do post :cancel end end ``` ### 2. Admin::PaymentsController (app/controllers/admin/payments_controller.rb) - `Admin::BaseController` 상속 - **index 액션:** - Payment.includes(:user).order(created_at: :desc) 로 기본 조회 - 검색: 사용자명/이메일/전화번호로 검색 (params[:search]) - 필터: 상태 (params[:status] - pending/completed/failed/canceled) - 필터: 결제 유형 (params[:payment_type] - report/career_up/premium/analysis_credit/package/workshop/strength_diary/custom) - 페이지네이션: PER_PAGE = 50, pagy 사용 (기존 컨트롤러들이 pagy 사용 중) - 정렬: created_at desc (기본), amount, status - **cancel 액션:** - 관리자 취소 기능. 기존 Payments::CancellationService를 참고하되, 관리자는 7일 제한 없이 취소 가능해야 함 - 취소 성공 시 flash notice와 함께 리다이렉트 - 취소 실패 시 flash alert와 함께 리다이렉트 ### 3. 테스트 (test/controllers/admin/payments_controller_test.rb) - index 액션 테스트: 관리자 접근 가능, 비관리자 접근 불가 - 검색 테스트 - 필터 테스트 - cancel 액션 테스트 - 기존 테스트 패턴: test/controllers/admin/ 디렉토리의 기존 테스트 참고 ### 참고 사항 - 기존 Admin::UsersController, Admin::DiagnosisSessionsController의 패턴을 따라주세요 - Payment 모델은 이미 존재합니다 (app/models/payment.rb) - 사용자 정보: user.name, user.email_address, user.phone - 상태 enum: pending, completed, failed, canceled - 결제 유형 enum: report, career_up, premium, analysis_credit, package, workshop, strength_diary, custom - pagy gem이 이미 설치되어 있습니다 - 기존 Admin::BaseController (app/controllers/admin/base_controller.rb) 상속 필수
페이지 이탈 분석 기능 구현
페이지뷰 추적 → 이탈 페이지/체류 시간/페이지 흐름 분석 기능을 admin 통계 대시보드에 추가. DB 마이그레이션, 모델, Concern, Job, Stimulus 컨트롤러, 통계 뷰, 테스트 포함.
할인코드
할인코드 테이블을 만들어야해. 할인코드 테이블을 만들고, 결제할 때 할인코드를 입력하면 할인된 금액을 결제하게 해야해. http://localhost:3000/admin 어드민에 할인코드 메뉴도 있어야함 할인코드는 상품별로 할인율을 정할 수 있어야함. (전체 적용도 가능) 할인코드 이름 / 할인코드 자동 생성 / 할인 유형 (원, %) / 적용 상품 / 최대 사용횟수 / 최소 구매 금액 / 만료일 / 활성화
할인코드 DB 마이그레이션 + 모델 + 서비스
## 목표 할인코드(DiscountCode) 테이블 생성, 모델 정의, 할인코드 검증/적용 서비스 구현 ## 작업 내용 ### 1. DB 마이그레이션 - `discount_codes` 테이블 생성: - name (string, NOT NULL) - 할인코드 이름 - code (string, NOT NULL, UNIQUE) - 할인코드 (자동생성 가능) - discount_type (string, NOT NULL) - 'percentage' 또는 'fixed_amount' - value (integer, NOT NULL) - 할인값 (퍼센트 또는 원) - applies_to_all (boolean, default: true) - 전체 상품 적용 여부 - max_uses (integer, nullable) - 최대 사용횟수 (null이면 무제한) - current_uses (integer, default: 0) - 현재 사용횟수 - min_purchase_amount (integer, default: 0) - 최소 구매금액 - expires_at (datetime, nullable) - 만료일 - active (boolean, default: true) - 활성화 여부 - metadata (jsonb, default: {}) - timestamps - 인덱스: code (unique), active, expires_at - `discount_code_products` 조인 테이블 생성: - discount_code_id (references, FK) - product_id (references, FK) - 인덱스: [discount_code_id, product_id] (unique) - `payments` 테이블의 discount_code_id를 UUID FK로 변환하는 마이그레이션 ### 2. 모델 - `DiscountCode` 모델: - enum discount_type: { percentage: "percentage", fixed_amount: "fixed_amount" } - has_many :discount_code_products, dependent: :destroy - has_many :products, through: :discount_code_products - has_many :payments - validates :name, :code, :discount_type, :value, presence: true - validates :code, uniqueness: true - validates :value, numericality: { greater_than: 0 } - validate: percentage는 1-100 범위 - scope :available - active && (expires_at nil OR 미래) && (max_uses nil OR current_uses < max_uses) - before_create :generate_code_if_blank (SecureRandom.alphanumeric(8).upcase) - `applicable_to?(product)`: applies_to_all이거나 products에 포함 - `calculate_discount(amount)`: 할인 금액 계산 - `usable?`: 사용 가능 여부 확인 - `use!`: current_uses 증가 - `DiscountCodeProduct` 모델: - belongs_to :discount_code - belongs_to :product - `Payment` 모델 수정: - belongs_to :discount_code, optional: true ### 3. 서비스 - `DiscountCodes::ValidationService`: - initialize(code:, product: nil, amount: 0) - call: 유효성 검사 (존재, 활성, 만료, 사용횟수, 최소금액, 상품 적용) - Result(discount_code, discount_amount, error) - `DiscountCodes::ApplyService`: - initialize(discount_code:, payment:) - call: 할인코드 사용 기록 (current_uses 증가, payment.discount_code_id 설정) ### 4. Fixture - test/fixtures/discount_codes.yml (active, expired, max_used, inactive 등) - test/fixtures/discount_code_products.yml ### 5. 테스트 - test/models/discount_code_test.rb - test/models/discount_code_product_test.rb - test/services/discount_codes/validation_service_test.rb - test/services/discount_codes/apply_service_test.rb ## 완료 기준 - 모든 마이그레이션 실행 성공 - 모델 테스트 통과 - 서비스 테스트 통과 - 기존 테스트가 깨지지 않음
9WAY에 doorkeeper 도입 (OAuth2 Provider)
## 개요 9WAY 강점 진단 서비스를 OAuth2 Provider로 전환하여 외부 서비스(커리어 관리, 채용, 교육)가 9WAY 계정으로 로그인할 수 있도록 한다. ## 작업 내용 - doorkeeper gem 설치 및 초기 설정 - doorkeeper 마이그레이션 실행 (oauth_applications, oauth_access_grants, oauth_access_tokens 테이블) - UUID primary key 설정 (기존 DB 패턴과 통일) - Authorization Code Grant 흐름 구현 - User 모델에 doorkeeper 관계 추가 - 기존 인증(Cookie Session, OmniAuth, Magic Link)과 병행 운영 - OAuth Application 관리 (어드민에서 client_id/secret 발급) ## 완료 기준 - [ ] doorkeeper 설치 및 마이그레이션 완료 - [ ] /oauth/authorize, /oauth/token 엔드포인트 동작 - [ ] 테스트 Consumer 앱에서 Authorization Code 흐름으로 로그인 성공 - [ ] 기존 웹 로그인(Google, Kakao, Magic Link) 정상 동작 확인 - [ ] 테스트 통과
OmniAuth Strategy (omniauth-9way) 작성 + 테스트
## 작업 내용 1. 기존 카카오 strategy 참고: `lib/omniauth/strategies/kakao.rb` 2. `lib/omniauth/strategies/nineway.rb` 생성: - OmniAuth::Strategies::OAuth2 상속 - option :name, 'nineway' - client_options: site, authorize_url(/oauth/authorize), token_url(/oauth/token) - ENV로 설정 가능하도록 (NINEWAY_CLIENT_ID, NINEWAY_CLIENT_SECRET, NINEWAY_SITE_URL) - info 콜백: /api/v1/me 호출하여 사용자 정보 가져오기 - uid: response의 id - info: name, email 3. Consumer 서비스에서 사용할 수 있도록 OmniAuth initializer 예시 작성 4. 테스트 작성: strategy의 기본 동작 검증 ## 참고 파일 - `lib/omniauth/strategies/kakao.rb` — 기존 커스텀 strategy 패턴 - `config/initializers/omniauth.rb` — OmniAuth 설정 패턴 - `config/initializers/doorkeeper.rb` — 스코프, 엔드포인트 확인 ## 완료 기준 - OmniAuth strategy 파일 작성 완료 - Consumer 서비스에서 바로 사용 가능한 수준 - 테스트 통과
Big5 성격 진단 기능 구현
Big5 성격 진단 전체 구현: 마이그레이션+모델+시드, 점수계산, 컨트롤러+라우트, 뷰+Stimulus, 결과 포매팅+결과뷰, i18n
Puma 2워커/5스레드 튜닝
## 목표 1000명 동시 진단 대응을 위해 Puma 동시 처리 능력을 3 → 10으로 향상. ## 변경 파일 - `config/deploy.yml` — `WEB_CONCURRENCY: 2`, `RAILS_MAX_THREADS: 5` 환경변수 추가 - `config/puma.rb` — `workers ENV.fetch("WEB_CONCURRENCY", 0)` + `preload_app!` 추가 ## 완료 기준 - 배포 후 Puma 2워커 기동 확인 (`ps aux | grep puma`) - 메모리 2GB 이내 확인 (`free -m`) - 헬스체크 정상 ## 주의사항 - **절대 3워커 이상 금지** — 4GB 서버에서 PDF 생성(Chromium) 시 OOM 위험 - DB 풀은 RAILS_MAX_THREADS 참조로 자동 반영 (워커당 5개) - PostgreSQL max_connections(100) 내 여유 확인: 2워커 × 5스레드 × 4DB = 최대 40 커넥션
Puma 워커/스레드 + DB 풀 설정
config/deploy.yml에 WEB_CONCURRENCY: 2, RAILS_MAX_THREADS: 5 추가. config/puma.rb에 workers + preload_app! 추가. 테스트 통과 확인.
Server: 일괄 제출 API + upsert_all + N+1 제거
## 목표 Big5 일괄 제출을 위한 서버 API 구현 + 기존 코드 N+1 제거. ## ⚠️ 리뷰 반려 사유 (2026-03-09) ### [CRITICAL] r[:selected] 타입 안전성 (controller:110) - `r[:selected] ? 1 : 0`에서 ActionController::Parameters를 통해 문자열 `"false"`가 올 경우 Ruby에서 truthy → 모든 응답이 1로 저장되는 치명적 버그 - **수정**: `ActiveModel::Type::Boolean.new.cast(r[:selected]) ? 1 : 0` 사용 ### [HIGH] question_id 타입 불일치 (controller:87-88) - `submitted_ids`가 문자열 Set, `valid_question_ids`가 UUID Set → 검증 무력화 가능 - **수정**: `r[:question_id].to_s`로 타입 통일 ### [HIGH] valid_questions_index 키 타입 불일치 (controller:107) - `index_by(&:id)` 키가 UUID인데 `r[:question_id]`가 문자열 → dna_type이 nil로 저장 - **수정**: `valid_questions_index[r[:question_id].to_s]` 또는 키 타입 통일 --- ## 작업 내용 (이하 기존 내용 동일) ### 1. 새 컨트롤러 액션: submit_all_big5_responses - POST /diagnosis_sessions/:id/submit_all_big5_responses - params: { responses: [{ question_id:, selected: true/false, response_time: }] } - 90개 응답(30라운드 × 3문항)을 한 번에 수신 - 서버 검증: 응답 수 확인, question_id 유효성, 세션 상태 확인 ### 2. session_manager: save_responses_bulk - upsert_all 활용 ### 3. N+1 제거 (기존 코드) ### 4. 라우트 추가 ## 완료 기준 - 반려 사유 3건 모두 수정 - 타입 안전성 테스트 추가 - 기존 테스트 통과
database.yml pool 키 수정 (max_connections → pool)
## 문제 `config/database.yml`에서 `max_connections` 키를 사용 중이나, Rails ActiveRecord가 인식하는 키는 `pool`임. 현재 AR 기본값 5가 우연히 맞아서 동작 중이나, 향후 스레드 수 변경 시 의도대로 동작하지 않을 위험. ## 변경 ```yaml # AS-IS max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> # TO-BE pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> ``` ## 완료 기준 - [ ] `pool` 키로 변경 - [ ] `docker compose exec web bin/rails runner "puts ActiveRecord::Base.connection_pool.size"` → 5 출력 - [ ] bin/ci 통과
[Phase 1] PR 1-1: 서브도메인 라우팅 설정
## 개요 blog.9way.co.kr / edu.9way.co.kr 서브도메인 라우팅 인프라 구축 ## 생성할 파일 - `app/controllers/blog/base_controller.rb` - `app/controllers/study/base_controller.rb` - `app/views/layouts/blog.html.erb` - `app/views/layouts/study.html.erb` - `test/controllers/blog/base_controller_test.rb` - `test/controllers/study/base_controller_test.rb` ## 수정할 파일 - `config/routes.rb` — 서브도메인 constraints 추가 - `config/environments/development.rb` — hosts 허용 - `config/environments/production.rb` — hosts 명시 ## 완료 기준 - blog.localhost:3000 → Blog::BaseController 라우팅 - edu.localhost:3000 → Study::BaseController 라우팅 - 기존 라우트 영향 없음 - 인증 세션 공유 동작 ## 의존성 없음 (첫 번째 PR)
PR 5-3: 비디오 진도 추적 Stimulus + API
Vimeo/YouTube Player API 래핑 Stimulus 컨트롤러 + 진도 API 엔드포인트. TDD 기반. ## 담당 파일 - app/javascript/controllers/video_player_controller.js (신규) - app/controllers/study/progresses_controller.rb (신규) - app/views/study/lessons/show.html.erb (비디오 임베드에 Stimulus 적용) - config/routes.rb (edu subdomain에 progress API 추가) - test/controllers/study/progresses_controller_test.rb (신규) ## 완료 기준 - Vimeo/YouTube Player API로 시청 시간 추적 - 5초 간격 서버 전송 (debounced) - 90%+ 시청 시 자동 완료 표시 - 이어보기 (last_position_seconds) - sendBeacon on page leave
[함께보기 PR1] DB 레이어: ViewGroup + ViewGroupMember 모델
## 설명 "함께 보기" 기능의 DB 모델 레이어 구현. ## DB 설계 ### view_groups - id: uuid (PK) - name: string, null: false, max 50자 - invite_code: string, unique, null: false (SecureRandom.alphanumeric(8).upcase) - user_id: uuid, FK(users), null: false (소유자) - created_at, updated_at ### view_group_members - id: uuid (PK) - view_group_id: uuid, FK(view_groups), null: false - user_id: uuid, FK(users), null: false - diagnosis_session_id: uuid, FK(diagnosis_sessions), null: false - created_at, updated_at - unique index: [view_group_id, user_id] ## 모델 규칙 - ViewGroup: belongs_to :user (소유자), has_many :view_group_members, dependent: :destroy - ViewGroupMember: belongs_to :view_group, belongs_to :user, belongs_to :diagnosis_session - invite_code는 생성 시 자동 발급 (before_create) - 소유자는 view_group_members에 포함하지 않음 (별도 user_id로 관리) ## 완료 기준 - [ ] 마이그레이션 migrate/rollback/migrate 성공 - [ ] 모델 테스트 통과 (유효성, 연관관계, invite_code 고유성) - [ ] fixtures 생성 (view_groups.yml, view_group_members.yml) - [ ] diagnosis_session은 report_purchased: true인 것만 연결 가능
마이그레이션 + 모델 + 테스트 + fixtures 구현
## 작업 내용 ViewGroup + ViewGroupMember의 마이그레이션, 모델, 테스트, fixtures를 TDD로 구현합니다. ## DB 설계 ### view_groups - id: uuid (PK) - name: string, null: false, max 50자 - invite_code: string, unique, null: false (SecureRandom.alphanumeric(8).upcase) - user_id: uuid, FK(users), null: false (소유자) - created_at, updated_at ### view_group_members - id: uuid (PK) - view_group_id: uuid, FK(view_groups), null: false - user_id: uuid, FK(users), null: false - diagnosis_session_id: uuid, FK(diagnosis_sessions), null: false - created_at, updated_at - unique index: [view_group_id, user_id] ## 모델 규칙 - ViewGroup: belongs_to :user (소유자), has_many :view_group_members, dependent: :destroy - ViewGroupMember: belongs_to :view_group, belongs_to :user, belongs_to :diagnosis_session - invite_code는 생성 시 자동 발급 (before_create) - 소유자는 view_group_members에 포함하지 않음 (별도 user_id로 관리) - diagnosis_session은 report_purchased: true인 것만 연결 가능 (커스텀 유효성 검증) ## 완료 기준 - [ ] 마이그레이션 migrate/rollback/migrate 성공 - [ ] 모델 테스트 통과 (유효성, 연관관계, invite_code 고유성) - [ ] fixtures 생성 (view_groups.yml, view_group_members.yml) - [ ] diagnosis_session report_purchased 검증 테스트
ViewGroups::TeamStrengthAnalyzer 서비스 + 테스트 구현
## 작업 내용 기존 Workspaces::TeamStrengthAnalyzer를 참고하여 ViewGroups::TeamStrengthAnalyzer 서비스를 TDD로 구현합니다. ## 참고 파일 - `app/services/workspaces/team_strength_analyzer.rb` — 핵심 로직 참고 - `test/services/workspaces/team_strength_analyzer_test.rb` — 테스트 패턴 참고 - `app/models/view_group.rb` — PR1에서 생성된 모델 - `app/models/view_group_member.rb` — PR1에서 생성된 모델 ## 핵심 차이점 - Workspace 버전: workspace_diagnoses 테이블을 조인하여 세션 수집 - ViewGroup 버전: view_group_members.diagnosis_session_id를 직접 사용 (더 단순) - report_purchased: true인 세션만 대상 ## 출력 데이터 구조 기존과 동일한 키 구조 유지: - way_averages, synergy, members_data, team_line_chart 등 ## 완료 기준 - [ ] `app/services/view_groups/team_strength_analyzer.rb` 생성 - [ ] `test/services/view_groups/team_strength_analyzer_test.rb` 생성 - [ ] ViewGroup을 받아 동일 구조의 분석 결과 반환 - [ ] 멤버 0명일 때 빈 결과 반환 (에러 없음) - [ ] 멤버 1명일 때 정상 동작 - [ ] 서비스 테스트 전부 통과
ViewGroupsController + 라우트 + 컨트롤러 테스트 구현
## 작업 내용 ViewGroupsController 및 라우팅을 TDD로 구현합니다. ## 라우트 설계 locale scope 안에 배치 (기존 패턴 준수): ```ruby resources :view_groups, only: [:index, :show, :new, :create, :destroy] do member do delete :leave get :team_strength end end get "view_groups/join/:invite_code", to: "view_groups#join", as: :join_view_group post "view_groups/join/:invite_code", to: "view_groups#process_join" ``` ## 컨트롤러 액션 ### index - 로그인 필수 - 자신이 소유하거나 멤버인 그룹 목록 표시 - `@owned_groups = current_user.view_groups` - `@joined_groups = ViewGroup.joins(:view_group_members).where(view_group_members: { user_id: current_user.id })` ### show - 로그인 필수 + 소유자 또는 멤버만 - 그룹 상세 (QR코드, 초대링크, 멤버 목록) ### new / create - 로그인 필수 - 그룹 이름 입력 → 생성 (invite_code 자동 발급) - 소유자는 자동으로 view_group_members에 추가하지 않음 (별도 user_id) ### destroy - 소유자만 삭제 가능 - dependent: :destroy로 멤버 전부 제거 ### join (GET) - 로그인 필수 (비로그인 → 로그인 → return_to) - invite_code로 그룹 찾기 - 이미 소유자 → "이미 그룹 소유자입니다" 안내 - 이미 멤버 → "이미 참여한 그룹입니다" 안내 - report_purchased: true인 진단 세션 목록 표시 → 선택하여 참여 ### process_join (POST) - diagnosis_session_id 파라미터 필수 - ViewGroupMember 생성 - 성공 → 그룹 show로 리다이렉트 ### leave (DELETE) - 멤버만 나가기 가능 - 소유자는 나가기 불가 → "그룹을 삭제하세요" 안내 (flash alert) ### team_strength (GET) - 소유자 또는 멤버만 접근 - ViewGroups::TeamStrengthAnalyzer 호출 - member_ids 파라미터 지원 (선택적 필터) ## 인가 매트릭스 | 액션 | 비로그인 | 일반 사용자 | 그룹 멤버 | 그룹 소유자 | |------|---------|-----------|----------|-----------| | index | redirect login | 자기 그룹만 | 자기 그룹만 | 자기 그룹만 | | show | redirect login | X | O | O | | create | redirect login | O | - | - | | destroy | redirect login | X | X | O | | join | redirect login | O (코드 유효시) | 이미 멤버 안내 | 이미 소유자 안내 | | leave | redirect login | X | O | X (소유자 불가) | | team_strength | redirect login | X | O | O | ## 참고 파일 - `app/controllers/workspaces_controller.rb` — join/process_join 패턴 참고 - `app/controllers/concerns/authentication.rb` — require_authentication 패턴 - `config/routes.rb` — 기존 라우팅 패턴 ## 완료 기준 - [ ] 컨트롤러 테스트 통과 (CRUD, join, leave, 권한, 비로그인 리다이렉트) - [ ] 비로그인 → 로그인 → return_to join URL 리다이렉트 정상 - [ ] 진단 미구매 사용자 참여 차단 + 안내 메시지 - [ ] 소유자 나가기 차단 + 안내 메시지 - [ ] strong parameters 적용
함께 보기 전체 뷰 구현 (index, show, new, join, team_strength)
## 작업 내용 함께 보기 기능의 전체 뷰를 구현합니다. PR3에서 생성한 스텁 뷰를 완성합니다. ## 참고 파일 (반드시 먼저 읽기) - `app/views/workspaces/team_strengths/` — 팀강점 매트릭스 뷰 (show, _overview, _matrix, _matrix_content) - `app/views/workspaces/` — 워크스페이스 뷰 패턴 - `app/views/shared/` — 공용 partial (_button, _card, _input, _flash, _sidebar_nav_item) - `app/helpers/qr_code_helper.rb` — QR코드 SVG 생성 - `app/controllers/view_groups_controller.rb` — 컨트롤러 (PR3) ## 뷰 목록 ### 1. index.html.erb — 내 그룹 목록 - 소유한 그룹 / 참여한 그룹 섹션 분리 - 각 그룹을 카드로 표시 (이름, 멤버 수, 생성일) - "새 그룹 만들기" 버튼 - 빈 상태: "아직 그룹이 없습니다. 새 그룹을 만들어보세요!" - 기존 shared/_card partial 활용 ### 2. new.html.erb — 그룹 생성 - 그룹 이름 입력 폼 - 기존 shared/_input, shared/_button partial 활용 - 에러 메시지 표시 ### 3. show.html.erb — 그룹 상세 - 그룹 이름 + 소유자 정보 - QR코드 표시 (QrCodeHelper#qr_code_svg 사용) - 초대 링크 (복사 버튼 포함) - 멤버 목록 (이름, 진단 유형, 참여일) - 소유자에게만 "그룹 삭제" 버튼 - 멤버에게만 "나가기" 버튼 - "팀 강점 보기" 버튼/링크 → team_strength 페이지로 - 빈 상태: "아직 멤버가 없습니다. QR코드를 공유하세요!" ### 4. join.html.erb — 참여 확인 - 그룹 이름 표시 - report_purchased: true인 진단 세션 목록 (라디오 버튼 또는 카드 선택) - 각 세션: 진단 유형, 완료일 표시 - "참여하기" 버튼 - 세션이 없으면: "구매된 진단 결과가 없습니다" 안내 ### 5. team_strength.html.erb — 팀강점 매트릭스 - 기존 workspaces/team_strengths/show의 구조를 참고하되, ViewGroup 전용으로 구현 - 멤버 선택 체크박스 (비교할 멤버 선택) - 라인 차트 (9WAY 점수 비교) - DNA 매트릭스 (멤버별 강점 DNA 뱃지) - 기존 team_strengths 뷰의 JS/차트 렌더링 패턴 참고 ## UI 요구사항 - Tailwind CSS 사용 (기존 디자인 시스템 준수) - 반응형 (모바일 QR 스캔이 주요 시나리오) - layout "authenticated" 사용 - i18n: 뷰에 하드코딩된 한국어 텍스트는 i18n 키로 관리 - 기존 shared partial 최대한 활용 ## 완료 기준 - [ ] 모든 뷰 정상 렌더링 - [ ] QR코드에 초대 URL 정상 인코딩 - [ ] 모바일 반응형 동작 - [ ] 빈 상태 처리 - [ ] 진단 세션 선택 UI 동작 - [ ] 팀강점 매트릭스 차트 렌더링 - [ ] i18n 키 추가 (ko.yml)
사이드바 + 하단탭 메뉴 추가 + 4개 locale i18n
## 작업 내용 사이드바와 하단 탭에 "함께 보기" 메뉴를 추가하고, 4개 언어 i18n을 설정합니다. ## 참고 파일 (반드시 먼저 읽기) - `app/views/shared/_sidebar.html.erb` — 사이드바 구조 - `app/views/shared/_bottom_tabs.html.erb` — 하단 탭 구조 - `app/views/shared/_sidebar_nav_item.html.erb` — 네비 아이템 partial - `config/locales/ko.yml`, `en.yml`, `zh.yml`, `vi.yml` — i18n 파일 ## 사이드바 수정 - 커리어테크 메뉴 아래에 "함께 보기" 항목 추가 - 기존 `_sidebar_nav_item` partial 사용 - 아이콘: 사람들/그룹 아이콘 SVG (기존 아이콘 패턴 참고) - path: `view_groups_path` - active 조건: `controller_name == "view_groups"` - **전 locale에서 표시** (커리어테크와 달리 ko 제한 없음) - **로그인 사용자에게만 표시** (authenticated? 조건) ## 하단 탭 수정 - 모바일 하단 탭에 "함께 보기" 추가 - 기존 탭 패턴과 동일한 구조 - 아이콘 + 텍스트 ## i18n (4개 언어) ### ko.yml ```yaml nav: sidebar: together: "함께 보기" bottom_tabs: together: "함께 보기" ``` ### en.yml ```yaml nav: sidebar: together: "View Together" bottom_tabs: together: "Together" ``` ### zh.yml ```yaml nav: sidebar: together: "一起看" bottom_tabs: together: "一起看" ``` ### vi.yml ```yaml nav: sidebar: together: "Xem cùng" bottom_tabs: together: "Cùng xem" ``` ## 완료 기준 - [ ] 사이드바에 "함께 보기" 메뉴 표시 (커리어테크 아래) - [ ] 하단 탭에 메뉴 추가 - [ ] 4개 locale 정상 표시 - [ ] 비로그인 시 메뉴 미표시 - [ ] 현재 페이지 active 상태 표시 - [ ] 기존 테스트 깨지지 않음
PDF 리포트 재설계 - 강점 해석 가이드 (1p)
## 섹션 2: 강점 해석 가이드 (1페이지) 기존 `_way_map_guide.html.erb` 정제. 9WAY 체계를 읽기 쉽게 설명. ### 포함 요소 - 3가지 핵심 질문 (영역/역할/차별성) 설명 텍스트 - 9WAY Circle Chart (SVG, `way_map_svg` 헬퍼 활용) - 차트 읽는 법 간략 안내 ### 디자인 원칙 - 텍스트 중심, 5개 문단 → 더 간결하게 정제 가능 - Circle Chart 크기 적절히 배치 - 불필요한 배경색/박스 제거 ### 완료 기준 - 기존 way_map_guide 파트셜 리팩토링 - paragraph_1~5 i18n 키 정제
강점 브랜딩: 수식어/되고 싶은 모습 시드 데이터
## 목표 DNA별 수식어(modifier)와 WAY별 되고 싶은 모습(aspiration) 데이터를 생성합니다. ## 작업 내용 1. **시드 데이터 구조 설계** - DNA 27개 × 약 10개 수식어 (형용사/부사구) - WAY 9개 × 약 10개 되고 싶은 모습 (명사구) - 4개 언어 지원: ko, en, zh, vi 2. **저장 방식 결정** (2가지 옵션 중 택 1) - Option A: `branding_options` 테이블 신규 생성 (type: modifier/aspiration, key, locale, options[]) - Option B: i18n YAML 파일에 추가 (`config/locales/branding/ko.yml` 등) - 권장: Option B (별도 테이블 불필요, 배포 편리) 3. **데이터 작성** - 기존 `config/locales/dna/ko.yml`의 phrase/short 필드 참고 - DNA 수식어 예: Creativity → ["창의적인", "혁신적인", "독창적인", ...] - WAY 되고 싶은 모습 예: Ideation → ["새로운 가능성을 여는 사람", "아이디어로 세상을 바꾸는 사람", ...] ## 완료 기준 - [ ] 27 DNA × ~10 수식어 (ko) 작성 - [ ] 9 WAY × ~10 되고 싶은 모습 (ko) 작성 - [ ] en/zh/vi 번역 (최소 ko 완성 후) - [ ] 데이터 로드 확인
어드민_진단관리
http://localhost:3000/admin/diagnosis_types 진단결과에 유형, 사용자, 결제 상태 확인 가능해야 하고, 유료무료 변경 가능해야해. DNA 5개도 보여야 하고, 워크스페이스에 속해 있으면 속한 워크스페이스명도 보여야하고 진단유형 / 사용자 이름 / 사용자 이메일 / 유형 / 완료일 / 진단 결과 보기 / pdf 다운로드 / 결제상태(변경가능)
어드민 진단 세션 관리 목록 페이지 구현
## 목표 어드민에서 전체 진단 세션을 관리할 수 있는 목록 페이지를 새로 만듭니다. ## 새 파일 생성 ### 1. 컨트롤러: `app/controllers/admin/diagnosis_sessions_controller.rb` 기존 `admin/users_controller.rb` 패턴을 따르되: ```ruby module Admin class DiagnosisSessionsController < BaseController PER_PAGE = 50 def index @sessions = DiagnosisSession.includes(:user, :payment, :workspace_diagnoses, diagnosis_type: [:category, :target]) .where(status: "completed") .order(created_at: :desc) # 검색 (사용자 이름/이메일) if params[:q].present? q = "%#{params[:q]}%" @sessions = @sessions.joins(:user).where("users.name ILIKE :q OR users.email_address ILIKE :q", q: q) end # 진단 유형 필터 if params[:diagnosis_type_id].present? @sessions = @sessions.where(diagnosis_type_id: params[:diagnosis_type_id]) end # 결제 상태 필터 case params[:payment_status] when "paid" @sessions = @sessions.where.not(payment_id: nil).joins(:payment).where(payments: { status: "completed" }) when "free" @sessions = @sessions.where(payment_id: nil) end # 정렬 @sort = params[:sort].presence_in(%w[created_at end_time]) || "created_at" @dir = params[:dir].presence_in(%w[asc desc]) || "desc" @sessions = @sessions.order(@sort => @dir) # 페이지네이션 @page = [params[:page].to_i, 1].max @total_count = @sessions.count @total_pages = [(@total_count.to_f / PER_PAGE).ceil, 1].max @page = @total_pages if @page > @total_pages @sessions = @sessions.offset((@page - 1) * PER_PAGE).limit(PER_PAGE) @diagnosis_types = DiagnosisType.includes(:category, :target).order(:display_order) end def toggle_payment session = DiagnosisSession.find(params[:id]) if session.report_purchased? session.update!(payment: nil) else payment = Payment.create!( user: session.user, order_id: "admin_free_#{session.id}_#{Time.current.to_i}", amount: 0, status: "completed", payment_type: "report", currency: "KRW", payment_key: "admin_granted", approved_at: Time.current ) session.update!(payment: payment) end redirect_to admin_diagnosis_sessions_path(request.query_parameters.except(:action, :controller, :id)), notice: "결제 상태가 변경되었습니다." end end end ``` ### 2. 뷰: `app/views/admin/diagnosis_sessions/index.html.erb` 테이블 컬럼: 1. **진단 유형**: `session.diagnosis_type.slug` (category_target 형태) 2. **사용자 이름**: `session.user&.name` (링크 → admin_user_path) 3. **사용자 이메일**: `session.user&.email_address` 4. **Top 5 DNA**: `session.json_data["scored_dna"]` 상위 5개 name을 작은 badge로 - `json_data["scored_dna"]`가 [{key, name, score},...] 배열 - 없으면 `json_data.dig("top_dna")` 확인 5. **완료일**: `session.end_time&.strftime("%Y-%m-%d")` 6. **진단 결과 보기**: 링크 → `"/#{session.locale}/diagnoses/#{session.id}"` (새 탭) 7. **PDF 다운로드**: 링크 → `"/#{session.locale}/diagnoses/#{session.id}/download_pdf"` (새 탭) 8. **결제 상태**: badge + 변경 버튼 - 유료: green badge "유료" + "무료로 변경" 링크 - 무료: gray badge "무료" + "유료로 변경" 링크 - 변경은 PATCH toggle_payment 호출 9. **워크스페이스**: `session.workspace_diagnoses.first&.workspace&.name` (있으면 표시) 상단: - 검색바 (이름/이메일) - 진단 유형 필터 (select) - 결제 상태 필터 (전체/유료/무료) 하단: - 페이지네이션 (기존 admin 패턴 - `@page`, `@total_pages` 사용) ### 3. 라우트 추가 (`config/routes.rb`) admin namespace에: ```ruby resources :diagnosis_sessions, only: %i[index] do member { patch :toggle_payment } end ``` ## 스타일 참고 - 기존 `admin/users/index.html.erb` 테이블 패턴 따르기 - Tailwind CSS, admin 레이아웃 - `admin_helper.rb`의 `sort_link` 헬퍼 활용 ## 주의사항 - workspace_diagnoses에서 workspace 이름 가져올 때 N+1 방지: `includes(:workspace_diagnoses => :workspace)` 또는 lazy load - user가 nil일 수 있음 (optional: true) - nil 체크 필수 - WorkspaceDiagnosis 모델이 있는지 확인 (`app/models/workspace_diagnosis.rb`) ## 완료 기준 - /admin/diagnosis_sessions 에서 전체 진단 세션 목록 확인 가능 - 검색, 필터링, 페이지네이션 동작 - 결제 상태 토글 동작 - 워크스페이스 이름 표시
워크스페이스 메뉴 권한 테스트 작성
## 작업 내용 sidebar-dev가 구현을 완료한 후, 워크스페이스 메뉴 권한 관련 테스트를 작성합니다. ### 테스트 시나리오 1. **regular 사용자**: 워크스페이스 섹션이 사이드바에 표시되지 않아야 함 2. **coach_basic 사용자 (워크스페이스 없음)**: 워크스페이스 섹션 + "워크스페이스 생성" 링크 표시 3. **coach_basic 사용자 (워크스페이스 있음)**: 워크스페이스 목록 + 하단에 "워크스페이스 생성" 링크 표시 4. **org_admin_basic 사용자**: 위와 동일한 동작 5. **admin 사용자**: 위와 동일한 동작 ### 테스트 유형 - 시스템 테스트 또는 통합 테스트로 작성 - 기존 테스트 패턴을 따름 (test/system/ 또는 test/integration/) - Minitest + fixtures 사용 ### 확인할 파일 - `app/views/shared/_sidebar.html.erb` - 표시 조건 - `app/views/shared/_sidebar_workspace_section.html.erb` - 생성 링크 - `test/` 디렉토리의 기존 테스트 패턴 참고 ### 완료 기준 - 모든 권한별 시나리오 테스트 통과 - `bin/rails test` 전체 테스트 통과
결제 관리
http://localhost:3000/admin/ 관리자 메뉴에 결제 내용을 보여주는 메뉴를 만들어줘. 사용자, 이메일, 전화번호, 상품명, 가격, 상태, 할인금액, 결제일, 영수증, 취소 테이블 표시
Admin Payments 뷰 + 사이드바 메뉴
## 작업 내용 관리자 결제 관리 페이지의 뷰를 구현하고, 사이드바에 메뉴를 추가합니다. ## 완료 기준 ### 1. 사이드바 메뉴 추가 (app/views/admin/shared/_sidebar.html.erb) - "진단 세션" 메뉴 아래에 "결제 관리" 메뉴 추가 - 아이콘: Heroicon의 credit-card 또는 banknotes 아이콘 사용 - 링크: admin_payments_path - 활성 상태 표시: 기존 패턴 따르기 ### 2. Payments Index 뷰 (app/views/admin/payments/index.html.erb) 기존 admin 뷰 패턴(diagnosis_sessions/index.html.erb)을 참고하여 일관된 디자인으로 구현: **페이지 헤더:** - 제목: "결제 관리" - 전체 건수 표시 **검색 & 필터 영역:** - 검색: 사용자명/이메일/전화번호 통합 검색 - 상태 필터: 전체/대기중(pending)/완료(completed)/실패(failed)/취소(canceled) - 결제 유형 필터: 전체 + 각 payment_type (한글 표시) **테이블 컬럼 (요구사항 순서):** 1. 사용자 (user.name) 2. 이메일 (user.email_address) 3. 전화번호 (user.phone) 4. 상품명 (payment_type 한글 표시 - report: "강점 진단 리포트", analysis_credit: "분석 크레딧" 등) 5. 가격 (amount, number_to_currency 포맷) 6. 상태 (status, 색상 배지로 표시 - completed: green, pending: yellow, failed: red, canceled: gray) 7. 할인금액 (discount_amount, 0이면 "-") 8. 결제일 (approved_at, 없으면 "-") 9. 영수증 (receipt_url이 있으면 링크, 없으면 "-") 10. 취소 (취소 버튼 - completed 상태이고 canceled_at이 없을 때만 표시) **페이지네이션:** - pagy 사용 (기존 패턴 참고) **결제 유형 한글 매핑:** ``` report → 강점 진단 리포트 career_up → 커리어업 premium → 프리미엄 analysis_credit → 분석 크레딧 package → 패키지 workshop → 워크숍 strength_diary → 강점 다이어리 custom → 커스텀 결제 ``` **상태 한글 매핑:** ``` pending → 대기중 completed → 완료 failed → 실패 canceled → 취소 ``` ### 3. Tailwind CSS 스타일 - 기존 admin 뷰의 Tailwind 클래스 패턴을 정확히 따르세요 - 반응형 테이블 (overflow-x-auto) - 상태 배지, 버튼 등 기존 디자인 시스템 유지 ### 참고 사항 - 기존 admin 뷰 패턴: app/views/admin/diagnosis_sessions/index.html.erb 참고 - 사이드바: app/views/admin/shared/_sidebar.html.erb - Tailwind CSS 사용 중 - ERB 템플릿 엔진 - 한국어 UI
할인코드 어드민 CRUD + UI
## 목표 어드민 패널에 할인코드 관리 기능 구현 (CRUD + 토글) ## 작업 내용 ### 1. 라우트 (config/routes.rb) ```ruby # admin 네임스페이스 안에 추가 resources :discount_codes, except: :destroy do member do patch :toggle_active end end ``` ### 2. 컨트롤러 (app/controllers/admin/discount_codes_controller.rb) - Admin::BaseController 상속 - 기존 Admin::ProductsController 패턴 따름 - 액션: index, show, new, create, edit, update, toggle_active - Index: 검색(name, code), 필터(discount_type, active), 정렬, 페이지네이션 - PER_PAGE = 30 ### 3. 뷰 (app/views/admin/discount_codes/) - index.html.erb: 테이블 (이름, 코드, 유형, 값, 적용상품, 사용횟수/최대, 만료일, 활성화, 액션) - show.html.erb: 상세 정보 + 적용 상품 목록 + 사용 내역 - _form.html.erb: 생성/수정 공통 폼 - 이름 (text) - 코드 (text, 자동생성 안내) - 할인 유형 (select: percentage/fixed_amount) - 할인값 (number) - 전체 적용 (checkbox) - 적용 상품 (multi-select, applies_to_all이 false일 때만) - 최대 사용횟수 (number, optional) - 최소 구매금액 (number) - 만료일 (datetime) - 활성화 (checkbox) - new.html.erb, edit.html.erb: form 렌더 ### 4. 사이드바 (app/views/admin/shared/_sidebar.html.erb) - "Payments" 아래에 "Discount Codes" 메뉴 추가 - 아이콘: heroicon 'tag' 또는 유사 ### 5. 로케일 (config/locales/*.yml) - en.yml, ko.yml, zh.yml, vi.yml에 admin.discount_codes 섹션 추가 - 기존 admin.products 패턴 참고 ### 6. Tailwind CSS 스타일 - 기존 어드민 뷰 (payments, products) 스타일과 일관성 유지 - auto_submit_controller 사용 (검색/필터) ### 7. 테스트 (test/integration/admin/discount_codes_test.rb) - 접근 제어 (일반 사용자 차단) - Index (기본 조회, 검색, 필터, 정렬, 페이지네이션) - Create (성공, 실패) - Update (성공, 실패) - Toggle active - Show ## ⚠️ 의존성 - model-dev가 DiscountCode 모델과 마이그레이션을 완료해야 함 - 마이그레이션이 아직 없으면 먼저 `bin/rails db:migrate` 실행 후 작업 - 모델이 없으면 모델 파일이 생길 때까지 컨트롤러/뷰부터 작성 ## 파일 담당 범위 (이 파일들만 수정) - app/controllers/admin/discount_codes_controller.rb (신규) - app/views/admin/discount_codes/* (신규) - app/views/admin/shared/_sidebar.html.erb (수정) - config/routes.rb (수정 - admin 블록 안에 추가) - config/locales/en.yml, ko.yml, zh.yml, vi.yml (수정 - admin.discount_codes 추가) - test/integration/admin/discount_codes_test.rb (신규) ## 완료 기준 - 어드민에서 할인코드 CRUD 가능 - 사이드바에 메뉴 표시 - 4개 로케일 파일 업데이트 - 통합 테스트 통과
Resource API 구축 (/api/v1)
## 개요 doorkeeper Bearer 토큰 인증 기반의 Resource API를 구축하여 외부 서비스에서 진단 결과, 강점 프로필 등을 조회할 수 있도록 한다. ## 작업 내용 - api/v1 namespace 신규 생성 - doorkeeper Bearer 토큰 인증 기반 API base controller - Scope 기반 권한 설계 (profile, diagnoses, strengths) - API endpoints: - GET /api/v1/me — 현재 사용자 프로필 - GET /api/v1/users/:uid/diagnoses — 진단 이력 - GET /api/v1/users/:uid/strength_profile — 강점 프로필 - Client Credentials Grant 지원 (서버 간 통신용) - API 응답 JSON 직렬화 ## 완료 기준 - [ ] Bearer 토큰으로 API 호출 성공 - [ ] 유효하지 않은 토큰 시 401 응답 - [ ] Scope에 따른 접근 제어 동작 - [ ] Client Credentials Grant로 서버 간 API 호출 성공 - [ ] API integration test 통과
Consumer 서비스 연동 가이드 문서 작성
## 작업 내용 `docs/api/consumer_integration_guide.md` 연동 가이드 문서 작성 ### 포함할 내용 1. **개요**: 9WAY OAuth2 Provider 소개, 지원 Grant Type 2. **사전 준비**: - 9WAY 어드민에서 OAuth Application 등록 방법 - client_id, client_secret 발급 - redirect_uri 설정 - scopes 설명 (profile, email, diagnoses, strengths) 3. **Authorization Code Grant 흐름**: - 단계별 설명 (authorize → callback → token) - PKCE 지원 설명 - 코드 예시 (Rails + OmniAuth) 4. **Client Credentials Grant 흐름**: - 서버 간 통신용 - 토큰 발급 curl 예시 5. **API 호출 예시**: - GET /api/v1/me (프로필 조회) - GET /api/v1/users/:id/diagnoses (진단 이력) - GET /api/v1/users/:id/strength_profile (강점 프로필) - 각 응답 JSON 예시 포함 6. **사용자 식별**: - provider_uid 매핑 방법 - Consumer DB에 저장할 필드 7. **에러 처리**: - 401, 403, 404 응답 처리 - 토큰 갱신 (Refresh Token) 8. **Rails Consumer 빠른 시작**: - Gemfile 설정 - OmniAuth initializer - 콜백 컨트롤러 예시 - API 클라이언트 서비스 객체 예시 ## 참고 - 기존 doorkeeper 설정: `config/initializers/doorkeeper.rb` - API 컨트롤러: `app/controllers/api/v1/` - API 라우트: `config/routes.rb`의 api namespace ## 완료 기준 - 개발자가 문서만 보고 Consumer 서비스를 연동할 수 있는 수준 - curl 예시와 Rails 코드 예시 모두 포함
Big5 응답 일괄 제출 (레거시 패턴)
## 목표 Big5 진단 응답을 매 라운드 서버 전송 → JS 누적 후 마지막 1번 제출로 변경. HTTP 요청 30배 감소 (1000명: 30,000 → 1,000). ## 배경 (레거시 분석) 레거시(Next.js)는 모든 응답을 localStorage에 누적 → 마지막에 POST 1번으로 1000명 동시 진단 처리. 현재 Rails는 30라운드 × 개별 AJAX = 30배 더 많은 서버 요청 발생. ## 변경 범위 - **Stimulus 컨트롤러**: 응답 데이터를 JS 메모리/sessionStorage에 누적 - **Turbo Stream 라운드 전환**: 서버 저장 제거, 클라이언트에서 다음 라운드 전환 (UI 유지) - **diagnosis_sessions_controller**: 최종 제출 액션 추가 (submit_all_big5_responses) - **session_manager**: save_responses_bulk 메서드 (upsert_all 활용) - **sessionStorage 폴백**: 네트워크 끊김/새로고침 대비 임시 저장 ## 완료 기준 - Big5 진단 30라운드 완료 시 서버 요청 1회만 발생 - sessionStorage에 중간 응답 저장 확인 - 새로고침 시 진행 상태 복원 - 기존 테스트 통과 ## 리스크 - 네트워크 끊김 시 데이터 손실 → sessionStorage 폴백으로 대응 - 라운드별 서버 검증 제거 → 최종 제출 시 일괄 검증으로 보완 - submit_part2_round도 동일 패턴 적용 검토 ## 의존성 없음 (PR #1과 병렬 작업 가능)
진단 결과 Rails.cache.fetch 적용
diagnoses_controller.rb show에서 Big5/9Way/Engagement 결과를 Rails.cache.fetch로 캐싱. expires_in: 30.days. big5_result_formatter.rb의 find_quality_code 맵핑도 캐시. 테스트 통과 확인.
Client: Stimulus 컨트롤러 + sessionStorage + 라운드 전환
## 목표 Big5 진단의 라운드별 AJAX 제출을 클라이언트 누적 + 최종 1회 제출로 변경. ## ⚠️ 리뷰 반려 사유 (2026-03-09) ### [MUST-FIX] 접근성 ARIA 속성 누락 - 질문 카드에 `role="radio"`, `aria-checked` 없음 - 컨테이너에 `role="radiogroup"` 없음 - neither 버튼에 `aria-label` 없음 - 카운트다운에 `role="timer"`, `aria-live="polite"` 없음 - 선택 시 `aria-checked="true"` 상태 변경 필요 ### [MUST-FIX] JS 내 한국어 텍스트 하드코딩 - `_showSubmitting()`, `_showComplete()`, `_showError()` 내 텍스트가 한국어만 - 4개 언어(ko, en, zh, vi) 지원인데 i18n 미적용 - **수정**: Stimulus value로 번역 텍스트 전달 또는 서버 렌더링 `<template>` 사용 ### [MUST-FIX] 타임아웃 자동 스킵 피드백 부재 - 30초 경과 시 자동으로 다음 라운드로 넘어가지만 사용자 안내 없음 - 토스트 메시지 또는 안내 텍스트 필요 --- ## 작업 내용 (이하 기존 내용 동일) ### 1. Stimulus 컨트롤러: big5_batch_controller.js ### 2. 클라이언트 라운드 전환 ### 3. sessionStorage 폴백 ### 4. 뷰 수정 ## 완료 기준 - 반려 사유 3건 모두 수정 - ARIA 접근성 테스트 확인 - 4개 언어에서 UI 텍스트 정상 표시 - 기존 기능 동작 유지
prepare_all_big5_rounds 세션별 캐싱
## 문제 `diagnosis_sessions_controller.rb#prepare_all_big5_rounds`가 매 show 요청마다 실행 (300-500ms). 90개 문항 + 번역 로드 + 45라운드 생성. 세션 ID 기반 결정론적 RNG이므로 결과 항상 동일. ## 변경 ```ruby def prepare_all_big5_rounds Rails.cache.fetch(["big5_rounds", @session.id, I18n.locale], expires_in: 1.day) do # 기존 로직 end end ``` 또한 `load_questions`와 `prepare_all_big5_rounds`의 이중 쿼리 통합: - load_questions에서 한 번 로드한 것을 재활용하도록 리팩토링 ## 완료 기준 - [ ] show 페이지 캐시 히트 시 응답시간 300-500ms → 10-30ms - [ ] 새로고침 시 동일 라운드 데이터 반환 - [ ] bin/ci 통과
[Phase 2] PR 2-1: Blog 모델 (DB 레이어)
## 개요 Blog::Post, Category, Comment, Like 4개 모델 + 마이그레이션 ## 스키마 - blog_categories: name, slug(unique), description - blog_posts: author_id(FK→users), category_id(FK), title, slug(unique), content(text/HTML), excerpt, description, keywords, status(enum: draft/published/archived), view_count, published_at, featured_image(ActiveStorage) - blog_comments: post_id(FK), author_id(FK→users), parent_id(self-ref 1단계), content - blog_likes: post_id(FK), user_id(FK), unique(post_id, user_id) ## 완료 기준 - 마이그레이션 성공 + 롤백 + 재마이그레이션 - 모델 유효성 검사 테스트 통과 - slug 자동 생성, enum 동작, 연관관계 동작 - featured_image ActiveStorage 첨부 ## 의존성 PR 1-1 (서브도메인 라우팅)
PR 7-1: 수강료 Toss Payments 결제 연동
Study enrollment Toss Payments 결제 연동. 기존 결제 서비스 패턴 재사용. TDD 기반. ## 담당 파일 - app/services/study/enrollment_service.rb (신규) - app/controllers/study/payments_controller.rb (신규) - app/views/study/payments/ (신규) - app/services/payments/checkout_service.rb (수정 - study_enrollment 타입 추가) - app/services/payments/confirmation_service.rb (수정 - 수강 확인 콜백) - config/routes.rb (edu subdomain에 payments 추가) - test/services/study/enrollment_service_test.rb (신규) - test/controllers/study/payments_controller_test.rb (신규) ## 완료 기준 - Toss Payments 수강료 결제 (88,000원/3개월) - 결제 완료 → 자동 Enrollment 생성 (status: active) - 기존 결제 서비스 패턴 재사용 ## 주의사항 - 기존 payments/ 서비스 코드를 먼저 읽고 패턴 파악 - routes.rb edu subdomain 블록 내에만 추가
[함께보기 PR2] 서비스: ViewGroups::TeamStrengthAnalyzer
## 설명 기존 Workspaces::TeamStrengthAnalyzer 로직을 ViewGroup 전용으로 별도 생성. 기존 코드에 영향 없이 독립적으로 구현. ## 참고 파일 - app/services/workspaces/team_strength_analyzer.rb (핵심 로직 참고) ## 핵심 차이점 - Workspace: workspace_diagnoses 테이블 조인으로 세션 수집 - ViewGroup: view_group_members.diagnosis_session_id 직접 사용 - report_purchased: true인 세션만 대상 ## 출력 데이터 구조 way_averages, synergy, members_data, team_line_chart 등 기존과 동일한 키 구조 유지. ## 완료 기준 - [ ] ViewGroups::TeamStrengthAnalyzer 서비스 생성 - [ ] ViewGroup을 받아 동일 구조의 분석 결과 반환 - [ ] 서비스 테스트 통과 - [ ] 멤버가 0명일 때 빈 결과 반환 (에러 없음) ## 의존성 - PR #1 완료 후 진행
DiagnosesController 권한 로직 수정 - 워크스페이스 admin 멤버 결과 열람 허용
## 목표 워크스페이스 admin이 같은 워크스페이스 멤버의 진단 결과를 볼 수 있도록 DiagnosesController 권한 로직을 수정한다. ## 현재 문제 - `DiagnosesController#set_session`에서 `@session.user == Current.user` 소유자만 허용 - 워크스페이스 admin이 멤버 결과에 접근하면 "접근 권한이 없습니다" 에러 발생 ## 수정 사항 - `set_session`에서 워크스페이스 admin 권한 체크 추가 - `WorkspaceDiagnosis` 테이블로 해당 세션이 워크스페이스에 속하는지 확인 - 보안: 같은 워크스페이스의 admin만 허용 (leader, member 불가) ## 완료 기준 - [ ] 워크스페이스 admin이 멤버 진단 결과를 볼 수 있음 - [ ] 워크스페이스 leader/member는 여전히 타인 결과 접근 불가 - [ ] 워크스페이스 비멤버는 접근 불가 - [ ] 자기 자신의 결과는 기존처럼 볼 수 있음
PDF 리포트 재설계 - 요약 (1p)
## 섹션 3: 요약 (1페이지) 기존 `_executive_summary.html.erb` 축소/정제. ### 포함 요소 - 퍼스널 브랜딩 문구 - 1위 강점 영역 (Domain) + 설명 - 1위 강점 역할 (WAY) + 설명 - 1위 강점 차별성 (DNA) + 설명 - Top 5 DNA 리스트 (순위, 이름, 한줄 설명) ### 디자인 원칙 - highlight-card/card-navy 등 컬러 박스 최소화 - 깔끔한 텍스트 위계 (제목/소제목/본문)로 구조화 - 불필요한 synergy 카드 제거 고려 ### 완료 기준 - 기존 executive_summary 파트셜 리팩토링 - 1페이지에 깔끔하게 수렴
강점 브랜딩: 수정 위자드 UI (Stimulus 5-step modal)
## 목표 강점 브랜딩 수정을 위한 5단계 위자드 모달 UI를 구현합니다. ## 위자드 단계 1. **DNA 선택** (Top 5 중 1~3개 선택, 체크박스) 2. **DNA 수식어 선택** (선택한 각 DNA의 수식어 목록에서 택 1, 라디오) 3. **WAY 선택** (Top 3 중 1개 선택, 라디오) 4. **되고 싶은 모습 선택** (선택한 WAY의 aspiration 목록에서 택 1, 라디오) 5. **AI 브랜딩 미리보기** (선택 결과 + AI 생성 문장 표시, 수정/재생성 가능) ## 기술 스택 - **Stimulus Controller**: `branding_wizard_controller.js` - 참고: 기존 `role_wizard_controller.js` 패턴 활용 - step 전환, 선택 상태 관리, API 호출 - **Turbo Frame**: 모달 내용 동적 로드 - **디자인**: 기존 모달 스타일 (`way_contribution_modal` 참고) ## 화면 구성 - 상단: 진행 표시 (Step 1/5, 2/5, ...) - 중앙: 선택 UI (카드형 그리드) - 하단: 이전/다음 버튼 - 최종 단계: "저장" 버튼 ## 완료 기준 - [ ] 5단계 위자드 모달 동작 - [ ] 각 단계 선택 데이터 올바르게 전달 - [ ] 뒤로 가기 시 이전 선택 유지 - [ ] 모바일 반응형 - [ ] AI 호출 중 로딩 상태 표시
문항관리
http://localhost:3000/admin/survey_questions 문항관리는 문항 번호별로 관리하는게 아니라, 검사 유형 (강점검사), 검사 대상, 언어 이런식으로 목록이 있고, 해당 목록을 선택하면 해당 검사에 문항들이 part1 1~12 / part2 1~36번이 쭉 내용과 함께 보이는거야. part1은 선택지 1점 0 (0%) 2점 0 (0%) 이런식으로 그리고 part2는 선택지에 대해서 A선택지 0 (0%) B선택지 0 (0%) C선택지 0(%) 이런식으로 보여야해. 통계를 내야하거든.
문항관리 리디자인 - 유형/대상/언어 그룹 + 응답 통계
## 목표 기존 문항관리 페이지를 리디자인합니다. 문항 번호별 목록이 아닌, 검사 유형/대상/언어 그룹으로 목록을 보여주고, 선택하면 해당 문항들과 응답 통계가 표시됩니다. ## 변경 파일 ### 1. 컨트롤러 수정: `app/controllers/admin/survey_questions_controller.rb` #### index 액션 리디자인 - 기존 index: 문항 번호별 평면 목록 → 변경: 유형/대상/언어 그룹 목록 - 그룹 목록 데이터: ```ruby def index @groups = DiagnosisType.includes(:category, :target).where(active: true).order(:display_order) @locales = %w[ko en zh vi] end ``` - 각 그룹 = DiagnosisType × locale 조합 (예: "강점검사 - 성인 - 한국어") #### show 액션 (그룹 선택 시) - 라우트 변경 또는 새 액션 추가: `questions` 또는 기존 show 재활용 - 새 액션 `group_detail` 추가: ```ruby def group_detail @diagnosis_type = DiagnosisType.find(params[:diagnosis_type_id]) @locale = params[:locale] || "ko" @target = @diagnosis_type.target @questions = SurveyQuestion.includes(:translations, :category) .where(category_id: @diagnosis_type.category_id) .active .order(:part, :display_order) # 응답 통계 계산 # completed 세션의 response만 카운트 session_ids = DiagnosisSession.where(diagnosis_type: @diagnosis_type, status: "completed").pluck(:id) @response_stats = calculate_response_stats(@questions, session_ids) end ``` #### 응답 통계 계산 private 메서드 ```ruby def calculate_response_stats(questions, session_ids) return {} if session_ids.empty? stats = {} responses = DiagnosisResponse.where(diagnosis_session_id: session_ids, survey_question_id: questions.pluck(:id)) .group(:survey_question_id, :response_value) .count questions.each do |q| total = 0 distribution = {} if q.part == 1 # Part1: 1점~5점 분포 (1..5).each do |val| count = responses[[q.id, val.to_s]] || responses[[q.id, val]] || 0 distribution[val] = count total += count end else # Part2: A, B, C 선택지 분포 (response_value가 "A", "B", "C" 또는 1,2,3) # DiagnosisResponse의 response_value 확인 필요 # selected_option_id 또는 response_value로 구분 %w[1 2 3].each do |val| count = responses[[q.id, val]] || responses[[q.id, val.to_i]] || 0 distribution[val] = count total += count end end stats[q.id] = { distribution: distribution, total: total } end stats end ``` ### 2. 뷰 생성 #### `app/views/admin/survey_questions/index.html.erb` (리디자인) - 그룹 목록을 카드/테이블로 표시: - 진단 유형명 (category.name + target.name) - 언어 선택 탭 또는 버튼 (ko, en, zh, vi) - 문항 수 표시 - 클릭 시 → group_detail로 이동 #### `app/views/admin/survey_questions/group_detail.html.erb` (새 뷰) - 상단: 뒤로 가기 + 그룹 정보 (유형, 대상, 언어) - Part 1 섹션 (1~12번): ``` Q1. [문항 내용 - 해당 target/locale의 번역] 1점: 0 (0%) | 2점: 0 (0%) | 3점: 0 (0%) | 4점: 0 (0%) | 5점: 0 (0%) ``` - 각 점수의 count와 percentage 표시 - percentage = (count / total * 100).round(1) - 바 차트(간단한 inline width %)로 시각화 - Part 2 섹션 (1~36번): ``` Q1. [문항 내용] A선택지: [텍스트] 0 (0%) | B선택지: [텍스트] 0 (0%) | C선택지: [텍스트] 0 (0%) ``` - Part2 문항은 `SurveyQuestionTranslation`에서 해당 target, locale의 텍스트 가져오기 - 선택지 텍스트는 translation에 포함되어 있을 수 있음 - 확인 필요 ### 3. 라우트 추가 (`config/routes.rb`) ```ruby resources :survey_questions, except: :destroy do collection { get :group_detail } member do patch :soft_delete patch :restore end end ``` ## 모델 관계 참고 - `SurveyQuestion`: category_id, part(1 or 2), dna_type, display_order - `SurveyQuestionTranslation`: survey_question_id, target_id, locale, question_text - `DiagnosisResponse`: diagnosis_session_id, survey_question_id, response_value, selected_option_id - `DiagnosisType`: category_id, target_id, slug - `DiagnosisCategory`: name (예: "강점검사") - `DiagnosisTarget`: name, slug (예: "성인", "adult") ## 스타일 참고 - 기존 admin Tailwind 패턴 따르기 - 통계 바: `<div class="bg-admin-primary/20 rounded-full h-2"><div class="bg-admin-primary rounded-full h-2" style="width: X%"></div></div>` - 테이블 스타일은 기존 admin 패턴 ## 주의사항 - DiagnosisResponse의 response_value 형식을 실제 DB 데이터로 확인할 것 (docker compose exec web rails runner "DiagnosisResponse.limit(5).pluck(:response_value, :selected_option_id)") - Part2 선택지가 어떻게 저장되는지 확인 필요 - 대량 데이터 쿼리 시 성능 고려 (group/count로 집계) ## 완료 기준 - /admin/survey_questions 에서 유형/대상별 그룹 목록 표시 - 그룹 선택 시 문항 + 응답 통계 표시 - Part1: 1~5점 분포 (count + percentage) - Part2: A/B/C 선택지 분포 (count + percentage)
2
커스텀 결제 메뉴
관리자에 커스텀 결제 메뉴 있어야함 1. 커스텀 결제 링크 생성 2. 생성된 링크 관리 및 링크 복사, 취소, QR
사용자 관리 인덱스 개선 + 인라인 역할 수정 + 진단 뷰어
관리자 사용자 관리 페이지를 개선합니다. ## 요구사항 ### 1. 인덱스 테이블 컬럼 추가 현재 index에는 이름/이메일/역할/생성일만 표시됩니다. 다음 컬럼을 추가하세요: - **전화번호**: user_profiles 테이블의 phone_country_code + phone_number (예: +82 1012345678) - **소속 워크스페이스**: workspace_members를 통한 워크스페이스 이름 목록 (쉼표 구분) - **진단**: 진단 세션 수 (클릭 시 목록 표시) ### 2. 컨트롤러 수정 (app/controllers/admin/users_controller.rb) - index 액션에서 `includes(:profile, :workspaces, :diagnosis_sessions)` eager loading 추가 - 검색(q 파라미터)에서 전화번호 검색도 지원: `LEFT JOIN user_profiles ON user_profiles.user_id = users.id` 후 phone_number ILIKE 검색 추가 - `update_role` 액션 추가: PATCH 요청으로 역할만 업데이트, Turbo Stream으로 응답 ### 3. 인라인 역할 수정 - 역할 컬럼에서 역할 뱃지를 클릭하면 드롭다운(select)이 나타나도록 - Turbo Frame 또는 Stimulus 컨트롤러 사용 - 역할 선택 시 즉시 PATCH 요청으로 서버에 저장 - 성공 시 뱃지로 다시 표시 ### 4. 진단 목록 뷰어 - 진단 수를 클릭하면 해당 사용자의 진단 세션 목록이 표시되도록 - Turbo Frame으로 진단 목록을 로드하거나 모달 사용 - 진단 목록에 표시할 정보: 진단 유형, 상태, 완료일 ### 5. i18n - 새로 추가되는 텍스트의 번역 키를 config/locales/ko.yml에 추가 ## 주요 파일 - `app/controllers/admin/users_controller.rb` - `app/views/admin/users/index.html.erb` - `config/routes.rb` (update_role 라우트 추가 필요시) - `config/locales/ko.yml` - Stimulus 컨트롤러가 필요하면 `app/javascript/controllers/` 에 생성 ## 참고 - 전화번호는 users 테이블이 아닌 user_profiles에 있음 (phone_country_code, phone_number) - User 모델에 `has_one :profile, class_name: "UserProfile"` 연관 있음 - User 모델에 `has_many :workspaces, through: :workspace_members` 연관 있음 - 기존 role_label, role_badge_class 헬퍼가 이미 존재함 - 현재 정렬 가능 컬럼: name, email_address, role, created_at
결제 흐름에 할인코드 통합
## 목표 결제 페이지에서 할인코드를 입력하고 검증/적용하는 기능 구현 ## 작업 내용 ### 1. 할인코드 검증 API (app/controllers/discount_codes_controller.rb 신규) ```ruby # POST /discount_codes/validate class DiscountCodesController < ApplicationController def validate result = DiscountCodes::ValidationService.call( code: params[:code], product: find_product, # payment_type으로 product 조회 amount: params[:amount].to_i ) if result.success? render json: { valid: true, discount_amount: result.discount_amount, discount_code_id: result.discount_code.id, message: "할인이 적용되었습니다" } else render json: { valid: false, message: result.error } end end end ``` ### 2. 라우트 추가 (config/routes.rb) ```ruby # admin 블록 바깥에 추가 post "discount_codes/validate", to: "discount_codes#validate" ``` ### 3. 결제 흐름 수정 **PaymentsController#checkout 수정**: - checkout_params에 `discount_code_id` 추가 - discount_code_id가 있으면 DiscountCodes::ValidationService로 재검증 - 검증 성공 시 CheckoutService에 discount_amount 전달 - 결제 완료 후 DiscountCodes::ApplyService로 사용 기록 **PaymentsController#success 수정**: - 결제 확인 후 할인코드 사용 처리 (ApplyService) ### 4. Stimulus 컨트롤러 (app/javascript/controllers/discount_code_controller.js) ```javascript // 할인코드 입력 + 검증 + UI 업데이트 import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["input", "result", "discountAmount", "discountCodeId", "totalAmount"] async validate() { const code = this.inputTarget.value.trim() if (!code) return const response = await fetch("/discount_codes/validate", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector("[name='csrf-token']").content }, body: JSON.stringify({ code, amount: this.originalAmount, payment_type: this.paymentType }) }) const data = await response.json() // UI 업데이트: 할인 금액 표시, hidden field 설정 } } ``` ### 5. 결제 뷰 수정 - 결제 페이지에 할인코드 입력 필드 추가 - 할인 적용 시 할인 금액과 최종 결제금액 표시 - hidden field로 discount_code_id 전달 ### 6. CustomPaymentsController 수정 - 커스텀 결제 링크에서도 할인코드 사용 가능하게 (선택사항) ### 7. 테스트 - test/controllers/discount_codes_controller_test.rb (validate 엔드포인트) - test/integration/payments_discount_test.rb (결제 + 할인코드 통합) ## ⚠️ 의존성 - model-dev가 DiscountCode 모델과 ValidationService를 완료해야 함 - 먼저 `bin/rails db:migrate` 실행하여 테이블 확인 - 모델/서비스가 없으면 컨트롤러/뷰/JS부터 작성 ## 파일 담당 범위 - app/controllers/discount_codes_controller.rb (신규) - app/javascript/controllers/discount_code_controller.js (신규) - app/controllers/payments_controller.rb (수정) - app/views/payments/ 관련 뷰 (수정) - config/routes.rb (수정 - discount_codes/validate 추가) - test/controllers/discount_codes_controller_test.rb (신규) - test/integration/payments_discount_test.rb (신규) ## 완료 기준 - 결제 페이지에서 할인코드 입력/검증 가능 - 유효한 코드 입력 시 할인 금액 표시 - 결제 완료 시 할인코드 사용 기록 - 테스트 통과
Consumer 연동 가이드 + 공통 OmniAuth Strategy
## 개요 외부 서비스(커리어, 채용, 교육)가 9WAY OAuth2 Provider에 쉽게 연동할 수 있도록 공통 OmniAuth Strategy와 연동 가이드를 제공한다. ## 작업 내용 - 9WAY용 OmniAuth strategy 작성 (omniauth-9way) - Consumer 서비스 boilerplate (Rails 기준) - 연동 가이드 문서 작성: - OAuth Application 등록 방법 - Authorization Code 흐름 설명 - API 호출 예시 (진단 결과 조회) - 사용자 식별 (provider_uid 매핑) ## 완료 기준 - [ ] OmniAuth strategy로 Consumer 앱에서 9WAY 로그인 성공 - [ ] API 토큰으로 진단 결과 조회 성공 - [ ] 연동 가이드 문서 완성
N+1 제거 + upsert_all + includes(:translations)
## 목표 일괄 제출된 응답을 효율적으로 저장. 쿼리 90개 → 1-2개로 감소. ## 변경 파일 - `app/services/diagnosis/session_manager.rb` — save_responses_bulk 메서드 추가 (upsert_all) - `app/controllers/diagnosis_sessions_controller.rb` — SurveyQuestion.find × N → where(id:).index_by - `app/controllers/diagnosis_sessions_controller.rb` — load_questions에 `.includes(:translations)` 추가 - submit_part2_round에도 동일 N+1 제거 적용 ## 구현 핵심 ```ruby # session_manager.rb def save_responses_bulk(session, responses_data) records = responses_data.map { |d| { diagnosis_session_id: session.id, ... } } DiagnosisResponse.upsert_all(records, unique_by: [:diagnosis_session_id, :survey_question_id]) end ``` ## 완료 기준 - 기존 테스트 통과 - DB 로그에서 최종 제출 시 쿼리 2개 이하 확인 - upsert_all 시 created_at 덮어쓰기 방지 확인 ## 주의사항 - upsert_all은 AR validation 스킵 → DB NOT NULL 제약으로 보장 - unique index `idx_responses_session_question` 활용 ## 의존성 PR #2 이후 (일괄 제출 구조에 맞춰 구현)
Big5Description 쿼리 캐싱
## 문제 `Big5ResultFormatter#build_description_cache`가 매 요청마다 `Big5Description.where(locale:)` 실행. 결과 페이지 자체는 Rails.cache로 캐싱되나, 캐시 미스 시 포맷팅 과정의 DB 쿼리는 캐싱 없음. ## 변경 ```ruby def build_description_cache(locale) @big5_cache = Rails.cache.fetch("big5:descriptions:#{locale}", expires_in: 1.day) do Big5Description.where(locale: locale) .each_with_object({}) do |desc, hash| hash[[desc.description_type, desc.key, desc.field_type]] = desc.content end end @name_suffix = locale == "ko" ? "ko" : locale.to_s end ``` ## 완료 기준 - [ ] Big5Description 쿼리가 locale별 1일 캐싱 - [ ] 캐시 미스 시에만 DB 쿼리 발생 - [ ] bin/ci 통과
[Phase 2] PR 2-2: Blog Admin CRUD
## 개요 Admin에서 블로그 포스트/카테고리 관리 CRUD ## 생성할 파일 - `app/controllers/admin/blog_posts_controller.rb` - `app/controllers/admin/blog_categories_controller.rb` - `app/views/admin/blog_posts/` (index, show, new, edit, _form) - `app/views/admin/blog_categories/` (index, _form) - 테스트 2개 ## 수정할 파일 - `config/routes.rb` — admin 내 blog 리소스 - `app/views/layouts/admin.html.erb` — 사이드바 메뉴 ## 완료 기준 - Admin 카테고리 CRUD - Admin 포스트 CRUD (draft/published/archived) - 이미지 업로드 (featured_image) - 12개 기본 카테고리 시드 ## 의존성 PR 2-1 (Blog 모델)
PR 7-2: 워크숍 Toss Payments 결제 연동
워크숍 신청 Toss Payments 결제 연동. TDD 기반. ## 담당 파일 - app/services/expert/application_service.rb (신규) - app/controllers/study/workshop_payments_controller.rb (신규) - app/views/study/workshop_payments/ (신규) - config/routes.rb (edu subdomain에 workshop payments 추가) - test/services/expert/application_service_test.rb (신규) - test/controllers/study/workshop_payments_controller_test.rb (신규) ## 완료 기준 - 워크숍 신청 + Toss Payments 결제 - 결제 완료 → 신청 상태 approved로 변경 - 기존 결제 서비스 패턴 참고 ## 주의사항 - 기존 payments/ 서비스 코드를 먼저 읽고 패턴 파악 - routes.rb edu subdomain 블록 내에만 추가 - developer-2가 study/payments 추가하므로 workshop_payments로 분리
[함께보기 PR3] 컨트롤러 + 라우트: CRUD, 참여, 나가기
## 설명 ViewGroupsController 및 라우팅 구현. ## 라우트 - resources :view_groups (index, show, new, create, destroy) - GET/POST /:locale/view_groups/join/:invite_code (참여) - DELETE /:locale/view_groups/:id/leave (나가기) - GET /:locale/view_groups/:id/team_strength (팀강점 데이터) ## 인가 매트릭스 | 액션 | 비로그인 | 일반 사용자 | 그룹 멤버 | 그룹 소유자 | |------|---------|-----------|----------|-----------| | index | redirect login | 자기 그룹만 | 자기 그룹만 | 자기 그룹만 | | show | redirect login | X | O | O | | create | redirect login | O | - | - | | destroy | redirect login | X | X | O | | join | redirect login | O (코드 유효 + report_purchased 진단 필요) | 이미 멤버 안내 | 이미 소유자 안내 | | leave | redirect login | X | O | X (소유자 불가, 그룹 삭제로 안내) | | team_strength | redirect login | X | O | O | ## 비즈니스 규칙 - 참여 시 사용자가 diagnosis_session 선택 (report_purchased: true인 것만 목록 표시) - 무료 검사(report_purchased: false)로는 참여 불가 - 소유자는 나가기 불가 → "그룹을 삭제하세요" 안내 - 소유자 그룹 삭제 시 dependent: :destroy로 멤버 전부 제거 ## 완료 기준 - [ ] 컨트롤러 테스트 통과 (CRUD, join, leave, 권한) - [ ] 비로그인 → 로그인 → return_to join URL 리다이렉트 정상 - [ ] 진단 미구매 사용자 참여 차단 + 안내 메시지 ## 의존성 - PR #1, #2 완료 후 진행
Frontend - 개요탭 WAY×DNA 강점 매트릭스 표
## 목표 팀 강점 분석 개요 탭에 WAY×DNA 강점 매트릭스 표를 추가한다. ## 변경 파일 - `app/views/workspaces/team_strengths/_overview.html.erb` - `config/locales/ko.yml` (및 en, zh, vi) ## 표 구조 - **열**: 9 WAY (발상, 탐색, 평가, 연결, 조율, 촉진, 추진, 실행, 완결) - 각 WAY 아래 3개 DNA 서브행 - **행**: 멤버별 1행 - **셀**: Top 1-5 → ● (진한 색), Top 6-10 → ◐ (연한 색), 없으면 빈칸 - **하단 요약행**: DNA별 팀 가중치 카운트 (Top 1-5 = 1개, Top 6-10 = 0.5개) ## UI 요구사항 - 기존 "팀 WAY 분포" 섹션 아래에 배치 - 가로 스크롤 지원 (overflow-x-auto) - 첫 열(멤버 이름) 고정 고려 (sticky) - 반응형: 모바일에서도 가로 스크롤로 사용 가능 ## 완료 기준 - [ ] 9 WAY × 27 DNA × N 멤버 매트릭스 표가 정상 렌더링됨 - [ ] Top 1-5 / Top 6-10 시각 구분이 명확함 - [ ] 하단 요약행에 DNA별 가중치 카운트 표시 - [ ] 점수는 표시하지 않음
PDF 리포트 재설계 - DNA 차별성 Top5 (10p)
## 섹션 4: DNA 차별성 5개 설명 (각 2페이지 × 5 = 10페이지) 기존 `_dna_detail.html.erb` 디자인 정제. ### 각 DNA 2페이지 구성 **Page A:** - 순위 + DNA 이름 + 한줄 설명 - Definition (정의) - 강점 특징 (development) - 활용 방법 (use) **Page B:** - 동기부여 욕구 (desire) - Positive / Negative 행동 (2컬럼) - DNA 조합 (Top5 내 다른 DNA와의 시너지) ### 디자인 원칙 - detail-box 스타일 → 구분선 + 들여쓰기 기반 텍스트로 전환 - green/red 박스 → 최소한의 색상 힌트 (텍스트 컬러 정도) - combo-box → 간결한 리스트 형태 - 전문 리포트 느낌의 타이포그래피 ### 완료 기준 - 기존 dna_detail 파트셜 리팩토링 - Top5 각 2페이지, 총 10페이지 - 데이터 구조 변경 없음 (ResultFormatter 그대로 활용)
강점 브랜딩: 컨트롤러 API 엔드포인트
## 목표 브랜딩 수정 위자드에서 사용할 API 엔드포인트를 구현합니다. ## 엔드포인트 설계 1. **GET /diagnoses/:id/branding/edit** - 위자드 모달 렌더링 - Top 5 DNA, Top 3 WAY 데이터 포함 - 기존 커스텀 브랜딩이 있으면 선택 상태 복원 2. **GET /diagnoses/:id/branding/modifiers** - 선택한 DNA의 수식어 목록 반환 - params: dna_keys[] (배열) - Turbo Frame 또는 JSON 응답 3. **GET /diagnoses/:id/branding/aspirations** - 선택한 WAY의 되고 싶은 모습 목록 반환 - params: way_key - Turbo Frame 또는 JSON 응답 4. **POST /diagnoses/:id/branding/generate** - AI 브랜딩 문장 생성 요청 - params: selected_dna_keys[], modifiers{}, selected_way_key, aspiration - JSON 응답: { sentence: "AI 생성 문장" } 5. **PATCH /diagnoses/:id/branding** - 최종 브랜딩 저장 - custom_branding JSONB 업데이트 - 성공 시 요약 탭으로 리다이렉트 ## 라우팅 ```ruby resources :diagnoses, only: [] do resource :branding, only: [:edit, :update], controller: 'diagnoses/brandings' do get :modifiers get :aspirations post :generate end end ``` ## 완료 기준 - [ ] 5개 엔드포인트 동작 - [ ] 권한 체크 (본인 진단만 수정 가능) - [ ] Strong parameters 설정 - [ ] 에러 핸들링 (AI 실패 시 fallback) - [ ] 테스트 작성
사용자 관리
https://9way.org/admin 사용자 관리에서, 이름 / 이메일 / 전화번호 / 역할 / 소속 워크스페이스 / 생성일 / 진단 역할은 누르면 수정 가능하게 목록 상자로 진단은 누르면 해당 사용자가 진단한 목록들 볼 수 있게
사용자 관리 기능 테스트 작성
사용자 관리 기능의 테스트를 작성합니다. ## 테스트 범위 ### 1. 컨트롤러 테스트 (test/controllers/admin/users_controller_test.rb) - index 액션: 전화번호 검색이 정상 동작하는지 - index 액션: 워크스페이스, 진단 수가 올바르게 표시되는지 - update_role 액션: 역할 변경이 정상 동작하는지 - update_role 액션: 권한 없는 사용자가 접근 시 거부되는지 ### 2. 통합 테스트 (test/integration/admin/users_test.rb) - admin이 아닌 사용자가 접근 시 리다이렉트 - 인덱스 페이지에 전화번호, 워크스페이스, 진단 수가 표시되는지 - 역할 변경 플로우 ## 참고사항 - 기존 테스트 파일이 있으면 확장, 없으면 새로 생성 - fixtures 확인 후 적절한 테스트 데이터 사용 - Rails Minitest 사용 (RSpec 아님) - 대시보드에 테스트 결과 기록할 것 ## 의존성 - impl-dev의 구현이 완료된 후 작업 시작
doorkeeper 설치 + DB 마이그레이션 + 설정 + 인증 통합
## 작업 내용 1. Gemfile에 `gem 'doorkeeper'` 추가 및 bundle install 2. `rails generate doorkeeper:install` 실행 3. doorkeeper 마이그레이션 생성 — **UUID PK 필수** (기존 DB 패턴: pgcrypto, gen_random_uuid) 4. `rails db:migrate` 실행 5. `config/initializers/doorkeeper.rb` 설정: - `resource_owner_authenticator`: 기존 authentication.rb의 세션 기반 인증 활용 - `admin_authenticator`: 기존 admin 권한 체크 활용 - `grant_flows`: ['authorization_code', 'client_credentials'] - `access_token_expires_in`: 2.hours - `use_refresh_token`: true 6. `config/routes.rb`에 `use_doorkeeper` 추가 (기존 라우트와 충돌 없도록) 7. User 모델에 doorkeeper 관계 추가 8. 기존 인증(Cookie Session, OmniAuth, Magic Link)이 정상 동작하는지 확인 ## 참고 파일 - `app/controllers/concerns/authentication.rb` — 기존 인증 로직 - `app/controllers/concerns/authorization.rb` — 어드민 권한 - `app/models/user.rb` — User 모델 - `app/models/session.rb` — Session 모델 - `config/initializers/omniauth.rb` — OmniAuth 설정 - `config/routes.rb` — 라우트 ## 완료 기준 - doorkeeper 테이블 생성 완료 (UUID PK) - /oauth/authorize, /oauth/token 엔드포인트 접근 가능 - 기존 웹 로그인 정상 동작 - doorkeeper initializer 설정 완료
API Base Controller + routes + /me endpoint
## 작업 내용 1. `Api::V1::BaseController` 생성 (`app/controllers/api/v1/base_controller.rb`): - `ActionController::API` 상속 - `before_action :doorkeeper_authorize!` - JSON 에러 핸들링 (401, 403, 404, 422) - `current_resource_owner` 헬퍼 (doorkeeper token → User) 2. `config/routes.rb`에 API namespace 추가: ```ruby namespace :api do namespace :v1 do resource :me, only: [:show], controller: 'profiles' resources :users, only: [] do resource :diagnoses, only: [:show], module: 'users' resource :strength_profile, only: [:show], module: 'users' end end end ``` 3. `Api::V1::ProfilesController` 생성 (`GET /api/v1/me`): - doorkeeper scope: `:profile` - 현재 토큰 소유자의 프로필 반환 - 응답: id, email, name, locale, role, email_verified_at, created_at 4. CORS 설정 (rack-cors gem 또는 수동): - API 경로만 CORS 허용 ## 참고 파일 - `app/models/user.rb` — User 모델 구조 확인 - `config/initializers/doorkeeper.rb` — 스코프 설정 확인 - `config/routes.rb` — 기존 라우트 확인 ## 완료 기준 - GET /api/v1/me가 Bearer 토큰으로 동작 - 토큰 없이 요청 시 401 응답 - JSON 에러 핸들링 동작
Big5 결과 페이지 캐싱
## 목표 완료된 진단 결과(불변 데이터)를 Rails.cache로 캐싱하여 반복 조회 시 DB 쿼리 0개. ## 변경 파일 - `app/controllers/diagnoses_controller.rb` — show 액션에 Rails.cache.fetch 적용 - `app/services/diagnosis/big5_result_formatter.rb` — find_quality_code 맵핑 캐시 ## 구현 ```ruby # diagnoses_controller.rb @result = Rails.cache.fetch(["big5_result", @session.id, I18n.locale], expires_in: 30.days) do Diagnosis::Big5ResultFormatter.new(@session).format end ``` ## 완료 기준 - 첫 조회 시 Formatter 실행, 이후 캐시 히트 (로그 확인) - 9Way, Engagement 결과에도 동일 패턴 적용 - expires_in: 30일 (진단 결과는 불변) ## 의존성 없음 (독립 작업 가능)
response_timeout 120초 → 60초 축소
## 문제 Kamal Proxy의 response_timeout이 120초로, 느린 요청이 Puma 스레드를 2분간 점유 가능. 10개 동시 스레드에서 1개가 120s 묶이면 10% 용량 손실. ## 변경 ```yaml # config/deploy.yml proxy: ssl: true host: 9way.org response_timeout: 60 # 120 → 60 ``` 60초로 설정하는 이유: PDF 생성(grover gem)이 10-30초 소요 가능하므로 30초는 위험. ## 완료 기준 - [ ] deploy.yml response_timeout 60으로 변경 - [ ] kamal deploy 후 정상 동작 확인 - [ ] 진단 완료 + 결과 페이지 + PDF 생성 정상 작동
[Phase 2] PR 2-3: TipTap 에디터 통합
## 개요 블로그 글 작성을 위한 TipTap 리치 텍스트 에디터 Stimulus 통합 ## 생성할 파일 - `app/javascript/controllers/tiptap_controller.js` - `app/javascript/controllers/image_upload_controller.js` ## 수정할 파일 - `config/importmap.rb` — TipTap 패키지 pin - `app/views/admin/blog_posts/_form.html.erb` — TipTap 적용 ## 완료 기준 - TipTap 에디터 렌더링 + 기본 포맷팅 (볼드, 이탤릭, 헤딩, 리스트, 링크) - 에디터 내 이미지 삽입 (ActiveStorage Direct Upload) - hidden field로 HTML 서버 전송 ## 리스크 - TipTap importmap 호환성 — CDN 빌드 또는 jsbundling 필요할 수 있음 ## 의존성 PR 2-2 (Blog Admin CRUD)
PR 7-3: 과제 제출 + 인증서 PDF 구현
과제 제출/리뷰 + 수료/자격 인증서 PDF 생성. TDD 기반. ## 담당 파일 - db/migrate/xxx_create_study_assignments.rb (신규) - db/migrate/xxx_create_study_certificates.rb (신규) - app/models/study/assignment.rb (신규) - app/models/study/certificate.rb (신규) - app/controllers/study/assignments_controller.rb (신규) - app/controllers/study/certificates_controller.rb (신규) - app/controllers/admin/study_assignments_controller.rb (신규 - Admin 과제 검토) - app/services/study/certificate_generator.rb (신규) - app/views/study/assignments/ (new, show) - app/views/study/certificates/show.html.erb - app/views/admin/study_assignments/ (index, show) - config/routes.rb (edu subdomain + admin namespace) - test/ (모델 2개, 컨트롤러 3개, 서비스 1개) ## 스키마 ### study_assignments - enrollment_id(FK), user_id(FK), course_id(FK) - status: enum (pending/reviewed/approved/rejected) - feedback:text - has_one_attached :file (10MB) ### study_certificates - user_id(FK), course_id(FK), enrollment_id(FK) - certificate_type: enum (completion/qualification) - certificate_number: string (unique, 9W-SC-YYYY-NNNNN) - issued_at:datetime ## 비즈니스 로직 - 80% 수강 완료 → 수료 인증서 자동 발급 - 과제 승인 → 코치 자격 인증서 - PDF: 기존 PdfGenerator 패턴 참고 (Grover) - 인증서 번호: 9W-SC-YYYY-NNNNN 형식 자동 생성 ## 주의사항 - Study 모듈 table_name_prefix "study_", UUID PK - routes.rb: edu subdomain + admin namespace 두 곳에 추가 - 다른 에이전트의 routes 영역 수정 금지
[함께보기 PR4] 뷰: 목록, 생성, 상세, QR코드, 참여, 팀강점 매트릭스
## 설명 함께 보기 기능의 전체 뷰 구현. ## 뷰 목록 - index: 내가 소유/참여한 그룹 목록 카드 - new: 그룹 이름 입력 폼 - show: QR코드 + 초대링크 + 멤버 목록 + 팀강점 매트릭스 탭 - join: QR 스캔 후 참여 확인 페이지 (진단 세션 선택) - team_strength: 팀강점 매트릭스 (기존 workspace team_strength와 동일 UI) ## UI 요구사항 - 반응형 (모바일 QR 스캔이 주요 시나리오) - QR코드: QrCodeHelper#qr_code_svg 활용 - 빈 상태 메시지 ("아직 멤버가 없습니다", "아직 그룹이 없습니다") - 참여 시 report_purchased: true인 진단 세션 선택 UI - 팀강점 차트는 기존 team_strengths 뷰와 동일한 데이터 구조로 렌더링 ## 완료 기준 - [ ] 모든 뷰 정상 렌더링 - [ ] QR코드에 초대 URL 정상 인코딩 - [ ] 모바일 반응형 동작 - [ ] 빈 상태 처리 - [ ] 진단 세션 선택 UI 동작 ## 의존성 - PR #3 완료 후 진행
멤버 목록/카드 뷰에 결과 보기 버튼 추가
## 목표 워크스페이스 멤버 목록 페이지에서 admin이 멤버 결과를 바로 볼 수 있는 버튼을 추가한다. ## 현재 문제 - 목록형(_member_row.html.erb): 보기 버튼이 전혀 없음 (삭제 버튼만 존재) - 카드형(_member_card.html.erb): 자기 자신은 "나" 텍스트만 표시, 보기 링크 없음 ## 수정 사항 - `_member_row.html.erb`: admin일 때 보기 버튼 추가 - `_member_card.html.erb`: 자기 자신도 보기 링크 추가, admin이면 타인 결과 보기 링크 표시 - N+1 쿼리 방지를 위한 diagnosis_sessions 프리로드 검토 ## 완료 기준 - [ ] 목록형에서 admin에게 보기 버튼이 표시됨 - [ ] 카드형에서 자기 자신의 결과 보기 링크가 표시됨 - [ ] 카드형에서 admin이 타인 결과 보기 링크를 클릭하면 정상 동작 - [ ] 완료된 진단이 없는 멤버는 보기 버튼 비활성화/숨김
PDF 리포트 재설계 - 강점 프로파일 (2p)
## 섹션 5: 강점 프로파일 (2페이지) 기존 `_domain_detail.html.erb` + `_way_map.html.erb` 통합/재구성. ### Page A: Domain 비율 - 3개 강점 영역(사고/관계/행동) 비율 바차트 - 각 영역 설명 텍스트 - 하위 WAY 분포 ### Page B: 9WAY 분포 - 9가지 역할 막대그래프 (점수 높은 순) - 1위/2위 WAY 하이라이트 - 전체 분포 시각화 ### 디자인 원칙 - 도메인 컬러는 차트에서만 사용 (파란/초록/노랑) - 텍스트 설명은 navy/slate만 - 불필요한 배경색 박스 제거 ### 완료 기준 - 새 파트셜 `_strength_profile.html.erb` 생성 또는 기존 파트셜 통합 - 2페이지에 Domain + WAY 전체 분포 표현
강점 브랜딩: AI 브랜딩 문장 생성 서비스
## 목표 사용자 선택(DNA 수식어 + WAY 되고 싶은 모습)을 바탕으로 AI가 브랜딩 문장을 다듬어주는 서비스를 구현합니다. ## 서비스 설계 - **클래스**: `Diagnosis::BrandingGenerator` - **참고**: 기존 `Diagnosis::AiInsightGenerator` 패턴 활용 - **AI 클라이언트**: `Ai::Client` (Claude Sonnet 4.6) ## 입력 ```ruby { selected_dna: [{ key: "creativity", name: "창의력", modifier: "혁신적인" }, ...], selected_way: { key: "ideation", name: "발상", aspiration: "새로운 가능성을 여는 사람" }, user_name: "홍길동", locale: "ko" } ``` ## 출력 ```ruby { sentence: "혁신적이고 유연한 발상력으로 새로운 가능성을 여는 사람, 홍길동", alternatives: ["대안 문장 1", "대안 문장 2"] # 선택 옵션 } ``` ## 프롬프트 설계 - 수식어들을 자연스러운 한국어/영어 문장으로 연결 - 되고 싶은 모습을 문장 뒤에 배치 - 20자 이내의 간결한 문장 지향 - locale별 자연스러운 문체 ## 완료 기준 - [ ] AI 문장 생성 정상 동작 - [ ] 3개 대안 문장 제공 - [ ] 다국어 지원 (ko, en, zh, vi) - [ ] AI 실패 시 수식어+되고 싶은 모습 단순 조합으로 fallback - [ ] 응답 시간 3초 이내 - [ ] 테스트 작성 (AI mock)
문항 관리
문항관리에서 언어를 누르면 해당 질문이 나오는거지, 전체 메뉴 언어를 선택하는게 아니야. 영어를 선택하면 메뉴 전체가 영어로 바뀌네;;;
Mailer 생성 및 이메일 발송 연동
## 목표 이메일 초대 생성 시 실제 이메일이 발송되도록 구현 ## 구현 내용 1. WorkspaceInvitationMailer 생성 (app/mailers/workspace_invitation_mailer.rb) - invitation_email(invitation) 메서드 - 초대 링크 포함 (public_invitation_url) - 워크스페이스 이름, 역할 정보 포함 2. 이메일 템플릿 작성 - app/views/workspace_invitation_mailer/invitation_email.html.erb - app/views/workspace_invitation_mailer/invitation_email.text.erb - 기존 mailer.html.erb 레이아웃 활용 - 깔끔하고 전문적인 디자인 (기존 MagicLinkMailer 스타일 참고) 3. InvitationService.create! 수정 (app/services/workspaces/invitation_service.rb) - target_email이 있으면 WorkspaceInvitationMailer.invitation_email(invitation).deliver_later 호출 - 이메일 발송 실패해도 초대 생성은 유지 (rescue) 4. 이메일 재발송 기능 - InvitationsController에 resend 액션 추가 - config/routes.rb에 member route 추가: post :resend - _invitation.html.erb에 재발송 버튼 추가 5. 테스트 작성 - test/mailers/workspace_invitation_mailer_test.rb - InvitationService 테스트에 이메일 발송 테스트 추가 ## 담당 파일 (이 파일만 수정) - NEW: app/mailers/workspace_invitation_mailer.rb - NEW: app/views/workspace_invitation_mailer/invitation_email.html.erb - NEW: app/views/workspace_invitation_mailer/invitation_email.text.erb - MODIFY: app/services/workspaces/invitation_service.rb - MODIFY: config/routes.rb (resend route만 추가) - NEW: test/mailers/workspace_invitation_mailer_test.rb - MODIFY: test/services/workspaces/invitation_service_test.rb ## 완료 기준 - target_email 포함 초대 생성 시 이메일 발송됨 - letter_opener_web에서 발송된 메일 확인 가능 - 재발송 버튼으로 이메일 재발송 가능 - 테스트 통과
OAuth Application 어드민 관리 UI
## 작업 내용 1. 어드민 영역에 OAuth Application 관리 컨트롤러 생성 - `Admin::OauthApplicationsController` (CRUD) 2. 어드민 라우트 추가 (`admin/oauth_applications`) 3. 뷰 생성: - index: 등록된 OAuth Application 목록 (이름, client_id, redirect_uri, scopes) - new/edit: Application 등록/수정 폼 - show: Application 상세 (client_id, client_secret 표시, secret은 생성 시에만 표시) 4. 기존 어드민 레이아웃/스타일(Tailwind) 활용 5. 기존 어드민 사이드바에 메뉴 추가 ## 참고 파일 - `app/controllers/admin/` — 기존 어드민 컨트롤러 패턴 참고 - `app/views/admin/` — 기존 어드민 뷰 패턴 참고 - `app/views/layouts/admin.html.erb` — 어드민 레이아웃 - `app/helpers/icon_helper.rb` — 아이콘 헬퍼 ## 의존성 - backend-dev의 doorkeeper 설치 완료 후 작업 가능 - Doorkeeper::Application 모델 사용 ## 완료 기준 - 어드민에서 OAuth Application 생성/수정/삭제 가능 - client_id/secret 발급 및 표시 - scopes, redirect_uri 설정 가능
diagnoses + strength_profile API endpoints
## 작업 내용 1. 먼저 기존 모델 구조 파악: - `app/models/diagnosis_session.rb` — 진단 세션 모델 확인 - `app/models/` — 강점 관련 모델 확인 (strength, diagnosis_result 등) - 기존 뷰에서 진단 결과 어떻게 표시하는지 확인 2. `Api::V1::Users::DiagnosesController` 생성 (`GET /api/v1/users/:user_id/diagnoses`): - doorkeeper scope: `:diagnoses` - Client Credentials일 경우 params[:user_id]로 사용자 조회 - Authorization Code일 경우 현재 토큰 소유자만 허용 (또는 user_id 일치 확인) - 응답: 진단 이력 목록 (id, completed_at, status 등) 3. `Api::V1::Users::StrengthProfilesController` 생성 (`GET /api/v1/users/:user_id/strength_profile`): - doorkeeper scope: `:strengths` - 가장 최근 완료된 진단의 강점 프로필 반환 - 응답: top strengths, 점수, 완료 일시 등 4. JSON 직렬화: - jbuilder 사용 (프로젝트에 이미 있을 경우) 또는 `render json:` + `as_json` - 민감 정보 제외 (password_digest 등) ## 의존성 - backend-dev의 BaseController + routes 완료 후 작업 ## 참고 - `Api::V1::BaseController`를 상속 - doorkeeper_authorize! 는 BaseController에서 처리됨 - scope별 접근 제어: `before_action -> { doorkeeper_authorize! :diagnoses }` ## 완료 기준 - GET /api/v1/users/:id/diagnoses 동작 - GET /api/v1/users/:id/strength_profile 동작 - scope 미충족 시 403 응답 - Client Credentials + Authorization Code 모두 지원
Puma 스레드 5 → 7 증가
## 문제 현재 2 workers × 5 threads = 10 동시 슬롯. show 캐싱 후 대부분 I/O bound 작업이므로 스레드 증가가 효과적. ## 변경 ```yaml # config/deploy.yml env: clear: RAILS_MAX_THREADS: 7 # 5 → 7 ``` database.yml의 pool도 RAILS_MAX_THREADS를 참조하므로 자동 연동. 2 × 7 = 14 슬롯 (40% 동시성 증가). 스레드당 스택 ~1MB이므로 메모리 영향 미미 (~14MB 추가). ## 완료 기준 - [ ] RAILS_MAX_THREADS=7 설정 - [ ] DB pool이 7로 연동 확인 - [ ] 메모리 사용량 200MB 이내 증가 확인 - [ ] bin/ci 통과
[Phase 3] PR 3-1: 블로그 공개 페이지
## 개요 blog.9way.co.kr 공개 페이지 (목록, 상세, 카테고리, 검색) ## 생성할 파일 - `app/controllers/blog/posts_controller.rb` - `app/controllers/blog/categories_controller.rb` - `app/views/blog/posts/` (index, show, _post_card) - `app/views/blog/categories/show.html.erb` - `app/views/blog/shared/_sidebar.html.erb` ## 라우팅 ```ruby constraints subdomain: "blog" do scope module: :blog do root "posts#index" resources :posts, only: [:index, :show], param: :slug resources :categories, only: [:show], param: :slug get "search", to: "posts#search" end end ``` ## 완료 기준 - 포스트 목록 (12/page, 페이지네이션) - 포스트 상세 (조회수 증가) - 카테고리 필터, 검색 - 비로그인 열람 가능 ## 의존성 PR 2-1 (Blog 모델)
[함께보기 PR5] 네비게이션: 사이드바 + 하단탭 + i18n
## 설명 사이드바와 하단 탭에 "함께 보기" 메뉴 추가 및 다국어 지원. ## 수정 파일 - app/views/shared/_sidebar.html.erb — 커리어테크 아래에 "함께 보기" 추가 - app/views/shared/_bottom_tabs.html.erb — 모바일 하단 탭 추가 - app/views/shared/_sidebar_nav_item.html.erb — 아이콘 SVG 추가 - config/locales/{ko,en,zh,vi}.yml — nav.sidebar.together 등 번역 키 ## 규칙 - 전 locale에서 표시 (커리어테크와 달리 ko 제한 없음) - 로그인 사용자에게만 표시 - 현재 페이지 active 상태 표시 ## 완료 기준 - [ ] 사이드바에 "함께 보기" 메뉴 표시 (커리어테크 아래) - [ ] 하단 탭에 메뉴 추가 - [ ] 4개 locale(ko, en, zh, vi) 정상 표시 - [ ] 비로그인 시 메뉴 미표시 ## 의존성 - PR #4 완료 후 진행
[조직문화 PR5] 뷰: 설문 관리 + 결과 대시보드 UI
## 설명 워크스페이스 대시보드 내 조직문화 진단 관리 뷰. ### 페이지 1. **설문 목록** (index): 분기 카드 (연도/분기, 상태 배지, 응답 수, QR+링크 복사 버튼) 2. **설문 생성** (new): 연도/분기 드롭다운 3. **결과 대시보드** (show): - overall 점수 (큰 숫자) - 몰입 유형 비율: 적극적 몰입 / 소극적 몰입 / 적극적 비몰입 (도넛 또는 바 차트) - 3요인 분석: 레이더 차트 + 강약점 해석 텍스트 - 9요인 분석: 막대그래프 + 강약점 해석 텍스트 - 전체 / 그룹별 탭 전환 - 3명 이하 그룹: "측정 불가" 표시 ### 재사용 - QrCodeHelper.qr_code_svg - clipboard_controller.js, qr_code_controller.js - 기존 워크스페이스 레이아웃/사이드바 ## 완료 기준 - [ ] 설문 목록/생성 페이지 렌더링 - [ ] QR코드 표시 + 링크 복사 동작 - [ ] 결과 대시보드 전체/그룹별 전환 - [ ] 차트 데이터 바인딩 - [ ] 3명 이하 그룹 "측정 불가" 표시 ## 의존성 PR3 완료 후
유료 콘텐츠 PDF 파트셜 + 오케스트레이터 연동
## 목표 PDF에 유료 콘텐츠 페이지 추가 (report_purchased? 조건부 렌더링) ## 상세 작업 ### A. ResultFormatter 확인 및 데이터 매핑 - `app/services/diagnosis/result_formatter.rb` 확인 - `result[:summary_contents]`에 이미 field_type별 데이터가 있음: - student: job, course, study - college: job, resume, grade - adult: job, work, competency, team - leader: job, leadership, management, decision - StrengthDescription에서 field_type별 content 조회됨 (dna_key 기준) - 필요시 ResultFormatter에 premium_sections 메서드 추가 ### B. PDF 파트셜 생성: `app/views/diagnoses/pdf/_premium_content.html.erb` - `result[:summary_contents]`를 섹션별로 그룹핑하여 렌더링 - 각 field_type별 페이지: 1. **job 섹션**: 추천 직무 목록 + 각 직무별 상세 (별도 페이지) - 직무 상세 필드: 하는 일, 주요 과업, 직업 트렌드, 필요 지식, 필요 스킬, 필요 경험, 준비할 것 - content에서 `\n\n`으로 분리, 각 문단의 첫 줄이 필드 제목 패턴인지 판별 2. **기타 섹션** (course/study/resume/work 등): bullet 리스트로 표시 - 섹션 제목 + DNA명 + 콘텐츠 리스트 - 기존 PDF 스타일 클래스 사용 (page, section-header, card, text-* 등) ### C. 오케스트레이터 연동: `app/views/diagnoses/pdf.html.erb` - DNA detail 루프 뒤에 premium content 추가 - 조건: `<% if session.report_purchased? %>` - `<%= render "diagnoses/pdf/premium_content", session: session, result: result, page_num: (다음 페이지 번호) %>` ## 사용 가능한 데이터 - `result[:summary_contents]` → [{field_type: "job", content: "..."}, ...] - `result[:top_dna]` → 상위 5개 DNA 정보 - `session.report_purchased?` → payment_id.present? && payment.completed? - StrengthDescription.content_for(dna_key:, field_type:, locale:, target_slug:) ## 주의사항 - **수정 파일**: `_premium_content.html.erb`(신규), `pdf.html.erb`(추가만) - `_domain_detail.html.erb`는 건드리지 마세요 (다른 팀원 담당) - `result_formatter.rb`는 읽기만, 수정이 필요하면 최소한으로 - PDF CSS는 `app/views/layouts/pdf.html.erb`에 정의됨 - 읽어서 기존 클래스 활용 ## 완료 기준 - report_purchased?인 경우에만 유료 페이지가 PDF에 포함됨 - 직무별 상세가 개별 페이지로 렌더링됨 - 기타 섹션이 bullet 리스트로 정상 표시됨 - 미결제 시 유료 페이지가 PDF에 포함되지 않음
PDF 리포트 재설계 - 탁월한 역할 (2p)
## 섹션 6: 탁월한 역할 (2페이지) 기존 `_way_sections.html.erb` + `_premium_content.html.erb` 통합. ### Page A: 1위 WAY × DNA 조합 상세 - 강점 슬로건 - WAY×DNA 조합 설명 (sections 1-4) - 이 역할에서 탁월해지는 이유 ### Page B: target별 콘텐츠 - 직업/직무 추천 (job) - target에 따른 추가 콘텐츠 (adult: work/competency/team, student: course/study 등) - SUMMARY_FIELD_MAP 기반 동적 렌더링 ### 디자인 원칙 - highlight-card → 텍스트 위계로 대체 - detail-box-accent → 구분선 기반 - target별 콘텐츠가 없으면 Page B는 WAY 조합 sections 5-8로 채움 ### 완료 기준 - 기존 way_sections + premium_content 통합 - report_purchased? 조건 유지 (유료 콘텐츠 보호) - 2페이지에 깔끔하게 수렴
강점 브랜딩: 요약 탭 UI 연동 + PDF 반영
## 목표 커스텀 브랜딩 결과를 진단 결과 요약 탭과 PDF 리포트에 반영합니다. ## 작업 내용 ### 1. 요약 탭 (_tab_summary.html.erb) - 기존 브랜딩 섹션에 "수정" 버튼 추가 - 커스텀 브랜딩이 있으면 AI 생성 문장 표시 - 없으면 기존 자동 생성 브랜딩 표시 - "수정" 클릭 → 위자드 모달 열기 - "초기화" 버튼 → 기본 브랜딩으로 복귀 ### 2. PDF 리포트 - `_executive_summary.html.erb` (P3 요약 페이지) - 커스텀 브랜딩이 있으면 해당 문장 사용 - `session.branding_sentence` 헬퍼 활용 - `_cover.html.erb` (P1 표지) - 표지의 브랜딩 문구도 커스텀 반영 ### 3. 공유 로직 - `DiagnosisSession#branding_sentence` 메서드 하나로 통일 - custom_branding 있으면 → ai_sentence 반환 - 없으면 → 기존 ResultFormatter 기반 자동 생성 ## 완료 기준 - [ ] 요약 탭에서 커스텀 브랜딩 표시 - [ ] "수정" 버튼으로 위자드 진입 - [ ] PDF에 커스텀 브랜딩 반영 - [ ] 초기화 기능 동작 - [ ] 기존 브랜딩 없는 세션 영향 없음
통계 관리
어드민에 통계 관리 메뉴를 만들어줘. http://localhost:3000/admin?locale=ko 여기는 구글애널리틱스나 믹스패널처럼 이 서비스의 통계가 나와서 관리할 수 있어야해.
벌크 이메일 초대 기능
## 목표 여러 이메일을 한 번에 등록하고 초대 발송하는 벌크 초대 기능 구현 ## 구현 내용 1. InvitationsController에 bulk_create 액션 추가 - POST /workspaces/:workspace_id/invitations/bulk_create - 여러 이메일을 쉼표/줄바꿈으로 구분하여 받음 - 각 이메일에 대해 초대 생성 + 이메일 발송 - 성공/실패 카운트 flash 메시지 2. InvitationService에 bulk_create! 메서드 추가 - emails 배열, workspace, params(role, expires, group 등), created_by - 이메일 유효성 검사 (형식, 중복, 이미 초대된 이메일) - 각 이메일에 대해 create! 호출 (개별 에러 처리) - 결과 반환: { success: [...], failed: [...] } 3. 벌크 입력 UI (index.html.erb의 Email Invite 탭 수정) - textarea로 다수 이메일 입력 (쉼표 또는 줄바꿈 구분) - 기존 단일 이메일 입력도 유지 - "벌크 초대" 토글/탭으로 전환 - 입력된 이메일 수 실시간 카운트 표시 (Stimulus) 4. Stimulus 컨트롤러 (bulk_invite_controller.js) - textarea 입력 시 이메일 수 카운트 - 이메일 유효성 클라이언트 검증 5. 라우트 추가 - config/routes.rb에 collection route: post :bulk_create 6. 테스트 작성 - test/controllers/workspaces/invitations_controller_bulk_test.rb - InvitationService bulk_create! 테스트 ## 담당 파일 (이 파일만 수정) - MODIFY: app/controllers/workspaces/invitations_controller.rb (bulk_create 액션 추가) - MODIFY: app/views/workspaces/invitations/index.html.erb (벌크 UI 추가) - MODIFY: config/routes.rb (bulk_create route만) - NEW: app/javascript/controllers/bulk_invite_controller.js - MODIFY: app/services/workspaces/invitation_service.rb (bulk_create! 메서드만 추가) - NEW: test/controllers/workspaces/invitations_controller_bulk_test.rb ## 주의사항 - mailer-dev가 InvitationService.create!에 이메일 발송을 추가하므로, bulk_create!는 기존 create!를 내부적으로 호출하면 됨 - config/locales는 수정하지 마세요 (status-dev가 담당) ## 완료 기준 - 여러 이메일을 한 번에 입력하여 초대 생성 가능 - 각 이메일에 개별적으로 초대 이메일 발송됨 - 유효하지 않은 이메일은 건너뛰고 결과 보고 - 테스트 통과
doorkeeper 통합 테스트
## 작업 내용 1. Authorization Code Grant 흐름 테스트: - OAuth Application 등록 → authorize 요청 → callback → token 교환 - access_token으로 보호된 리소스 접근 - 잘못된 token으로 401 응답 확인 2. Client Credentials Grant 테스트: - client_id/secret으로 직접 token 발급 - 서버 간 API 호출 시나리오 3. 기존 인증 회귀 테스트: - Google OAuth 로그인 정상 동작 - Kakao OAuth 로그인 정상 동작 - Magic Link 로그인 정상 동작 - 기존 세션 기반 인증 정상 동작 4. Refresh Token 테스트: - 만료된 access_token으로 refresh 요청 - 새 access_token 발급 확인 ## 참고 파일 - `test/integration/authentication_test.rb` — 기존 인증 테스트 패턴 참고 - `test/test_helper.rb` — 테스트 헬퍼 ## 의존성 - backend-dev의 doorkeeper 설치 완료 후 작업 가능 ## 완료 기준 - 모든 테스트 통과 (rails test) - Authorization Code, Client Credentials 흐름 검증 - 기존 인증 회귀 테스트 통과
Resource API 통합 테스트
## 작업 내용 `test/integration/api/v1/` 디렉토리에 통합 테스트 작성 1. **ProfilesController 테스트** (`profiles_test.rb`): - Bearer 토큰으로 GET /api/v1/me 성공 - 토큰 없이 요청 시 401 - profile scope 없는 토큰으로 요청 시 403 - 응답 JSON 구조 검증 2. **DiagnosesController 테스트** (`diagnoses_test.rb`): - Authorization Code 토큰으로 자신의 진단 이력 조회 - Client Credentials 토큰으로 특정 사용자 진단 조회 - diagnoses scope 없는 토큰으로 403 - 존재하지 않는 사용자 404 3. **StrengthProfilesController 테스트** (`strength_profiles_test.rb`): - 강점 프로필 조회 성공 - strengths scope 없는 토큰으로 403 - 진단 결과 없는 사용자 처리 4. **공통 테스트**: - CORS 헤더 확인 - 만료된 토큰으로 401 - 잘못된 형식 토큰으로 401 ## 의존성 - backend-dev + api-dev 완료 후 작업 ## 참고 - `test/integration/doorkeeper_oauth_test.rb` — 기존 doorkeeper 테스트 패턴 참고 - Minitest 사용, fixtures 기반 ## 완료 기준 - 모든 테스트 통과 - 전체 `rails test` 통과 (기존 payments 버그 제외)
Cloudflare CDN 설정
## 문제 정적 자산(JS, CSS, 이미지)이 Rails 서버에서 직접 서빙. 모든 요청이 서버에 도달. ## 변경 (코드 외 인프라 작업) 1. Cloudflare에 9way.org 도메인 등록 (무료 플랜) 2. DNS를 Cloudflare 네임서버로 변경 3. SSL: Full(Strict) 모드 설정 (Kamal Proxy Let's Encrypt와 공존) 4. 캐싱 규칙: `/assets/*` → Cache Everything, Edge TTL 30일 5. Page Rules: `/up` → Bypass Cache (health check) ## 리스크 - Cloudflare + Kamal Proxy 이중 SSL → Full(Strict) 모드 필수 (리다이렉트 루프 방지) ## 완료 기준 - [ ] 정적 자산 CDN 서빙 (cf-cache-status: HIT) - [ ] SSL 정상 작동 - [ ] 진단 플로우 정상 동작
[Phase 3] PR 3-2: 블로그 SEO + RSS
## 개요 블로그 SEO 최적화 및 RSS 피드 ## 생성할 파일 - `app/views/blog/posts/feed.rss.builder` - `app/views/blog/posts/_meta_tags.html.erb` ## 완료 기준 - OpenGraph 메타 태그 (title, description, image, url) - JSON-LD BlogPosting schema - RSS 피드 (/feed.rss) - canonical URL ## 의존성 PR 3-1 (블로그 공개 페이지)
[조직문화 PR6] 뷰: 공개 설문 응답 UI (비인증)
## 설명 비인증 사용자용 설문 응답 UI. layout: "focus" 사용. ### 화면 1. **그룹 선택**: 워크스페이스 그룹 목록 (카드/버튼 형태) 2. **안내 화면**: - "약 5분이 소요됩니다" - "3명 이하 그룹의 결과는 측정되지 않습니다" - "응답자의 정보는 기록되지 않습니다" - 심리적 안정감을 위한 디자인 3. **문항 응답**: 10문항 7점 리커트 척도 - 기존 _engagement_question.html.erb 패턴 재사용 - Stimulus: engagement_diagnosis_controller.js 참고 4. **완료 화면**: 감사 메시지 + 브라우저 닫기 안내 ### 특이사항 - 모바일 최적화 (QR 스캔이 주 진입점) - Turbo Stream으로 문항 전환 (기존 패턴) - 로그인 여부와 무관하게 동일 UI ## 완료 기준 - [ ] 전체 플로우 정상 렌더링 (그룹 선택 → 안내 → 문항 → 완료) - [ ] 모바일 반응형 - [ ] 7점 척도 인터랙션 - [ ] Turbo Stream 문항 전환 ## 의존성 PR4 완료 후
기존 팀 상위 DNA 섹션 정리
## 목표 개요 탭의 기존 "팀 상위 강점 DNA" 섹션을 카운팅 기반으로 정리한다. ## 변경 파일 - `app/views/workspaces/team_strengths/_overview.html.erb` ## 상세 내용 1. 현재 dna[:score]는 이미 가중치 카운트 (Top1-5=1.0, Top6-10=0.5) 2. 표시 형식 변경: "3.5" → 단위 명확화 또는 순위 배지로 개선 3. 점수 노출 제거, 시각적 강조로 대체 ## 완료 기준 - [ ] 점수 대신 가중치 카운트가 명확히 표시됨 - [ ] WAY×DNA 표와 시각적 일관성 유지
PDF 리포트 재설계 - AI 분석 (2p)
## 섹션 7: AI 분석 (2페이지, 있을 때만) 기존 `ai_pdf.html.erb`의 AI 분석을 메인 PDF에 통합. ### 조건 - `@session.json_data.dig('ai_insights', locale)` 존재할 때만 렌더링 - AI 분석이 없으면 이 섹션 전체 스킵 ### Page A: - comprehensive_insight (강점 종합 분석) - interest_insight (관심 영역 인사이트) ### Page B: - development_plan (강점 개발 방법) - encouragement (응원 메시지) ### 디자인 원칙 - 마크다운 → HTML 변환 (simple_format 또는 커스텀 renderer) - 텍스트 중심, 긴 글 읽기 편한 레이아웃 - 섹션 구분은 제목 + 구분선으로 ### 완료 기준 - 새 파트셜 `_ai_analysis.html.erb` 생성 - pdf.html.erb 오케스트레이터에서 조건부 렌더링 - 기존 ai_pdf.html.erb는 별도 유지 (download_ai_pdf 라우트)
개인의 결제 크레딧, 결제
프로필 메뉴에 내 정보 / 결제 / 크레딧 http://localhost:3000/ko/profile 이런 메뉴 만들어줘. 결제에는 영수증 발급 및 취소도 가능해야해. 취소는 상품별로 달라. 일단 리포트 결제는 취소 불가
멤버 승인 상태 표시 및 i18n
## 목표 이메일 초대 목록에서 해당 멤버의 수락(승인) 여부를 표시 ## 구현 내용 1. WorkspaceInvitation 모델에 승인 상태 확인 헬퍼 추가 - accepted? 메서드: target_email의 사용자가 해당 워크스페이스의 멤버인지 확인 - accepted_member 메서드: 수락한 멤버 객체 반환 - 로직: User.find_by(email: target_email) → workspace.workspace_members.exists?(user: user) 2. _invitation.html.erb 수정 - 승인 상태 배지 추가 - "Pending" (노란색 배지): 아직 수락하지 않음 - "Accepted" (초록색 배지): 멤버로 수락됨 - 수락한 경우 사용자 이름도 표시 - 상태에 따른 아이콘 표시 (체크마크 / 시계) 3. i18n 키 추가 (모든 로케일 파일) - config/locales/en.yml - config/locales/ko.yml - config/locales/zh.yml - config/locales/vi.yml 추가할 키: - invitations.status.pending: "대기 중" / "Pending" / "待处理" / "Đang chờ" - invitations.status.accepted: "수락됨" / "Accepted" / "已接受" / "Đã chấp nhận" - invitations.resend: "재발송" / "Resend" / "重新发送" / "Gửi lại" - invitations.bulk_invite: "벌크 초대" / "Bulk Invite" / "批量邀请" / "Mời hàng loạt" - invitations.bulk_placeholder: 적절한 안내 텍스트 - invitations.bulk_submit: "벌크 발송" / "Send Bulk" / "批量发送" / "Gửi hàng loạt" - invitations.flash.bulk_result: "N건 성공, M건 실패" - invitations.flash.resent: "초대 이메일을 재발송했습니다" 4. 테스트 작성 - test/models/workspace_invitation_test.rb에 accepted? 테스트 추가 ## 담당 파일 (이 파일만 수정) - MODIFY: app/models/workspace_invitation.rb - MODIFY: app/views/workspaces/invitations/_invitation.html.erb - MODIFY: config/locales/en.yml - MODIFY: config/locales/ko.yml - MODIFY: config/locales/zh.yml - MODIFY: config/locales/vi.yml - MODIFY: test/models/workspace_invitation_test.rb ## 완료 기준 - 이메일 초대 목록에서 각 초대의 수락 상태가 표시됨 - Pending/Accepted 배지가 올바르게 표시됨 - 4개 언어 i18n 키 추가됨 - 테스트 통과
k6 부하 테스트 스크립트 작성
## 목적 최적화 효과를 실측으로 검증할 부하 테스트 도구 도입. ## 변경 - `test/load/diagnosis_flow.js` (신규, k6 스크립트) ## 시나리오 1. 진단 세션 생성 → show 페이지 로드 → 45라운드 응답 → complete 2. 동시 사용자: 10 → 50 → 100 → 500 ramp-up 3. 목표 메트릭: p95 < 1s, 에러율 < 1% ## 완료 기준 - [ ] k6 스크립트 작동 - [ ] 스테이징에서 테스트 실행 가능 - [ ] before/after 성능 비교 가능 - [ ] p50, p95, p99, 에러율 메트릭 정의
[Phase 3] PR 3-3: 블로그 댓글 + 좋아요
## 개요 블로그 댓글(1단계 쓰레드) + 좋아요 토글 (Turbo Stream) ## 생성할 파일 - `app/controllers/blog/comments_controller.rb` - `app/controllers/blog/likes_controller.rb` - `app/views/blog/comments/` (_comment, _form) - `app/views/blog/likes/_button.html.erb` - 테스트 2개 ## 완료 기준 - 로그인 사용자: 댓글 작성/삭제 (1단계 쓰레드, Turbo Stream) - 로그인 사용자: 좋아요 토글 (Turbo Frame) - 비로그인: 로그인 유도 - 본인 댓글만 삭제 (admin 전체 가능) ## 의존성 PR 3-1 (블로그 공개 페이지)
[조직문화 PR7] Admin: B2B 문항 분리 관리 + 네비게이션
## 설명 관리자 메뉴에서 engagement_b2b 문항을 별도 섹션으로 표시 + 워크스페이스 사이드바에 조직문화 메뉴 추가. ### Admin 문항관리 - admin/survey_questions#index에서 engagement_b2b 카테고리를 별도 카드로 표시 - 기존 available_targets_for 로직 활용 - B2B 10문항 CRUD (기존 패턴) ### 워크스페이스 네비게이션 - 사이드바에 "조직문화 진단" 메뉴 추가 - workspace_permissions에 engagement 권한 추가 (admin, leader) - i18n 키 추가 (ko, en, zh, vi) ## 완료 기준 - [ ] admin 문항관리에서 B2B 문항 별도 섹션 확인 - [ ] 워크스페이스 사이드바에 메뉴 표시 - [ ] 권한에 따른 메뉴 노출 (leader 이상) - [ ] 4개 locale 정상 표시 ## 의존성 PR1 완료 후
멤버 결과 보기 권한 통합 테스트 작성
## 목표 워크스페이스 멤버 결과 보기 권한이 올바르게 동작하는지 통합 테스트를 작성한다. ## 테스트 케이스 - [ ] 워크스페이스 admin이 멤버 진단 결과를 볼 수 있는지 - [ ] 워크스페이스 leader가 타인 결과에 접근하면 거부되는지 - [ ] 워크스페이스 member가 타인 결과에 접근하면 거부되는지 - [ ] 워크스페이스 비멤버가 결과에 접근하면 거부되는지 - [ ] 자기 자신의 결과는 역할 무관하게 볼 수 있는지 ## 완료 기준 - [ ] 모든 테스트 통과 - [ ] 기존 테스트 깨지지 않음
표지 + 해석가이드 + 요약 파트셜 리팩토링
3개 1페이지 섹션 파트셜 리팩토링. _cover.html.erb, _way_map_guide.html.erb, _executive_summary.html.erb. 박스/컬러 최소화, 전문적 타이포그래피 중심.
상품 관리
https://9way.org/admin/workspaces 지금은 상품이 강점 진단 리포트 밖에 없지만, 상품관리 메뉴가 필요할 것 같아. 그래야 나중에 안 헷갈릴 듯
Admin BaseController switch_locale override 및 SurveyQuestions 필터링 수정
## 작업 내용 1. `app/controllers/admin/base_controller.rb`에 `switch_locale` override 추가 - Admin 영역에서는 params[:locale]을 무시하고 항상 기본 locale(ko) 사용 - `def switch_locale(&action); I18n.with_locale(I18n.default_locale, &action); end` 2. `app/controllers/admin/survey_questions_controller.rb`의 `group_detail` 액션 수정 - `params[:locale]` 대신 `params[:lang]` 사용 (URL 파라미터명 변경) - `@locale = params[:lang].presence || "ko"` - 선택된 locale에 맞는 질문 번역만 표시되도록 필터링 로직 추가 3. 관련 컨트롤러 테스트 작성 ## 완료 기준 - Admin에서 어떤 파라미터를 전달해도 UI 언어가 변경되지 않음 - group_detail에서 선택된 언어의 질문 텍스트가 정확히 표시됨 - 테스트 통과
[Phase 4] PR 4-1: Study 모델 (코스/챕터/레슨)
## 개요 Study::Course, Chapter, Lesson 모델 + 시드 데이터 ## 스키마 - study_courses: slug(unique), title, subtitle, description, price_monthly(88000), completion_threshold(80), total_chapters/lessons/duration(auto), is_active - study_chapters: course_id(FK), chapter_number(unique per course), title, description, is_preview, display_order - study_lessons: chapter_id(FK), course_id(FK), lesson_number(unique per chapter), title, video_id, video_type(enum: vimeo/youtube), duration_minutes, is_preview, materials_url ## 완료 기준 - 코스-챕터-레슨 계층 연관관계 - course.recalculate_totals! 콜백 - 시드: "강점코치 입문 과정" 커리큘럼 (11챕터, ~20레슨) ## 의존성 PR 1-1 (서브도메인 라우팅)
개인 PDF - 9WAY MAP 원형 차트 SVG 삽입
## 목표 PDF에 9WAY 원형 차트를 SVG로 직접 삽입하는 새 페이지 추가 ## 작업 내용 - ERB 파트셜 `pdf/_way_map_chart.html.erb` 생성 - 기존 `circular_chart_controller.js` 로직을 서버 사이드 SVG로 변환 - 9 WAY 점수를 방사형(원형) 차트로 시각화 - Domain 섹터 색상 적용 (Thinking: #3B82F6, Relationship: #10B981, Execution: #F59E0B) - Grover(Puppeteer)가 HTML을 렌더링하므로 SVG 직접 임베드 가능 ## 레거시 참고 - `/mnt/c/dev/9way/components/diagnosis/way-map-chart.tsx` - 원형 차트 컴포넌트 - `/mnt/c/dev/9way/lib/pdf/templates/diagnosis/result.tsx` lines 109-146 - 차트 이미지 삽입부 ## 완료 기준 - PDF에 원형 차트가 정상 렌더링됨 - 9개 WAY 점수가 방사형으로 표시됨 - Domain별 색상 구분 적용됨 - 기존 PDF 페이지 번호 조정됨
DNA 차별성 Top5 파트셜 리디자인 (10p)
_dna_detail.html.erb 리디자인. Top5 각 2페이지. detail-box → 구분선+텍스트, green/red 박스 → 텍스트 컬러 힌트. 전문 리포트 느낌.
문항 관리 뷰 언어 선택 파라미터 수정
## 작업 내용 1. `app/views/admin/survey_questions/index.html.erb` 수정 - 언어 버튼 링크에서 `locale:` 파라미터를 `lang:` 으로 변경 - `group_detail_admin_survey_questions_path(diagnosis_type_id: dt.id, lang: locale)` 형태 2. `app/views/admin/survey_questions/group_detail.html.erb` 수정 - 같은 방식으로 `locale:` → `lang:` 변경 - 현재 선택된 언어 표시(active 상태) 확인 3. 통합 테스트 작성 - 언어 버튼 클릭 시 UI 언어가 변경되지 않는지 확인 - 선택된 언어의 질문이 정확히 표시되는지 확인 ## 완료 기준 - 모든 뷰에서 `locale:` 대신 `lang:` 파라미터 사용 - 언어 버튼 클릭 시 전체 UI 언어가 변경되지 않음 - 테스트 통과
[Phase 4] PR 4-2: Study Admin CRUD
## 개요 Admin에서 코스/챕터/레슨 관리 ## 생성할 파일 - `app/controllers/admin/study_courses_controller.rb` - `app/controllers/admin/study_chapters_controller.rb` - `app/controllers/admin/study_lessons_controller.rb` - `app/views/admin/study_courses/` (index, show, _form) ## 완료 기준 - 코스 CRUD + 챕터/레슨 중첩 관리 - 코스 활성화/비활성화 - admin 사이드바에 교육 메뉴 ## 의존성 PR 4-1 (Study 모델)
개인 PDF - 차트 해석 가이드 텍스트 페이지
## 목표 9WAY MAP 차트와 함께 해석 가이드 텍스트를 PDF 페이지로 추가 ## 작업 내용 - PDF 파트셜 `pdf/_way_map_guide.html.erb` 생성 - 레거시의 5단락 차트 해석 가이드 텍스트를 i18n화 (ko/en/zh/vi) - 원형 차트 SVG와 가이드 텍스트를 한 페이지 또는 연속 페이지로 배치 - `pdf.html.erb` 오케스트레이터에 새 페이지 삽입 (P3 위치) ## 레거시 참고 (가이드 텍스트 5단락) 1. 강점을 제대로 이해하려면 3가지 핵심 질문... (영역/역할/차별성) 2. 아래 그래프에서 사고, 관계, 행동 세 영역 중... (Domain 해석) 3. 원 주변 부채꼴 면적인 9가지 강점 역할... (WAY 해석) 4. 강점 역할 바깥쪽 DNA 아이콘... (DNA 해석) 5. 다음 페이지들을 천천히 읽어보세요... (활용 안내) 참고 파일: `/mnt/c/dev/9way/lib/pdf/templates/diagnosis/result.tsx` lines 112-138 ## 완료 기준 - 가이드 텍스트가 PDF에 정상 표시됨 - 4개 언어 i18n 적용됨 - 차트와 가이드가 자연스럽게 배치됨
강점 프로파일 + 탁월한 역할 + AI 분석 파트셜 신규 생성
3개 신규 파트셜 생성. _strength_profile.html.erb(domain+way 분포 2p), _excellent_role.html.erb(WAY×DNA 조합+target별 콘텐츠 2p), _ai_analysis.html.erb(AI 인사이트 2p 조건부).
CustomPaymentLink 모델 + 마이그레이션 + 라우팅 + 서비스
## 작업 내용 CustomPaymentLink 모델, DB 마이그레이션, 라우팅, 서비스 객체를 생성합니다. ## 1. DB 마이그레이션 ```ruby create_table :custom_payment_links, id: :uuid do |t| t.string :title, null: false # 상품명 t.integer :amount, null: false # 결제 금액 (원) t.text :description # 상세 설명 t.string :status, default: "pending", null: false # pending/completed/canceled t.string :token, null: false # URL 토큰 t.datetime :expires_at, null: false # 만료일 t.references :created_by, type: :uuid, null: false, foreign_key: { to_table: :users } t.references :payment, type: :uuid, foreign_key: true # 결제 완료 시 연결 t.datetime :paid_at # 결제 완료 시각 t.timestamps end add_index :custom_payment_links, :token, unique: true add_index :custom_payment_links, :status ``` ## 2. CustomPaymentLink 모델 - belongs_to :created_by, class_name: "User" - belongs_to :payment, optional: true - enum :status (pending, completed, canceled) - validates :title, :amount, :token, :expires_at presence - validates :amount, numericality: { greater_than: 0 } - validates :token, uniqueness: true - before_validation :generate_token (SecureRandom.urlsafe_base64(32)) - scope :active → pending + 만료 안 됨 - scope :recent → created_at desc - expired? 메서드 - complete!(payment:) 메서드 - cancel! 메서드 ## 3. 라우팅 (config/routes.rb) admin namespace 안에 추가: ```ruby resources :custom_payment_links, only: %i[index new create] do member do patch :cancel get :qr_code end end ``` locale scope 밖에 추가 (토큰 기반 공개 접근): ```ruby # Custom Payment Links (토큰 기반 공개 결제) get "pay/:token", to: "custom_payments#show", as: :custom_payment get "pay/:token/success", to: "custom_payments#success", as: :custom_payment_success get "pay/:token/fail", to: "custom_payments#fail", as: :custom_payment_fail ``` ## 4. 서비스 객체 ### CustomPaymentLinks::CreateService - 입력: title, amount, description, expires_in(일수), created_by(User) - expires_at = expires_in.days.from_now - CustomPaymentLink.create! - 반환: custom_payment_link ### CustomPaymentLinks::CancelService - 입력: custom_payment_link - pending? 검증 - update!(status: "canceled") ## 5. 테스트 - 모델 테스트: 유효성 검증, 상태 전환, scope - 서비스 테스트: 생성, 취소 시나리오 ## 담당 파일 - db/migrate/XXXX_create_custom_payment_links.rb - app/models/custom_payment_link.rb - config/routes.rb - app/services/custom_payment_links/create_service.rb - app/services/custom_payment_links/cancel_service.rb - test/models/custom_payment_link_test.rb - test/fixtures/custom_payment_links.yml ## 완료 기준 - 마이그레이션 실행 성공 - 모델 테스트 통과 - 서비스 테스트 통과 - routes 정상 작동
[Phase 5] PR 5-1: Enrollment + Progress 모델
## 개요 수강 등록 + 진도 추적 모델 ## 스키마 - study_enrollments: user_id(FK), course_id(FK), subscription_type(enum: monthly/quarterly/workshop_bonus/admin_grant), status(enum: active/pending/expired/cancelled/completed), payment_status, payment_key, starts_at, ends_at - study_progress: enrollment_id(FK), user_id(FK), lesson_id(FK), watched_seconds, completed_percentage, is_completed(90%+), last_position_seconds, unique(enrollment_id, lesson_id) ## 완료 기준 - 수강 등록/만료 로직 - 90%+ 시청 → is_completed = true - 80% 레슨 완료 → 코스 수료 ## 의존성 PR 4-1 (Study 모델)
개인 PDF - WAY 상세 진단 타입별 8개 섹션
## 목표 WAY 상세 페이지를 진단 타입별 8개 구조화 섹션으로 재구성 ## 작업 내용 - `pdf/_way_detail.html.erb` 리팩토링 - 타입별 섹션 제목 매핑: - **Adult/College**: 강점 슬로건, 탁월한 역할, 나만의 차별성, 주의할 모습, 스트레스 상황, 자주하는 말, 커리어 TIP, 성장 가이드 - **Student**: 강점 슬로건, 학습 스타일, 나만의 차별성, 효과적인 공부 방법, 집중력이 높아지는 환경, 집중력이 떨어지는 환경, 진로 TIP, 성장 가이드 - **Parent**: 강점 슬로건, 학습 스타일, 자녀의 차별성, 자녀를 대할 때 주의할 점, 스트레스 상황, 듣기 싫은 말, 진로 TIP, 코칭 가이드 - **Leader**: 강점 슬로건, 리더십 스타일, 강점의 차별성, 리더십 강점, 리더십 함정, 성숙을 위한 과제, 갈등 유발 대상, 리더십 TIP - ResultFormatter에 WAY detailedDescription 데이터 매핑 확인/추가 - StrengthDescription에서 WAY별 상세 설명 조회 (field_type 확인) - 1위 WAY만 표시 (레거시 동일) ## 레거시 참고 - `/mnt/c/dev/9way/lib/pdf/templates/diagnosis/result.tsx` lines 212-306 - WAY 상세 섹션 - `sectionTitlesByType` 객체 (lines 218-269) - 타입별 섹션 제목 정의 ## 완료 기준 - 5가지 진단 타입별 적절한 섹션 제목이 표시됨 - 1위 WAY의 8개 섹션이 구조화되어 렌더링됨 - 기존 summary_contents + 27 DNA 랭킹은 별도 페이지로 유지
관리자 커스텀 결제 링크 UI (컨트롤러 + 뷰 + 사이드바)
## 작업 내용 관리자가 커스텀 결제 링크를 생성하고 관리할 수 있는 Admin UI를 구현합니다. ## 1. Admin::CustomPaymentLinksController - BaseController 상속 (Admin::BaseController) - index: 결제 링크 목록 (최신순, 페이지네이션 없이 전체) - new: 생성 폼 - create: CustomPaymentLinks::CreateService 호출 - cancel: CustomPaymentLinks::CancelService 호출 - qr_code: QR 코드 SVG 반환 ## 2. 뷰 (Admin Views) ### index.html.erb - 결제 링크 목록 참고 이미지의 UI를 구현: - 헤더: "생성된 결제 링크 목록", "총 N개의 커스텀 결제 링크" - 테이블 컬럼: 상품명, 금액, 상태, 생성일, 만료일, 작업 - 상태 배지: - 대기중 (pending): 회색 배경, "⏳ 대기중" - 결제완료 (completed): 보라색 배경, "✅ 결제완료" - 취소됨 (canceled): 회색 텍스트, "⊘ 취소됨" - 작업 컬럼: - 대기중: "복사" 버튼 + "취소" 버튼 (빨간색) - 결제완료: "결제완료: YYYY.MM.DD" 텍스트 - 취소됨: 작업 없음 - "새 결제 링크 만들기" 버튼 (상단) - 금액은 number_to_currency(amount, unit: "₩", precision: 0) 형식 ### new.html.erb - 결제 링크 생성 폼 참고 이미지의 UI를 구현: - 제목: "커스텀 결제 링크 생성" - 설명: "관리자가 임의의 금액으로 결제 링크를 생성할 수 있습니다. 생성된 링크는 고객에게 공유하여 결제를 받을 수 있습니다." - 폼 필드: - 상품명 * (text_field, placeholder: "예: 1:1 코칭 컨설팅") - 결제 금액 (원) * (number_field, placeholder: "예: 150000") - 상세 설명 (선택) (text_area, placeholder: "예: 2시간 1:1 강점 코칭 세션") - 유효 기간 (select: 7일/14일/30일) - "결제 링크 생성" 버튼 (보라색, 전체 너비) ### QR 코드 기능 - rqrcode gem 사용 (이미 Gemfile에 있음) - 링크 목록에서 "QR" 아이콘 클릭 시 모달/팝오버로 QR 표시 - Stimulus controller로 모달 토글 ### 링크 복사 기능 - "복사" 버튼 클릭 시 클립보드에 결제 링크 URL 복사 - navigator.clipboard.writeText() 사용 - Stimulus controller: clipboard_controller.js ## 3. 사이드바 수정 app/views/admin/shared/_sidebar.html.erb에 추가: - workspaces 메뉴 아래에 추가 - icon: "payments" (결제 아이콘 SVG path 추가) - label: "커스텀 결제" - active: controller_name == "custom_payment_links" - _nav_item.html.erb의 icons 해시에 "payments" 아이콘 추가 ## 4. i18n config/locales/ko.yml에 추가: ```yaml admin: nav: custom_payments: "커스텀 결제" custom_payment_links: title: "커스텀 결제 링크" subtitle: "총 %{count}개의 커스텀 결제 링크" new_link: "새 결제 링크 만들기" create_title: "커스텀 결제 링크 생성" create_description: "관리자가 임의의 금액으로 결제 링크를 생성할 수 있습니다..." form: title: "상품명" title_placeholder: "예: 1:1 코칭 컨설팅" amount: "결제 금액 (원)" amount_placeholder: "예: 150000" description: "상세 설명 (선택)" description_placeholder: "예: 2시간 1:1 강점 코칭 세션" expires_in: "유효 기간" submit: "결제 링크 생성" status: pending: "대기중" completed: "결제완료" canceled: "취소됨" actions: copy: "복사" cancel: "취소" qr: "QR" flash: created: "결제 링크가 생성되었습니다." canceled: "결제 링크가 취소되었습니다." copied: "링크가 클립보드에 복사되었습니다." ``` ## 5. 기존 UI 패턴 참고 - 테이블: bg-white rounded-xl shadow-sm border, thead bg-surface-emphasis, tr hover:bg-surface-emphasis - 배지: inline-flex items-center px-2 py-0.5 rounded text-xs font-medium - 버튼: px-4 py-2 bg-admin-primary text-white rounded-lg - 폼: w-full rounded-lg border-border-default focus:border-admin-primary ## 담당 파일 - app/controllers/admin/custom_payment_links_controller.rb - app/views/admin/custom_payment_links/index.html.erb - app/views/admin/custom_payment_links/new.html.erb - app/views/admin/shared/_sidebar.html.erb (수정) - app/views/admin/shared/_nav_item.html.erb (아이콘 추가) - config/locales/ko.yml (admin 섹션 수정) - app/javascript/controllers/clipboard_controller.js (새 파일) - app/javascript/controllers/qr_modal_controller.js (새 파일) - test/controllers/admin/custom_payment_links_controller_test.rb ## 완료 기준 - Admin 결제 링크 목록 페이지 작동 - 생성 폼 작동 (유효성 검증 포함) - 취소 기능 작동 - 복사 기능 작동 - QR 코드 표시 - 사이드바 메뉴 표시 - 컨트롤러 테스트 통과
[Phase 5] PR 5-2: 교육 공개 페이지
## 개요 edu.9way.co.kr 코스 목록, 상세, 레슨 플레이어 ## 생성할 파일 - `app/controllers/study/courses_controller.rb` - `app/controllers/study/lessons_controller.rb` - `app/controllers/study/enrollments_controller.rb` - `app/views/study/courses/` (index, show) - `app/views/study/lessons/show.html.erb` - `app/views/study/enrollments/show.html.erb` ## 완료 기준 - edu.9way.org → 활성 코스 목록 - 코스 상세 (커리큘럼, 미리보기, 가격) - 수강 등록 → 레슨 시청 - Vimeo/YouTube 비디오 임베드 - 미등록: 미리보기 레슨만 접근 ## 의존성 PR 5-1 (Enrollment + Progress)
개인 PDF - Domain 상세 페이지 개선
## 목표 Domain 상세 페이지를 1위 도메인 중심 + 3영역 비율 바차트로 개선 ## 작업 내용 - `pdf/_domain_detail.html.erb` 수정 - 1위 Domain 중심 레이아웃으로 변경 (현재는 3개 모두 동등 표시) - 3영역 비율 바 차트 추가 (Thinking/Relationship/Execution 비율 표시) - 1위 Domain의 상세 설명 문단 추가 (현재는 description만 있고 detailedDescription 없음) - 비율 표시: 실제 비율 % + 정규화된 바 너비 ## 레거시 참고 - `/mnt/c/dev/9way/lib/pdf/templates/diagnosis/result.tsx` lines 148-210 - 3영역 비율 계산: totalScore 대비 각 domain score 비율 - 50%를 최대값으로 정규화하여 바 차트 표시 - 색상: Thinking #3b82f6, Relationship #10b981, Execution #f97316 ## 완료 기준 - 1위 Domain이 강조되어 표시됨 - 3영역 비율 바 차트가 렌더링됨 - 상세 설명 문단이 포함됨
고객 결제 페이지 + Toss Payments 연동
## 작업 내용 고객이 커스텀 결제 링크를 통해 접근하여 Toss Payments로 결제할 수 있는 공개 페이지를 구현합니다. ## 1. CustomPaymentsController 로그인 불요 (공개 페이지). ApplicationController 상속하되 skip_before_action :require_authentication. ### show (GET /pay/:token) - CustomPaymentLink.find_by!(token: params[:token]) - 만료/취소/완료 여부 체크 - 만료됨 → expired 상태 표시 - 취소됨 → canceled 상태 표시 - 결제완료 → 이미 완료 메시지 - 대기중 → 결제 폼 표시 ### checkout (POST /pay/:token/checkout) - 결제 링크 유효성 검증 (pending? && !expired?) - 고객용 임시 User 생성 또는 guest 처리 - 실제로는 Toss에서 결제 처리하므로 user 없이도 가능 - Payment 생성 시 user_id는 결제 링크 생성자(admin)의 ID 사용 - Payments::CheckoutService 호출: - payment_type: "CUSTOM" - amount: custom_payment_link.amount - description: custom_payment_link.title - metadata: { custom_payment_link_id: link.id } - JSON 응답으로 Toss SDK용 데이터 반환 ### success (GET /pay/:token/success) - Payments::ConfirmationService로 결제 승인 - CustomPaymentLink 상태를 completed로 변경 - payment 연결, paid_at 설정 - 성공 페이지 표시 ### fail (GET /pay/:token/fail) - 실패 메시지 표시 - 다시 시도 링크 제공 ## 2. 뷰 ### show.html.erb - 결제 페이지 - 독립 레이아웃 (admin/user 레이아웃 아님) - 깔끔한 결제 카드 형태: - 9WAY 로고 - 상품명 (title) - 상세 설명 (description) - 결제 금액 (amount) - 큰 글씨 - "결제하기" 버튼 - Toss Payments SDK 로드: ```html <script src="https://js.tosspayments.com/v2/standard"></script> ``` - Stimulus 또는 vanilla JS로 Toss 결제 위젯 초기화 - 결제 시퀀스: 1. "결제하기" 클릭 2. POST /pay/:token/checkout으로 주문 생성 3. 응답의 orderId, amount로 Toss requestPayment() 호출 4. 성공 → /pay/:token/success?paymentKey=...&orderId=...&amount=... 5. 실패 → /pay/:token/fail?code=...&message=... - 만료/취소/완료 시 적절한 메시지 표시 ### success.html.erb - 결제 성공 - 성공 아이콘 + "결제가 완료되었습니다" 메시지 - 결제 정보 요약 (상품명, 금액, 결제일시) ### 기존 참고 코드 - app/controllers/payments_controller.rb의 checkout/success/fail 패턴 - app/services/payments/checkout_service.rb의 결제 생성 로직 - app/services/payments/confirmation_service.rb의 결제 승인 로직 - 기존 Toss client_key: Rails.application.config.toss_payments.client_key ## 3. 레이아웃 - 별도의 minimal 레이아웃 사용: layout "custom_payment" - app/views/layouts/custom_payment.html.erb 생성 - 심플한 흰색 배경 + 가운데 정렬 카드 ## 4. 라우팅 라우팅은 "백엔드" 에이전트가 처리합니다. 다음 경로를 사용합니다: - GET /pay/:token → custom_payments#show - POST /pay/:token/checkout → custom_payments#checkout - GET /pay/:token/success → custom_payments#success - GET /pay/:token/fail → custom_payments#fail ## 담당 파일 - app/controllers/custom_payments_controller.rb - app/views/custom_payments/show.html.erb - app/views/custom_payments/success.html.erb - app/views/layouts/custom_payment.html.erb - app/javascript/controllers/toss_custom_payment_controller.js - test/controllers/custom_payments_controller_test.rb ## 완료 기준 - 유효한 토큰으로 결제 페이지 접근 가능 - 만료/취소/완료 상태 적절히 표시 - Toss Payments 결제 플로우 동작 - 결제 성공 시 CustomPaymentLink 상태 업데이트 - 컨트롤러 테스트 통과
[Phase 5] PR 5-3: 비디오 진도 추적 (Stimulus)
## 개요 Vimeo/YouTube API 래핑 Stimulus 컨트롤러 + 진도 API ## 생성할 파일 - `app/javascript/controllers/video_player_controller.js` - `app/controllers/study/progress_controller.rb` ## 완료 기준 - Vimeo/YouTube Player API로 시청 시간 추적 - 5초 간격 서버 전송 (debounced) - 90%+ 시청 시 자동 완료 표시 - 이어보기 (last_position_seconds) - sendBeacon on page leave ## 의존성 PR 5-2 (교육 공개 페이지)
[리퍼럴 PR1] DB 레이어 — 크레딧 확장 + Referral 테이블
## 설명 진단 크레딧 + 리퍼럴 보상 시스템의 DB 기반 구축. ### 마이그레이션 1. `add_diagnosis_balance_to_user_credits` — diagnosis_balance(integer, default: 0) 2. `add_credit_type_to_credit_transactions` — credit_type(string, default: "report") 3. `add_referral_code_to_users` — referral_code(string, unique index) 4. `create_referrals` — referrer_id, referee_id, status, rewarded_at, diagnosis_session_id ### 모델 변경 - `UserCredit`: diagnosis_balance 관련 메서드 (deduct_diagnosis!, add_diagnosis!, diagnosis_sufficient?) - `CreditTransaction`: credit_type validation ('report' | 'diagnosis'), transaction_type에 'referral' 추가 - `User`: referral_code 생성, has_many :referrals_given/received - `Referral` (신규): belongs_to referrer/referee, status 관리 ## 완료 기준 - [ ] 4개 마이그레이션 성공 (가역적) - [ ] UserCredit diagnosis_balance 메서드 동작 (deduct/add/sufficient) - [ ] Referral 모델 CRUD + 유효성 검증 (중복 방지) - [ ] 기존 UserCredit.deduct!/add! 동작 변경 없음 - [ ] 테스트 통과 ## 의존성 없음 (첫 번째 PR)
마이그레이션 + 모델 구현 (TDD)
## 작업 내용 ### 1. 마이그레이션 4개 생성 1. `add_diagnosis_balance_to_user_credits` — diagnosis_balance(integer, default: 0, null: false) 2. `add_credit_type_to_credit_transactions` — credit_type(string, default: "report", null: false) + index 3. `add_referral_code_to_users` — referral_code(string) + unique index 4. `create_referrals` — id:uuid, referrer_id(uuid FK→users), referee_id(uuid FK→users, unique), status(string, default: "pending"), rewarded_at(datetime), diagnosis_session_id(uuid) + index(referrer_id, status) ### 2. 모델 변경 - **UserCredit**: diagnosis_sufficient?(amount), deduct_diagnosis!(amount, description:, diagnosis_session:), add_diagnosis!(amount, description:, payment:) - **CreditTransaction**: credit_type validation ('report' | 'diagnosis'), transaction_type에 'referral' 추가 - **User**: before_create :generate_referral_code, has_many :referrals_given/:referrals_received, referral_code unique - **Referral (신규)**: belongs_to referrer/referee, belongs_to diagnosis_session(optional), status validation (pending/completed/rewarded/expired), validates referee_id uniqueness ### 3. TDD - test/models/user_credit_test.rb 확장 (diagnosis_balance 관련) - test/models/referral_test.rb 신규 - test/fixtures/referrals.yml 신규 - 기존 user_credit_test.rb 테스트가 깨지지 않는지 확인 ### 완료 기준 - 4개 마이그레이션 성공 (가역적) - 기존 UserCredit.deduct!/add! 동작 변경 없음 - 모든 테스트 통과
Referrals::TrackingService + RewardService TDD 구현
## 작업 내용 ### 1. Referrals::TrackingService (`app/services/referrals/tracking_service.rb`) 신규 가입 시 referral_code로 Referral(pending) 생성. ```ruby module Referrals class TrackingService def self.call(referee:, referral_code:) # 1. referral_code로 referrer 찾기 # 2. 자기 자신 추천 방지 (referrer == referee → skip) # 3. referee에 이미 referral 있으면 무시 # 4. Referral.create!(referrer:, referee:, status: "pending") end end end ``` ### 2. Referrals::RewardService (`app/services/referrals/reward_service.rb`) 강점 진단 완료 시 리퍼럴 보상 지급. ```ruby module Referrals class RewardService def self.call(diagnosis_session:) # 1. diagnosis_session.user의 pending referral 확인 # 2. 진단이 strength 카테고리인지 확인 (strength가 아니면 skip) # 3. referrer의 월간 보상 카운트 확인 (>= 10이면 skip) # 4. referral.update!(status: "rewarded", rewarded_at: Time.current, diagnosis_session:) # 5. referrer의 credit.add_diagnosis!(1, description: "추천 보상 (#{referee.name})") end end end ``` ### 3. TDD **test/services/referrals/tracking_service_test.rb**: - 정상: referral_code로 Referral(pending) 생성 - 자기 추천: referrer == referee → Referral 생성 안 함 - 이미 추천인 있음: referee에 referral 존재 → 무시 - 잘못된 코드: 존재하지 않는 referral_code → 무시 - nil 파라미터 처리 **test/services/referrals/reward_service_test.rb**: - 정상: strength 진단 완료 → referrer diagnosis_balance +1, status=rewarded - strength 아닌 진단: big5 완료 → 보상 안 함 - 월 10개 초과: 11번째 → 보상 안 함 - pending referral 없음: → 아무것도 안 함 - 이미 rewarded: → 중복 보상 안 함 - referrer 크레딧 없음: ensure_credit! 후 +1 ## 완료 기준 - TrackingService: 정상 생성, 자기추천 방지, 중복 방지 - RewardService: 보상 지급, 월 10개 상한, 중복 방지 - 모든 테스트 통과
PART2 쌍대비교 전환 (3지선다 → BIG5식 페어링)
## 설명 강점 진단 PART2를 3지선다(36라운드)에서 BIG5식 쌍대비교(54라운드)로 변경. ### 변경 내용 - 매 라운드: 2개 도메인에서 1개씩 페어링 + "둘 다 해당 안됨" - 페어링: 사고-관계 / 사고-행동 / 관계-행동 (18쌍씩, 랜덤 셔플) - 사전 생성 방식 (session_seed 기반 deterministic) - "둘 다 해당 안됨" = 타임아웃과 동일 패턴 (value=0, time=null) ### 변경 파일 1. `diagnosis_sessions_controller.rb` — 페어링 알고리즘, 상수, round 계산 2. `_part2_round.html.erb` — 2버튼 + "해당없음" 3. `_part2_guide.html.erb` — 미리보기 변경 4. `diagnosis_controller.js` — skipPart2Round 추가 5. i18n (4개 locale) 6. 컨트롤러 테스트 ### DB 마이그레이션: 불필요 ### 채점 로직 변경: 불필요 ## 완료 기준 - [ ] 54라운드 쌍대비교 UI 정상 동작 - [ ] 108문항 정확히 소진 (중복 없음, 누락 없음) - [ ] 18-18-18 페어 균형 보장 - [ ] "둘 다 해당 안됨" 선택 시 value=0 저장 - [ ] 30초 타임아웃 정상 동작 - [ ] 가이드 화면 업데이트 - [ ] i18n 4개 언어 - [ ] 기존 완료 세션 채점 호환 - [ ] 테스트 통과
컨트롤러 연동: ref 추적 + 크레딧 차감 + 보상 트리거 + 공유 URL
## 작업 내용 ### 1. ApplicationController — ref 파라미터 쿠키 저장 - `before_action :track_referral_code` - `params[:ref]` 있으면 → `cookies.signed[:referral_code] = { value: params[:ref], expires: 30.days }` - 이미 로그인된 유저이고 기존 회원이면 무시 (신규 가입 시에만 의미) ### 2. DiagnosesController — 진단 시작 시 크레딧 차감 - Big5/성장마인드셋/행복도 진단 시작 시 진단 크레딧 1개 차감 - DiagnosisType에 `requires_diagnosis_credit?` 메서드 추가: category.slug가 big5/happiness/growth_mindset인 경우 - 크레딧 부족 시 redirect with flash alert - 기존 진단 시작 로직 확인 후 적절한 위치에 삽입 ### 3. DiagnosisSessionsController 또는 SessionManager — 보상 트리거 - 강점 진단 완료 시 `Referrals::RewardService.call(diagnosis_session:)` 호출 - 기존 complete 로직 확인 후 after_commit 또는 직접 호출 ### 4. 회원가입 흐름 — 리퍼럴 추적 - OAuth 콜백(OmniAuth) 또는 매직링크 가입 시: - `cookies.signed[:referral_code]`가 있으면 → `Referrals::TrackingService.call(referee: user, referral_code: code)` - 가입 완료 후 쿠키 삭제 ### 5. 공유 URL에 ref 파라미터 추가 - `app/views/diagnoses/result.html.erb`: `data-share-menu-url-value`와 `data-kakao-share-url-value`에 `?ref=current_user.referral_code` 추가 - `app/views/diagnoses/_tab_summary.html.erb`: 동일하게 ref 파라미터 추가 - 로그인 유저만 ref 파라미터 추가 (비로그인은 빈 URL) ### 6. i18n - `config/locales/diagnosis/ko.yml`에 크레딧 부족 메시지 추가: - `diagnosis.flash.insufficient_diagnosis_credits: "진단 크레딧이 부족합니다. 크레딧을 구매하거나 친구에게 공유하여 무료 크레딧을 받으세요."` - en, zh, vi도 추가 ## 완료 기준 - ref 파라미터 쿠키 저장 동작 - Big5/성장/행복도 진단 시작 시 크레딧 차감 - 크레딧 부족 시 진단 시작 불가 + flash 메시지 - 강점 진단 완료 시 리퍼럴 보상 트리거 - 신규 가입 시 리퍼럴 추적 기록 - 공유 URL에 ref 파라미터 포함 - 테스트 통과
진단 크레딧 구매 서비스 + 컨트롤러 + 라우트
## 작업 내용 ### 1. Payments::DiagnosisCreditPurchaseService (`app/services/payments/diagnosis_credit_purchase_service.rb`) 기존 `Payments::CreditPurchaseService` (워크스페이스용)를 참고하여 개인 진단 크레딧 구매 서비스 생성. ```ruby module Payments class DiagnosisCreditPurchaseService PRICE_PER_CREDIT = 3_000 # 원 (VAT 포함) def initialize(user:, quantity: 1, locale: "ko") @user = user @quantity = quantity.to_i @locale = locale end # PG 결제 준비 def prepare_checkout! amount = @quantity * PRICE_PER_CREDIT order_id = OrderIdGenerator.generate(payment_type: "DIAG_CREDIT", user_id: @user.id) payment = Payment.create!( user: @user, order_id: order_id, amount: amount, currency: "KRW", provider: "toss_payments", payment_type: "diagnosis_credit", description: "진단 크레딧 #{@quantity}개", status: "pending", metadata: { "quantity" => @quantity, "product_type" => "diagnosis_credit" } ) { payment_id: payment.id, order_id: order_id, amount: amount, order_name: "진단 크레딧 #{@quantity}개", customer_key: "customer_#{@user.id}" } end # 결제 완료 후 크레딧 지급 def self.complete_purchase!(payment) quantity = payment.metadata&.dig("quantity").to_i return unless quantity > 0 return unless payment.metadata&.dig("product_type") == "diagnosis_credit" user = payment.user credit = user.ensure_credit! payment.with_lock do return if CreditTransaction.exists?(payment_id: payment.id, credit_type: "diagnosis") credit.add_diagnosis!( quantity, description: "진단 크레딧 구매 (#{quantity}개)", payment: payment ) end end end end ``` ### 2. Payment 모델 수정 - `payment_type` validation에 `diagnosis_credit` 추가 (기존 validation 확인 후) ### 3. ConfirmationService 수정 (`app/services/payments/confirmation_service.rb`) - 결제 완료 후 처리 로직에 diagnosis_credit 케이스 추가: ```ruby # 기존 complete_purchase 로직 안에 if payment.payment_type == "diagnosis_credit" DiagnosisCreditPurchaseService.complete_purchase!(payment) end ``` ### 4. DiagnosisCreditsController (`app/controllers/diagnosis_credits_controller.rb`) ```ruby class DiagnosisCreditsController < ApplicationController layout "authenticated" def new # 구매 페이지 표시 @quantity = (params[:quantity] || 1).to_i @price_per_credit = Payments::DiagnosisCreditPurchaseService::PRICE_PER_CREDIT @total = @quantity * @price_per_credit end def create quantity = (params[:quantity] || 1).to_i service = Payments::DiagnosisCreditPurchaseService.new( user: Current.user, quantity: quantity, locale: I18n.locale ) checkout_data = service.prepare_checkout! render json: checkout_data end end ``` ### 5. 라우트 추가 (`config/routes.rb`) locale scope 내부에: ```ruby resources :diagnosis_credits, only: %i[new create] ``` ### 6. Product seed (선택) 기존 Product 모델 확인 후 필요하면 seed 추가. ### 7. TDD - `test/services/payments/diagnosis_credit_purchase_service_test.rb`: - prepare_checkout!: Payment(pending) 생성, amount 검증 - complete_purchase!: diagnosis_balance 증가, 멱등성 검증 - `test/controllers/diagnosis_credits_controller_test.rb`: - GET new: 200 응답 - POST create: checkout_data JSON 반환 ## 완료 기준 - 진단 크레딧 구매 → PG 결제 → diagnosis_balance 증가 - 멱등성 보장 (중복 결제 방지) - 테스트 통과
크레딧 현황 UI + 리퍼럴 대시보드 + 구매 페이지 + 크레딧 부족 안내
## 작업 내용 ### 1. 크레딧 현황 페이지 수정 (`app/views/credits/index.html.erb`) 기존 리포트 크레딧만 표시 → 2종류 크레딧 분리 표시. - **리포트 크레딧 카드**: 기존 balance 표시, 용도: "강점 리포트 열람" - **진단 크레딧 카드**: diagnosis_balance 표시, 용도: "Big5 / 성장 / 행복도 진단" - "구매하기" 버튼 → diagnosis_credits/new 링크 - "공유로 무료 획득" 안내 문구 - 거래 내역에 credit_type 표시 (리포트/진단 구분) - 기존 Tailwind 디자인 시스템 유지 CreditsController도 수정 필요: ```ruby def index @user = Current.user @user.ensure_credit! @credit = @user.credit @transactions = @user.credit_transactions.order(created_at: :desc).limit(50) @referrals = @user.referrals_given.rewarded.this_month # 월간 리퍼럴 현황 end ``` ### 2. 리퍼럴 현황 섹션 크레딧 페이지 내에 리퍼럴 섹션 추가 (별도 페이지 불필요): - 이번 달 보상 횟수: N / 10 - 최근 추천 목록 (rewarded 상태, 날짜, 피추천자 이름) - "친구에게 공유하기" CTA → 진단 결과 공유 안내 ### 3. 진단 크레딧 구매 페이지 (`app/views/diagnosis_credits/new.html.erb`) PR4에서 이미 생성됨 — 확인 후 디자인 개선: - 가격 표시: 3,000원 / 크레딧 - 수량 선택 (1, 3, 5개) - Toss Payments 결제 버튼 - 기존 paywall 디자인 참고 (`app/views/diagnoses/paywall/`) ### 4. 크레딧 부족 안내 모달/페이지 진단 시작 시 크레딧 부족할 때 보여줄 UI: - `app/views/diagnoses/_insufficient_credits.html.erb` (partial) - "진단 크레딧이 필요합니다" 안내 - 두 가지 옵션: 1. "크레딧 구매" → diagnosis_credits/new 링크 2. "친구에게 공유하여 무료 크레딧 받기" → 공유 안내 - DiagnosesController에서 크레딧 부족 시 이 partial을 render하도록 (현재는 redirect + flash) ### 5. i18n (4개 언어) ```yaml # credits 관련 credits: index: report_credit: "리포트 크레딧" diagnosis_credit: "진단 크레딧" report_credit_desc: "강점 리포트 열람에 사용" diagnosis_credit_desc: "Big5 / 성장마인드셋 / 행복도 진단에 사용" buy_credits: "크레딧 구매" earn_free: "공유로 무료 획득" referral_status: "이번 달 추천 보상" referral_count: "%{count} / 10" share_invite: "친구에게 공유하기" transaction: report: "리포트" diagnosis: "진단" referral: "추천 보상" # diagnosis_credits 관련 diagnosis_credits: new: title: "진단 크레딧 구매" price_per_credit: "크레딧당 %{price}원" quantity: "수량" total: "총 결제금액" purchase: "결제하기" # insufficient credits diagnosis: insufficient_credits: title: "진단 크레딧이 필요합니다" buy_option: "크레딧 구매하기" share_option: "친구에게 공유하여 무료 크레딧 받기" ``` en, zh, vi도 추가. ### 6. 기존 UI 참고 - `app/views/diagnoses/paywall/` — 결제 페이지 디자인 - `app/views/credits/index.html.erb` — 현재 크레딧 페이지 - `app/views/shared/` — 공통 partial (_button, _card 등) - Tailwind CSS 사용, 모바일 반응형 필수 ## 완료 기준 - 크레딧 페이지에 2종류 크레딧 분리 표시 - 리퍼럴 현황 (N/10) 표시 - 진단 크레딧 구매 페이지 디자인 - 크레딧 부족 시 안내 UI - 모바일 반응형 - 4개 locale i18n - 테스트 통과
[할인코드확장 PR1] DB 마이그레이션 + 모델 — apply_scope enum + discount_code_items polymorphic
## 목표 할인코드 적용 범위를 Product 전용에서 Expert::Course + Study::Course까지 확장하기 위한 DB/모델 기반 작업 ## 변경 사항 ### 마이그레이션 1: add_apply_scope_to_discount_codes - discount_codes 테이블에 apply_scope string 컬럼 추가 (default: "all", null: false) - 데이터 이관: applies_to_all: true → apply_scope: "all", false → "specific" - applies_to_all 컬럼은 아직 제거하지 않음 (ignored_columns 먼저) ### 마이그레이션 2: create_discount_code_items - discount_code_items 테이블 생성: discount_code_id(FK), discountable_type(string), discountable_id(uuid) - 복합 유니크 인덱스: [discount_code_id, discountable_type, discountable_id] - discount_code_products 데이터를 discount_code_items로 이관 (discountable_type: "Product") ### 모델 - DiscountCodeItem 모델 생성 (polymorphic belongs_to :discountable) - DiscountCode: apply_scope enum (all/report/education/specific), ignored_columns += ["applies_to_all"] - DiscountCode#applicable_to? 로직 변경: scope 기반 ## 완료 기준 - [ ] 마이그레이션 up/down 정상 동작 - [ ] 기존 데이터 이관 정상 - [ ] DiscountCode apply_scope enum 동작 - [ ] DiscountCodeItem polymorphic 연관 동작 - [ ] 기존 테스트 전수 통과 - [ ] 새 모델 테스트 추가 ## 관련 파일 - app/models/discount_code.rb - app/models/discount_code_product.rb - 신규: app/models/discount_code_item.rb - test/models/discount_code_test.rb - test/fixtures/discount_codes.yml
개인 PDF - 유료 콘텐츠 페이지 구현
## 목표 PDF에 유료 콘텐츠 페이지 추가 (report_purchased? 조건부 렌더링) ## 작업 내용 - PDF 파트셜 `pdf/_premium_content.html.erb` 생성 - 직무별 상세 페이지 (각 직무마다 개별 페이지): - 하는 일, 주요 과업, 직업 트렌드, 필요 지식, 필요 스킬, 필요 경험, 준비할 것 - 자소서/이력서 TIP 페이지 (bullet 리스트) - 진로/커리어 가이드 페이지 - `pdf.html.erb` 오케스트레이터에서 `session.report_purchased?` 조건부 렌더링 - ResultFormatter에 premium_content 데이터 매핑 추가 - StrengthDescription에서 job/course/study 등 summary field_type 활용 ## 레거시 참고 - `/mnt/c/dev/9way/lib/pdf/templates/diagnosis/result.tsx` lines 382-490 - 유료 콘텐츠 렌더링 - `/mnt/c/dev/9way/lib/pdf/templates/diagnosis/types.ts` lines 86-102 - PremiumSection/PremiumContentItem 타입 - 직무별: jobDescriptions Record (직무명 → 설명) - 일반: contents string[] (자소서 tip, 학교생활 tip 등) ## 완료 기준 - report_purchased?인 경우에만 유료 페이지가 PDF에 포함됨 - 직무별 상세가 개별 페이지로 렌더링됨 - 자소서TIP/진로가이드가 정상 표시됨 - 미결제 시 유료 페이지가 PDF에 포함되지 않음
인프라 설정 + 통계 컨트롤러 + 개요 대시보드
Chartkick gem + Chart.js importmap 설정, Admin::StatisticsController 생성 (show/users/diagnoses/revenue 4개 액션 + 데이터 쿼리), routes.rb에 statistics 라우트 추가, 사이드바에 통계 메뉴 추가, 개요 대시보드 뷰(show.html.erb) + 탭 네비게이션 생성. 선행 작업으로 다른 서브 티켓의 의존성.
[Phase 6] PR 6-1: Workshop 모델
## 개요 Expert::Course, CourseApplication, Certificate 모델 ## 스키마 - expert_courses: level(0-3,12), batch_number, start_date, end_date, max_participants, status(enum: draft/open/in_progress/completed), location, price, time - expert_course_applications: applicant_id(FK→users), course_id(FK), status(enum), payment_status, payment_amount, payment_key, unique(applicant_id, course_id) - expert_certificates: user_id(FK), course_id(FK), application_id(FK), certificate_type(enum: completion/qualification), certificate_number(unique, 9W-C-YYYY-NQ-NNN) ## 완료 기준 - 워크숍 과정/신청/인증서 모델 - 인증서 번호 자동 생성 ## 의존성 PR 4-1 (Study 모델)
[리퍼럴 PR2] 비즈니스 로직 — 리퍼럴 보상/추적 서비스
## 설명 리퍼럴 추적 및 보상 로직을 서비스 객체로 구현. ### 서비스 1. **Referrals::TrackingService** — 신규 가입 시 referral_code로 Referral(pending) 생성 - 자기 자신 추천 방지 - 이미 추천인 있으면 무시 2. **Referrals::RewardService** — 강점 진단 완료 시 리퍼럴 보상 - 조건: referee의 첫 strength 진단 완료 - referrer의 월간 보상 < 10개 - referrer diagnosis_balance +1 - referral status → "rewarded" - 중복 보상 방지 ## 완료 기준 - [ ] TrackingService: 정상 생성, 자기추천 방지, 중복 방지 - [ ] RewardService: 보상 지급, 월 10개 상한, 중복 방지, strength 아닌 진단은 무시 - [ ] 테스트 커버리지 95%+ ## 의존성 PR#1 완료 후
[할인코드확장 PR2] ValidationService scope 검증 + 공개 API 확장
## 목표 ValidationService가 apply_scope별로 할인코드 적용 가능 여부를 검증하도록 확장 ## 변경 사항 ### ValidationService - product: → item: 파라미터 변경 (Product, Expert::Course, Study::Course 수용) - apply_scope별 검증: all→항상, report→Product만, education→Expert::Course/Study::Course만, specific→items 존재 체크 ### DiscountCodesController#validate - item_type, item_id optional 파라미터 추가 - 허용 타입: Product, Expert::Course, Study::Course - 기존 code+amount만 보내는 호출 하위호환 유지 ## 완료 기준 - [ ] ValidationService scope별 검증 정상 - [ ] report scope에서 교육 거부 - [ ] education scope에서 리포트 거부 - [ ] specific scope에서 미등록 항목 거부 - [ ] 공개 API item_type/item_id 파라미터 동작 - [ ] 기존 리포트 결제 할인코드 흐름 정상 (하위호환) ## 의존성: PR #1 완료 필수 ## 관련 파일 - app/services/discount_codes/validation_service.rb - app/controllers/discount_codes_controller.rb - test/services/discount_codes/validation_service_test.rb - test/controllers/discount_codes_controller_test.rb
SVG 원형 차트 헬퍼 + PDF 파트셜 생성
circular_chart_controller.js 로직을 분석하여 서버 사이드 SVG 원형 차트를 생성하는 헬퍼 메서드를 만들고, PDF 파트셜 _way_map_chart.html.erb를 생성한다. 작업 범위: 1. app/helpers/pdf_chart_helper.rb에 way_map_svg 메서드 추가 2. app/views/diagnoses/pdf/_way_map_chart.html.erb 파트셜 생성 3. 9 WAY 점수를 방사형 차트로 시각화 4. Domain 섹터 색상 적용 (Thinking: #3B82F6, Relationship: #10B981, Execution: #F59E0B) 참고 파일: - app/javascript/controllers/circular_chart_controller.js (현재 JS 차트 로직) - app/helpers/chart_helper.rb (색상 정의, WAY_DOMAIN_MAP 등) - /mnt/c/dev/9way/components/diagnosis/way-map-chart.tsx (레거시 차트)
사용자 + 진단 분석 뷰
users.html.erb (사용자 가입 추이 차트, 역할 분포, 월간 활성 사용자) + diagnoses.html.erb (진단 완료 추이, 유형별 분포, 상태별 통계, 평균 완료 시간) 뷰 생성. StatisticsController의 인스턴스 변수 활용. 의존: 인프라 설정 서브 티켓 완료 후 시작.
[Phase 6] PR 6-2: Workshop Admin + 공개 페이지
## 개요 워크숍 Admin CRUD + edu.9way.co.kr/workshops 공개 페이지 ## 생성할 파일 - `app/controllers/admin/expert_courses_controller.rb` - `app/controllers/study/workshops_controller.rb` - `app/views/admin/expert_courses/` (index, show, _form) - `app/views/study/workshops/` (index, show, apply) ## 완료 기준 - Admin 워크숍 CRUD + 신청자 관리 - edu.9way.org/workshops 목록/상세 - 워크숍 신청 폼 - 워크숍 완료 → 보너스 수강권 자동 발급 ## 의존성 PR 6-1 (Workshop 모델)
[리퍼럴 PR3] 컨트롤러 — ref 파라미터 추적 + 크레딧 차감 + 보상 트리거
## 설명 컨트롤러 레이어에서 리퍼럴 추적, 진단 크레딧 차감, 보상 트리거 연결. ### 변경 사항 1. **ApplicationController**: `before_action :track_referral_code` — ?ref= 파라미터 → 쿠키(30일) 저장 2. **DiagnosesController#start**: Big5/성장/행복도 진단 시작 시 진단 크레딧 1개 차감, 부족 시 에러 3. **DiagnosisSessionsController#complete**: 강점 진단 완료 시 Referrals::RewardService 호출 4. **회원가입 흐름**: OAuth/매직링크 가입 시 쿠키의 ref로 Referrals::TrackingService 호출 5. **공유 URL**: result.html.erb, _tab_summary.html.erb에서 공유 URL에 ?ref=current_user.referral_code 추가 ## 완료 기준 - [ ] ref 파라미터 쿠키 저장 동작 - [ ] Big5/성장/행복도 진단 시작 시 크레딧 차감 - [ ] 크레딧 부족 시 진단 시작 불가 + 안내 - [ ] 강점 진단 완료 시 리퍼럴 보상 트리거 - [ ] 신규 가입 시 리퍼럴 추적 기록 - [ ] 공유 URL에 ref 파라미터 포함 - [ ] 테스트 통과 ## 의존성 PR#2 완료 후
[할인코드확장 PR3] Expert/Study 서비스 할인 연동
## 목표 Expert::ApplicationService와 Study::EnrollmentService에 할인코드 지원 추가 ## 변경 사항 ### Expert::ApplicationService - discount_code_id: 파라미터 추가 (optional, default: nil) - ValidationService로 검증 → discount_amount 계산 - Payments::CheckoutService에 discount_amount, discount_code_id 전달 (이미 지원됨) - 할인코드 사용 횟수 증가 (discount_code.use!) ### Study::EnrollmentService - discount_code_id: 파라미터 추가 (optional, default: nil) - prepare_checkout에서 할인코드 검증 + 할인액 계산 - create_pending_payment에서 할인된 금액으로 Payment 생성 ### WorkshopPaymentsController - create 액션에서 params[:discount_code_id] 받아서 ApplicationService에 전달 ## 완료 기준 - [ ] Expert 교육 결제 시 할인코드 적용 정상 - [ ] Study 온라인강좌 결제 시 할인코드 적용 정상 - [ ] 할인코드 없이 기존 결제 흐름 정상 (하위호환) - [ ] 무효 할인코드 거부 정상 ## 의존성: PR #2 완료 필수 ## 관련 파일 - app/services/expert/application_service.rb - app/services/study/enrollment_service.rb - app/controllers/study/workshop_payments_controller.rb
가이드 텍스트 i18n + PDF 파트셜 + 오케스트레이터 수정
차트 해석 가이드 텍스트를 i18n에 추가하고, PDF 파트셜을 생성하고, PDF 오케스트레이터를 수정하여 새 페이지를 삽입한다. 작업 범위: 1. config/locales/ko.yml에 가이드 텍스트 5단락 추가 (diagnosis.pdf.chart_guide 키) 2. app/views/diagnoses/pdf/_way_map_guide.html.erb 파트셜 생성 3. app/views/diagnoses/pdf.html.erb 오케스트레이터 수정 (P3: guide+chart 페이지 삽입, 이후 페이지 번호 조정) 4. PDF CSS에 가이드 텍스트 스타일 추가 가이드 텍스트 원문 (레거시): 1. "강점을 제대로 이해하려면 3가지 핵심 질문에 답할 수 있어야 합니다..." 2. "먼저 아래 그래프에서 사고, 관계, 행동 세 영역 중..." 3. "다음으로 원 주변에 있는 부채꼴 면적인, 9가지 강점 역할을..." 4. "마지막으로 강점 역할 바깥쪽에 위치한 DNA 아이콘을..." 5. "이제 다음 페이지들을 천천히 읽어보세요..." 참고 파일: - /mnt/c/dev/9way/lib/pdf/templates/diagnosis/result.tsx lines 112-138 - app/views/diagnoses/pdf.html.erb (현재 오케스트레이터) - config/locales/ko.yml (현재 i18n)
매출 분석 뷰 + 통합 테스트
revenue.html.erb (매출 추이 차트, 결제 유형 분포, 워크스페이스 크레딧 사용량) 뷰 생성 + Admin::StatisticsController 통합 테스트 작성. StatisticsController의 인스턴스 변수 활용. 의존: 인프라 설정 서브 티켓 완료 후 시작.
[Phase 7] PR 7-1: 수강료 Toss Payments 결제
## 개요 Study enrollment Toss Payments 결제 연동 ## 생성할 파일 - `app/services/study/enrollment_service.rb` - `app/controllers/study/payments_controller.rb` - `test/services/study/enrollment_service_test.rb` ## 수정할 파일 - `app/services/payments/checkout_service.rb` — study_enrollment payment_type 추가 - `app/services/payments/confirmation_service.rb` — 수강 확인 콜백 ## 완료 기준 - Toss Payments 수강료 결제 (88,000원/3개월) - 결제 완료 → 자동 Enrollment 생성 - 기존 결제 서비스 패턴 재사용 ## 의존성 PR 5-1 (Enrollment + Progress)
[리퍼럴 PR4] 진단 크레딧 개인 구매 (PG 결제, 3,000원)
## 설명 개인이 진단 크레딧을 직접 구매할 수 있는 PG 결제 플로우. ### 구현 1. **Payments::DiagnosisCreditPurchaseService** - prepare_checkout!: Payment(pending) 생성, Toss Payments 연동 - complete_purchase!: diagnosis_balance += quantity 2. **DiagnosisCreditsController**: 구매 페이지, 결제 요청 3. **ConfirmationService 수정**: diagnosis_credit 결제 타입 후처리 추가 4. **Product seed**: diagnosis_credit_1 (3,000원) 5. **라우트 추가** ### 가격 - 1크레딧 = 3,000원 (VAT 포함) - 기존 Toss Payments 인프라 재사용 ## 완료 기준 - [ ] 진단 크레딧 구매 → PG 결제 → diagnosis_balance 증가 - [ ] 결제 실패 시 롤백 - [ ] Product seed 데이터 - [ ] 테스트 통과 ## 의존성 PR#1 완료 후 (PR#2, #3과 병렬 가능)
[할인코드확장 PR4] Admin 폼 UI — 라디오 4개 + 조건부 체크박스
## 목표 Admin 할인코드 관리 폼에서 apply_scope 4가지를 선택하고, specific 시 상품/교육과정을 체크박스로 선택 ## 변경 사항 ### Admin::DiscountCodesController - new/edit/create/update에서 Expert::Course, Study::Course 목록 전달 - discount_code_params에 apply_scope, discount_code_item 관련 파라미터 추가 - show에서 apply_scope별 적용 대상 표시 ### _form.html.erb - applies_to_all 체크박스 → apply_scope 라디오 4개 (전체/리포트/교육/특정선택) - specific 선택 시 3개 섹션: Products, Expert::Course, Study::Course - Stimulus toggle 컨트롤러 활용 ### i18n - config/locales/ko.yml: admin.discount_codes.form 키 추가 ## 완료 기준 - [ ] 라디오 4개 정상 렌더링 - [ ] specific 선택 시 체크박스 노출 - [ ] 생성/수정 시 apply_scope + items 저장 정상 - [ ] show 페이지에서 적용 범위 표시 ## 의존성: PR #1 완료 필수 ## 관련 파일 - app/controllers/admin/discount_codes_controller.rb - app/views/admin/discount_codes/_form.html.erb - app/views/admin/discount_codes/show.html.erb - config/locales/ko.yml
프로필 탭 네비게이션 + 라우트 + i18n
## 목표 프로필 페이지에 탭 네비게이션(내 정보/결제/크레딧)을 구현하고, 라우트와 i18n을 설정합니다. ## 완료 기준 1. `app/views/shared/_profile_tabs.html.erb` 탭 partial 생성 - 3개 탭: 내 정보(profile_path), 결제(payments_path), 크레딧(credits_path) - 현재 페이지 활성 탭 표시 (Tailwind CSS) - 모바일/데스크탑 반응형 2. `app/views/profiles/show.html.erb` 수정 - 기존 프로필 정보 위에 탭 네비게이션 추가 - 기존 UI 유지하면서 탭 통합 3. `config/routes.rb` 수정 - `resources :credits, only: [:index]` 추가 (locale scope 내) 4. i18n 키 추가 (4개 언어: ko, en, zh, vi) - `config/locales/ko.yml`: profile.tabs.info, profile.tabs.payments, profile.tabs.credits - `config/locales/en.yml`, `zh.yml`, `vi.yml` 동일 구조 5. 통합 테스트 작성 ## 담당 파일 (이 파일만 수정) - `app/views/shared/_profile_tabs.html.erb` (신규) - `app/views/profiles/show.html.erb` (수정) - `config/routes.rb` (수정) - `config/locales/ko.yml` (수정) - `config/locales/en.yml` (수정) - `config/locales/zh.yml` (수정) - `config/locales/vi.yml` (수정) - `test/integration/profile_tabs_test.rb` (신규)
[Phase 7] PR 7-2: 워크숍 Toss Payments 결제
## 개요 워크숍 신청 Toss Payments 결제 연동 ## 생성할 파일 - `app/services/expert/application_service.rb` - `test/services/expert/application_service_test.rb` ## 완료 기준 - 워크숍 신청 + Toss Payments 결제 - 결제 완료 → 신청 상태 변경 ## 의존성 PR 6-1 (Workshop 모델)
[리퍼럴 PR5] 뷰/UI — 크레딧 현황 + 리퍼럴 대시보드 + 구매 페이지
## 설명 사용자 대면 UI 구현. ### 페이지 1. **크레딧 현황** (credits/index 수정): 리포트 크레딧 + 진단 크레딧 분리 표시 2. **리퍼럴 현황**: 초대한 친구 목록, 월간 보상 카운트 (N/10) 3. **진단 크레딧 구매 페이지**: 3,000원, Toss Payments 연동 4. **크레딧 부족 안내**: 진단 시작 시 크레딧 없으면 구매/공유 안내 모달 ### i18n - ko, en, zh, vi 4개 locale 지원 ## 완료 기준 - [ ] 크레딧 페이지에 2종류 크레딧 분리 표시 - [ ] 리퍼럴 현황 페이지 정상 렌더링 - [ ] 진단 크레딧 구매 페이지 → 결제 연동 - [ ] 크레딧 부족 시 안내 UI - [ ] 모바일 반응형 - [ ] 4개 locale i18n ## 의존성 PR#3, PR#4 완료 후
[할인코드확장 PR5] 교육 결제 페이지 할인코드 UI + 레거시 정리
## 목표 교육 결제 페이지에 할인코드 입력 UI 추가 + 레거시 테이블/컬럼 정리 ## 변경 사항 ### 교육 결제 페이지 UI - Workshop 결제 페이지에 payments/_discount_code.html.erb partial 삽입 - Stimulus discount-code 컨트롤러에 item_type/item_id 파라미터 전달 - Study::Course 결제 페이지에도 동일 적용 ### 레거시 정리 (2단계 제거) - 마이그레이션: discount_code_products 테이블 삭제 - 마이그레이션: applies_to_all 컬럼 삭제 - DiscountCodeProduct 모델 파일 삭제 - DiscountCode에서 ignored_columns 제거 ## 완료 기준 - [ ] 교육 결제 페이지에서 할인코드 입력/검증 UI 동작 - [ ] 레거시 테이블/컬럼 완전 제거 - [ ] bin/ci 전체 통과 ## 의존성: PR #3, PR #4 완료 필수 ## 관련 파일 - app/views/payments/_discount_code.html.erb - app/javascript/controllers/discount_code_controller.js - app/models/discount_code_product.rb (삭제)
Admin Products 라우트 + 컨트롤러 구현
Admin::ProductsController CRUD 구현 ## 수정 파일 - config/routes.rb: admin 네임스페이스에 resources :products 추가 (toggle_active member 라우트 포함) - app/controllers/admin/products_controller.rb: 새 파일 생성 ## 컨트롤러 요구사항 - Admin::BaseController 상속 (require_admin 자동 적용) - 액션: index, show, new, create, edit, update, toggle_active - index: 검색(q param, name ILIKE), 필터(product_type, active), 정렬(sort/direction), 페이지네이션(PER_PAGE=30) - show: @product with product_prices eager load - create/update: strong params (name, slug, description, price, currency, product_type, active, product_prices_attributes[id, currency, amount, _destroy]) - toggle_active: active 토글 후 redirect - accepts_nested_attributes_for :product_prices (모델에 추가 필요) - flash 메시지: notice/alert, i18n 키 사용 - 기존 Admin::WorkspacesController 패턴 참고 ## 완료 기준 - rails routes | grep admin/products 로 라우트 확인 - 컨트롤러 파일이 기존 패턴과 일관성 있음
[Phase 7] PR 7-3: 과제 제출 + 인증서 PDF
## 개요 과제 제출/리뷰 + 수료/자격 인증서 PDF 생성 ## 생성할 파일 - `db/migrate/xxx_create_study_assignments.rb` - `db/migrate/xxx_create_study_certificates.rb` - `app/models/study/assignment.rb` - `app/models/study/certificate.rb` - `app/controllers/study/assignments_controller.rb` - `app/controllers/study/certificates_controller.rb` - `app/services/study/certificate_generator.rb` - `app/views/study/assignments/` (new, show) - `app/views/study/certificates/show.html.erb` ## 스키마 - study_assignments: enrollment_id(FK), user_id(FK), course_id(FK), status(enum: pending/reviewed/approved/rejected), feedback, file(ActiveStorage 10MB) - study_certificates: user_id(FK), course_id(FK), enrollment_id(FK), certificate_type(enum: completion/qualification), certificate_number(unique, 9W-SC-YYYY-NNNNN) ## 완료 기준 - 과제 제출 (10MB 파일, ActiveStorage) - Admin 과제 검토 (approve/reject + feedback) - 80% 수강 완료 → 수료 인증서 - 과제 승인 → 코치 자격 인증서 - PDF 생성 (Grover, 기존 PdfGenerator 패턴) - 인증서 번호: 9W-SC-YYYY-NNNNN ## 의존성 PR 5-1 (Enrollment + Progress)
개인 크레딧 페이지 생성
## 목표 개인 크레딧 잔액 및 거래 내역을 보여주는 페이지를 생성합니다. ## 완료 기준 1. `app/controllers/credits_controller.rb` 생성 - layout "authenticated" - index 액션: Current.user의 크레딧 잔액 + CreditTransaction 내역 (최신순) - require_authentication 적용 2. `app/views/credits/index.html.erb` 생성 - 상단: `shared/_profile_tabs` partial 렌더 (탭 네비게이션) - 크레딧 잔액 카드 (큰 숫자로 표시) - 거래 내역 테이블: 날짜, 유형(purchase/usage/admin_grant/refund), 설명, 변동량(+/-), 잔액 - 변동량: 양수는 초록, 음수는 빨강 - 빈 상태 처리 (거래 내역 없을 때) - Tailwind CSS, 기존 shared/_card partial 활용 3. 통합 테스트 작성 - 로그인 후 크레딧 페이지 접근 가능 - 크레딧 잔액 표시 확인 - 거래 내역 표시 확인 ## 참고: 기존 모델 - UserCredit: user_id(unique), balance(integer) - CreditTransaction: user_id, amount, balance_after, transaction_type, description, payment_id, diagnosis_session_id - User#credit_balance: credit&.balance || 0 - User#ensure_credit!: credit || create_credit!(balance: 0) ## 담당 파일 (이 파일만 수정/생성) - `app/controllers/credits_controller.rb` (신규) - `app/views/credits/index.html.erb` (신규) - `test/controllers/credits_controller_test.rb` (신규) ## 주의사항 - `shared/_profile_tabs` partial은 agent-nav가 생성합니다. 해당 파일이 아직 없으면, 탭 partial 렌더링 부분을 주석 처리하고 나중에 통합하세요. - routes.rb는 agent-nav가 수정합니다. credits 라우트가 이미 추가되어 있을 것입니다. - i18n 키는 agent-nav가 관리합니다. t() 호출은 사용하되, locales 파일은 직접 수정하지 마세요.
서브도메인 라우팅 + 베이스 컨트롤러 + 레이아웃 구현
## 작업 내용 Blog/Study 서브도메인 라우팅 인프라를 TDD로 구현합니다. ### 1. routes.rb 서브도메인 constraints ```ruby constraints subdomain: "blog" do scope module: :blog do root "posts#index" # 임시 — Phase 2에서 실제 구현 end end constraints subdomain: "edu" do scope module: :study do root "courses#index" # 임시 — Phase 4에서 실제 구현 end end ``` - 기존 라우트(locale scope, admin, api)에 영향 없도록 배치 - 메인 도메인(서브도메인 없음)은 기존 라우트 그대로 ### 2. 베이스 컨트롤러 - `app/controllers/blog/base_controller.rb`: ApplicationController 상속, `layout "blog"`, `allow_unauthenticated_access` - `app/controllers/study/base_controller.rb`: ApplicationController 상속, `layout "study"`, `allow_unauthenticated_access` ### 3. 임시 컨트롤러 (라우팅 확인용) - `app/controllers/blog/posts_controller.rb`: index 액션만 (빈 페이지) - `app/controllers/study/courses_controller.rb`: index 액션만 (빈 페이지) ### 4. 레이아웃 - `app/views/layouts/blog.html.erb`: 기존 application 레이아웃 참고, Blog 전용 navbar/footer - `app/views/layouts/study.html.erb`: 기존 application 레이아웃 참고, Edu 전용 navbar/footer - 기존 shared partials 최대 활용 (_flash 등) ### 5. 환경 설정 - `config/environments/development.rb`: `config.hosts` 에 blog.localhost, edu.localhost 추가 - `config/environments/production.rb`: blog.9way.org, edu.9way.org 추가 (*.9way.org 이미 있으면 확인만) ### 6. 테스트 (TDD) - `test/controllers/blog/base_controller_test.rb` - `test/controllers/study/base_controller_test.rb` - 서브도메인 라우팅 동작 확인 - 기존 라우트 영향 없음 확인 ### 완료 기준 - blog.localhost:3000 접근 시 Blog 레이아웃 렌더링 - edu.localhost:3000 접근 시 Study 레이아웃 렌더링 - 기존 localhost:3000 라우트 정상 동작 - 테스트 전체 통과 (bin/rails test)
Blog 모델 4개 + 마이그레이션 구현 (TDD)
Blog::Category, Blog::Post, Blog::Comment, Blog::Like 4개 모델과 마이그레이션을 TDD로 구현합니다. ## 스키마 - blog_categories: id(uuid), name(string), slug(string, unique index), description(text), timestamps - blog_posts: id(uuid), author_id(uuid FK→users), category_id(uuid FK→blog_categories), title(string), slug(string, unique index), content(text/HTML), excerpt(text), description(text), keywords(string), status(integer, enum: draft/published/archived), view_count(integer, default: 0), published_at(datetime), timestamps, featured_image(ActiveStorage) - blog_comments: id(uuid), post_id(uuid FK→blog_posts), author_id(uuid FK→users), parent_id(uuid, self-ref nullable), content(text), timestamps - blog_likes: id(uuid), post_id(uuid FK→blog_posts), user_id(uuid FK→users), timestamps, unique index(post_id, user_id) ## 완료 기준 - 마이그레이션 성공 + 롤백 + 재마이그레이션 - 모델 유효성 검사 테스트 통과 - slug 자동 생성 (parameterize), enum 동작, 연관관계 동작 - featured_image ActiveStorage 첨부 - blog_posts에 author_id, category_id 인덱스
PR 3-2: 블로그 SEO + RSS 구현
블로그 SEO 메타태그(OpenGraph, Twitter Card, JSON-LD BlogPosting), RSS 피드, canonical URL 구현. TDD 기반. ## 담당 파일 - app/views/blog/shared/_meta_tags.html.erb (신규) - app/views/blog/posts/feed.rss.builder (신규) - app/views/layouts/blog.html.erb (meta_tags partial yield :head 삽입) - app/controllers/blog/posts_controller.rb (feed 액션 추가) - config/routes.rb (blog subdomain에 feed 라우트 추가) - test/controllers/blog/posts_controller_test.rb (feed 테스트 추가) ## 주의사항 - routes.rb blog subdomain 블록에만 feed 라우트 추가 - blog/posts/show.html.erb 수정 금지 (PR 3-3 담당)
PR 2-3: TipTap 에디터 통합 구현
블로그 Admin 글 작성 폼에 TipTap 리치 텍스트 에디터 Stimulus 통합. TDD 기반. ## 담당 파일 - app/javascript/controllers/tiptap_controller.js (신규) - app/javascript/controllers/image_upload_controller.js (신규) - config/importmap.rb (TipTap CDN pin 추가) - app/views/admin/blog_posts/_form.html.erb (textarea → TipTap 에디터) - test/controllers/admin/blog_posts_controller_test.rb (에디터 관련 테스트) ## 완료 기준 - TipTap 에디터 렌더링 + 기본 포맷팅 (볼드, 이탤릭, 헤딩, 리스트, 링크) - hidden field로 HTML content 서버 전송 - 이미지 삽입 (ActiveStorage Direct Upload) ## 리스크 - TipTap importmap 호환성 — CDN 빌드 필요 시 jsDelivr/unpkg 활용
Admin Products 뷰 + 사이드바 + i18n 구현
Admin Products 뷰 파일, 사이드바 메뉴, i18n 번역 구현 ## 생성할 파일 - app/views/admin/products/index.html.erb - app/views/admin/products/show.html.erb - app/views/admin/products/new.html.erb - app/views/admin/products/edit.html.erb - app/views/admin/products/_form.html.erb ## 수정할 파일 - app/views/admin/shared/_sidebar.html.erb: products 메뉴 추가 (diagnosis_sessions와 workspaces 사이) - app/views/admin/shared/_nav_item.html.erb: products 아이콘 SVG 추가 (쇼핑백/상자 아이콘) - config/locales/ko.yml: admin.nav.products, admin.products.* 번역 키 추가 - config/locales/en.yml: 영문 번역 추가 ## 뷰 요구사항 ### index.html.erb - 기존 admin/workspaces/index.html.erb 패턴 참고 - 검색바 (name 검색) - 필터: product_type (report, credit_pack), active (true/false/all) - 테이블: slug, name, product_type, price, currency, active 상태 배지, 등록일 - 각 행에 show/edit 링크 - 페이지네이션 (shared/_pagination 파티션 사용) - 새 상품 등록 버튼 ### show.html.erb - 상품 기본 정보 카드 (name, slug, description, product_type, price, currency, active, metadata) - 다통화 가격 목록 (ProductPrice 테이블) - 편집/목록 버튼 ### _form.html.erb (new/edit 공유) - 필드: name, slug, description, price, currency (select), product_type (select), active (checkbox) - ProductPrice nested form: currency + amount 행 추가/삭제 가능 (Stimulus 없이 순수 HTML로) - 기존 shared 파티션(shared/_input, shared/_button, shared/_card) 활용 ### 사이드바 - icon key: "products" - i18n key: admin.nav.products - path: admin_products_path - active: controller_name == "products" ### i18n 키 (ko.yml) - admin.nav.products: "상품" - admin.products.title: "상품 관리" - admin.products.new_product: "새 상품 등록" - admin.products.edit_product: "상품 수정" - admin.products.form.*: 폼 라벨들 - admin.products.show.*: 상세 화면 라벨들 ## Tailwind CSS - 기존 admin 뷰와 동일한 디자인 시스템 사용 - 반응형 테이블, 상태 배지, 카드 레이아웃 ## 완료 기준 - 사이드바에 상품 메뉴가 표시됨 - 모든 뷰 파일이 기존 admin 패턴과 일관됨 - i18n 키가 누락 없이 등록됨
Study 모델 3개 + 마이그레이션 + 시드 구현 (TDD)
Study::Course, Study::Chapter, Study::Lesson 3개 모델과 마이그레이션, 시드 데이터를 TDD로 구현합니다. ## 스키마 - study_courses: id(uuid), slug(string, unique index), title(string), subtitle(string), description(text), price_monthly(integer, default: 88000), completion_threshold(integer, default: 80), total_chapters(integer, default: 0), total_lessons(integer, default: 0), total_duration(integer, default: 0), is_active(boolean, default: false), timestamps - study_chapters: id(uuid), course_id(uuid FK→study_courses), chapter_number(integer), title(string), description(text), is_preview(boolean, default: false), display_order(integer), timestamps, unique index(course_id, chapter_number) - study_lessons: id(uuid), chapter_id(uuid FK→study_chapters), course_id(uuid FK→study_courses), lesson_number(integer), title(string), video_id(string), video_type(integer, enum: vimeo/youtube), duration_minutes(integer, default: 0), is_preview(boolean, default: false), materials_url(string), timestamps, unique index(chapter_id, lesson_number) ## 완료 기준 - 마이그레이션 성공 + 롤백 + 재마이그레이션 - 코스-챕터-레슨 계층 연관관계 동작 - course.recalculate_totals! 콜백 (챕터/레슨 수, 총 시간 자동 계산) - 시드: "강점코치 입문 과정" 커리큘럼 (11챕터, ~20레슨) - slug 자동 생성
PR 3-3: 블로그 댓글 + 좋아요 구현
블로그 댓글(Turbo Stream) + 좋아요(Turbo Frame) 구현. TDD 기반. ## 담당 파일 - app/controllers/blog/comments_controller.rb (신규) - app/controllers/blog/likes_controller.rb (신규) - app/views/blog/comments/ (신규 디렉토리) - app/views/blog/likes/ (신규 디렉토리) - app/views/blog/posts/show.html.erb (댓글/좋아요 섹션 추가) - config/routes.rb (blog subdomain에 comments/likes 중첩 라우트) - test/controllers/blog/comments_controller_test.rb (신규) - test/controllers/blog/likes_controller_test.rb (신규) ## 주의사항 - routes.rb blog subdomain posts 리소스에 comments/likes 중첩 - blog/shared/_meta_tags.html.erb 수정 금지 (PR 3-2 담당) - 댓글: Turbo Stream, 1단계 쓰레드 - 좋아요: Turbo Frame 토글 - 비로그인 → 로그인 유도, 본인 댓글만 삭제(admin 전체)
PR 5-2: 교육 공개 페이지 구현
edu 서브도메인 공개 페이지: 코스 목록, 코스 상세, 레슨 플레이어, 수강등록. TDD 기반. ## 담당 파일 - app/controllers/study/courses_controller.rb (신규) - app/controllers/study/lessons_controller.rb (신규) - app/controllers/study/enrollments_controller.rb (신규) - app/views/study/courses/index.html.erb (신규) - app/views/study/courses/show.html.erb (신규) - app/views/study/lessons/show.html.erb (신규) - app/views/layouts/study.html.erb (수정 — 네비게이션 등) - config/routes.rb (edu subdomain에 라우트 추가) - test/controllers/study/ (3개 컨트롤러 테스트) ## 완료 기준 - edu 서브도메인 → 활성 코스 목록 - 코스 상세 (커리큘럼, 미리보기, 가격) - 수강 등록 (로그인 필요) - 레슨 시청 (등록자만, 미리보기는 모두) - Vimeo/YouTube 비디오 임베드 - 미등록자: 미리보기 레슨만 접근 ## 주의사항 - Study::BaseController 상속 (layout "study", allow_unauthenticated_access) - routes.rb edu subdomain 블록 내에만 라우트 추가 - 다른 routes 영역 수정 금지
결제 내역 UI 개선 + 취소 정책
## 목표 결제 내역 페이지에 프로필 탭을 적용하고, 영수증 링크를 강화하며, 리포트 결제 취소 불가 정책을 구현합니다. ## 완료 기준 ### 1. 결제 내역 UI 개선 - `app/views/payments/index.html.erb` 수정 - 상단에 `shared/_profile_tabs` partial 렌더 (탭 네비게이션) - 각 결제 항목에 영수증 링크 표시 (receipt_url이 있으면) - 취소 불가 상품은 "취소불가" 배지 표시 - `app/views/payments/show.html.erb` 수정 - 상단에 `shared/_profile_tabs` partial 렌더 - 리포트 결제일 경우 취소 버튼 대신 "리포트 결제는 취소할 수 없습니다" 안내 ### 2. 리포트 결제 취소 불가 정책 - `app/models/payment.rb` 수정 - `refundable?` 메서드에 `payment_type != "REPORT"` 조건 추가 - 즉: completed && not canceled && 7일 이내 && payment_type != "REPORT" - `app/services/payments/cancellation_service.rb` 수정 (필요시) - 리포트 결제 취소 요청 시 명확한 에러 메시지 ### 3. 테스트 - Payment#refundable? 테스트: 리포트 결제는 false 반환 - CancellationService 테스트: 리포트 결제 취소 거부 - 통합 테스트: 결제 내역 페이지 탭 표시, 리포트 결제 취소 버튼 미표시 ## 참고: 기존 코드 - Payment.payment_type: REPORT, CAREER_UP, PREMIUM, ANALYSIS_CREDIT, PACKAGE, WORKSHOP, STRENGTH_DIARY, CUSTOM - Payment#refundable?: completed? && canceled_at.nil? && approved_at > 7.days.ago - receipt_url 컬럼이 이미 존재 ## 담당 파일 (이 파일만 수정) - `app/views/payments/index.html.erb` (수정) - `app/views/payments/show.html.erb` (수정) - `app/models/payment.rb` (수정) - `app/services/payments/cancellation_service.rb` (수정, 필요시) - `test/models/payment_test.rb` (수정/생성) - `test/services/payments/cancellation_service_test.rb` (수정/생성) - `test/integration/payments_profile_tab_test.rb` (신규) ## 주의사항 - `shared/_profile_tabs` partial은 agent-nav가 생성합니다. 아직 없으면 주석 처리 후 나중에 통합. - i18n 키는 agent-nav가 관리합니다. t() 호출은 사용하되, locales 파일은 직접 수정하지 마세요. - routes.rb는 수정하지 마세요 (agent-nav 담당)
UX/UI 디자인 검토 (Blog + Study 모델)
Blog 모델과 Study 모델 구현 완료 후 디자인 시스템 준수 여부를 검토합니다. 모델 레이어이므로 주로 시드 데이터의 적절성, 모델 구조의 사용자 경험 지원 여부를 확인합니다.
PR 5-1: Enrollment + Progress 모델 구현
교육 수강등록(Enrollment) + 진도추적(Progress) 모델 구현. TDD 기반. ## 스키마 (티켓 명세 기준) ### study_enrollments - user_id(FK), course_id(FK) - subscription_type: enum (monthly/quarterly/workshop_bonus/admin_grant) - status: enum (active/pending/expired/cancelled/completed) - payment_status, payment_key - starts_at, ends_at - unique index: [user_id, course_id] (동일 과정 중복 등록 방지) ### study_progresses - enrollment_id(FK), user_id(FK), lesson_id(FK) - watched_seconds:integer, completed_percentage:integer - is_completed:boolean (90%+ 시청 시 true) - last_position_seconds:integer - unique index: [enrollment_id, lesson_id] ## 비즈니스 로직 - 90%+ 시청 → is_completed = true - 80% 레슨 완료 → 코스 수료 ## 주의사항 - Study 모듈 table_name_prefix "study_", UUID PK - inverse_of 필수, fixtures에 _fixture: model_class:
PR 6-2: Workshop Admin + 공개 페이지 구현
워크숍 Admin CRUD + edu 서브도메인 워크숍 공개 페이지. TDD 기반. ## 담당 파일 (Admin) - app/controllers/admin/expert_courses_controller.rb (신규) - app/views/admin/expert_courses/ (index, show, new, edit, _form) - app/views/admin/shared/_sidebar.html.erb (워크숍 메뉴 추가) - config/routes.rb (admin namespace에 expert_courses 리소스 추가) - config/locales/ko.yml (admin.nav.workshops, admin.expert_courses.* 번역) ## 담당 파일 (공개 페이지) - app/controllers/study/workshops_controller.rb (신규) - app/views/study/workshops/index.html.erb (신규) - app/views/study/workshops/show.html.erb (신규) - app/views/study/workshops/_application_form.html.erb (신규) - config/routes.rb (edu subdomain에 workshops 라우트 추가) ## 완료 기준 - Admin: 워크숍 CRUD + 신청자 목록 + 상태 관리 - 공개: 워크숍 목록/상세 + 신청 폼 - 신청 시 로그인 필요 ## 주의사항 - Admin sidebar에 워크숍 메뉴 추가 (교육 메뉴 아래) - routes.rb: admin namespace에 expert_courses, edu subdomain에 workshops - 다른 routes 영역 수정 금지
Admin Products 통합 테스트 구현
Admin Products 통합 테스트 작성 ## 생성/수정할 파일 - test/integration/admin/products_test.rb: 새 파일 생성 - test/fixtures/products.yml: 기존 fixtures 확인 및 보완 - test/fixtures/product_prices.yml: 기존 fixtures 확인 및 보완 ## 테스트 요구사항 (Minitest) ### 인증/권한 테스트 - 비로그인 사용자 접근 시 로그인 페이지로 리다이렉트 - 일반 사용자 접근 시 접근 거부 - admin 사용자만 접근 가능 ### index 테스트 - 상품 목록 표시 - name 검색 (q 파라미터) - product_type 필터 - active 필터 - 정렬 (sort/direction 파라미터) ### CRUD 테스트 - new: 새 상품 폼 렌더링 - create: 유효한 파라미터로 상품 생성 - create: 무효한 파라미터(slug 중복 등)로 실패 - show: 상품 상세 + product_prices 표시 - edit: 편집 폼 렌더링 - update: 상품 정보 수정 - update: product_prices 추가/수정/삭제 ### toggle_active 테스트 - 활성 → 비활성 토글 - 비활성 → 활성 토글 ## 테스트 패턴 - 기존 test/integration/admin/workspaces_test.rb 패턴 참고 - sign_in_as(users(:admin_user)) 사용 - assert_response, assert_redirected_to, assert_difference 활용 - fixtures 기반 테스트 데이터 ## 완료 기준 - bin/rails test test/integration/admin/products_test.rb 통과 - 모든 CRUD + 필터 + 권한 테스트 커버
테스트 및 품질 검증 (Blog + Study 모델)
Blog 모델과 Study 모델 구현 + UX/UI 검토 완료 후 전체 테스트와 품질을 검증합니다. bin/ci 실행, N+1 쿼리, 보안 점검을 수행합니다.
PR 6-1: Workshop(Expert) 모델 구현
전문가 과정 모델 계층 구현. TDD 기반. ## 스키마 (티켓 명세 기준) ### expert_courses - level:integer(0-3,12), batch_number:integer - start_date:date, end_date:date - max_participants:integer, status: enum (draft/open/in_progress/completed) - location:string, price:integer, time:string - title:string, slug:string(unique), description:text ### expert_course_applications - applicant_id(FK→users), course_id(FK) - status: enum (pending/approved/rejected/cancelled) - payment_status:string, payment_amount:integer, payment_key:string - unique index: [applicant_id, course_id] ### expert_certificates - user_id(FK), course_id(FK), application_id(FK) - certificate_type: enum (completion/qualification) - certificate_number:string (unique, 형식: 9W-C-YYYY-NQ-NNN) - issued_at:datetime, expires_at:datetime(nullable) ## 주의사항 - Expert 모듈 table_name_prefix "expert_", UUID PK - inverse_of 필수, 슬러그 한국어 fallback - certificate_number 자동 생성 콜백 - fixtures에 _fixture: model_class: