diff --git a/design/uiux/uiux.md b/design/uiux/uiux.md index db678ce..f4f16a9 100644 --- a/design/uiux/uiux.md +++ b/design/uiux/uiux.md @@ -628,12 +628,12 @@ graph TD #### 개요 - **목적**: 실시간 회의 진행 및 AI 기반 회의록 자동 작성 -- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020 +- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020, UFR-PART-010/020/030, UFR-HOST-010/020, UFR-TERM-010/020 - **비즈니스 중요도**: 높음 (핵심 화면) -- **접근 경로**: 템플릿선택 → "이 템플릿으로 시작" -- **권한**: - - 회의 시작/종료: 회의 생성자 전용 - - 참석자 초대: 모든 참석자 +- **접근 경로**: 대시보드 → "참여하기" 버튼 (페이지 전환) +- **권한** (MVP 개선): + - **회의 생성자 전용**: 회의 종료, 녹음 제어 (일시정지/재개/종료) + - **모든 참석자**: 회의 참여, AI 주요 내용 체크, 용어 확인, 관련 회의록 확인, 중도 퇴장 #### 주요 기능 1. 음성 녹음 및 실시간 텍스트 변환 (STT) diff --git a/design/userstory.md b/design/userstory.md index 12088f8..85daa61 100644 --- a/design/userstory.md +++ b/design/userstory.md @@ -1,6 +1,6 @@ -# AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.3.0) +# AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.4.0) -- [AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.3.0)](#ai기반-회의록-작성-및-이력-관리-개선-서비스---유저스토리-v230) +- [AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.4.0)](#ai기반-회의록-작성-및-이력-관리-개선-서비스---유저스토리-v240) - [차별화 전략](#차별화-전략) - [1. 기본 기능 (Hygiene Factors)](#1-기본-기능-hygiene-factors) - [2. 핵심 차별화 포인트 (Differentiators)](#2-핵심-차별화-포인트-differentiators) @@ -19,7 +19,6 @@ ### 2. 핵심 차별화 포인트 (Differentiators) - **맥락 기반 용어 설명**: 단순 용어 설명을 넘어, 관련 회의록과 업무이력을 바탕으로 실용적인 정보 제공 -- **강화된 Todo 연결**: Action item이 담당자의 Todo와 실시간으로 연결되고, 진행 상황이 회의록에 자동 반영 - **섹션 AI 요약 재생성**: 버튼 클릭으로 작성한 섹션 내용을 AI가 요약 (2-3문장, 2-5초 처리) - **지능형 회의 진행 지원**: 회의 패턴 분석을 통한 안건 추천, 효율성 분석 및 개선 제안 @@ -31,7 +30,6 @@ 2. **Meeting** - 회의, 회의록, Todo, 실시간 협업 통합 관리 - 회의 관리: 회의 예약, 시작, 종료 - 회의록 관리: 회의록 생성, 수정, 확정 - - Todo 관리: Todo 할당, 진행 상황 추적, 회의록 양방향 연결 - 실시간 협업: WebSocket 기반 실시간 동기화, 버전 관리, 충돌 해결 - 템플릿 관리: 회의록 템플릿 관리 - 통계 생성: 회의 및 Todo 통계 @@ -83,16 +81,15 @@ --- -### UFR-USER-020: [대시보드] 사용자로서 | 나는, 나의 회의 및 Todo 현황을 파악하기 위해 | 대시보드를 조회하고 싶다. +### UFR-USER-020: [대시보드] 사용자로서 | 나는, 나의 회의 현황을 파악하기 위해 | 대시보드를 조회하고 싶다. **수행절차:** 1. 로그인 후 대시보드 자동 표시 또는 하단 네비게이션에서 홈 아이콘 클릭 -2. 통계 블록 확인 (예정된 회의, 나의 Todo) +2. 통계 블록 확인 (예정된 회의, 작성중 회의록) 3. 최근 회의 목록 확인 (최대 3개) -4. 나의 Todo 목록 확인 (최대 3개) -5. 나의 회의록 목록 확인 (최대 4개) -6. 필요 시 섹션별 "전체 보기" 링크 클릭하여 상세 화면 이동 -7. FAB 버튼으로 회의 예약 또는 바로 시작 +4. 나의 회의록 목록 확인 (최대 4개) +5. 필요 시 "전체 보기" 링크 클릭하여 회의록 목록 화면 이동 +6. FAB 버튼으로 회의 예약 또는 바로 시작 **입력:** - 없음 (자동 렌더링) @@ -101,18 +98,16 @@ - 헤더: 사용자 이름, 오늘 예정된 회의 개수 또는 안내 메시지 - 통계 블록 (2열 그리드): - 예정된 회의: 전체 예정 + 진행 중 회의 개수 - - 나의 Todo: 현재 사용자에게 할당된 전체 Todo 개수 + - 작성중 회의록: 내가 참석한 회의 중 '작성중' 상태인 회의록 개수 (클릭 액션 없음, 정보 표시만) - 최근 회의 (회의록 미생성 우선, 빠른 일시 순, 최대 3개): - 회의 카드: 상태 배지, 제목, 생성자 표시(👑), 날짜/시간, 참석자 수, 장소, 액션 버튼 - 진행 중 회의: 좌측 녹색 바 강조, "참여하기" 버튼 - 예정 회의: 생성자인 경우 "수정" 버튼 - 완료 회의: "보기" 버튼 -- 나의 Todo (미완료 우선, 마감일 순, 최대 3개): - - Todo 카드: 체크박스, D-day 배지, 우선순위 배지, 제목, 회의록 링크, 마감일, 편집 버튼 - 나의 회의록 (최신순, 최대 4개, 2x2 그리드): - 회의록 카드: 상태 배지, 생성자 표시(👑), 제목, 날짜/시간, 참석자 수, 검증완료율(작성중인 경우) -- 사이드바 (데스크톱): 로고, 메뉴(회의록, Todo 관리), 사용자 정보, 로그아웃 -- 하단 네비게이션 (모바일): 홈(활성), 회의록, Todo +- 사이드바 (데스크톱): 로고, 메뉴(대시보드, 회의록), 사용자 정보, 로그아웃 +- 하단 네비게이션 (모바일): 홈(활성), 회의록 - FAB 메뉴: - 회의예약 버튼: 회의 예약 화면으로 이동 - 바로시작 버튼: 템플릿 선택 화면으로 이동 @@ -120,7 +115,6 @@ **예외처리:** - 로그인 안 된 경우: 로그인 화면으로 리다이렉트 - 최근 회의 없음: 빈 상태 표시 -- Todo 없음: "할당된 Todo가 없습니다" 빈 상태 표시 - 회의록 없음: "참여한 회의록이 없습니다" 빈 상태 표시 **관련 유저스토리:** @@ -129,7 +123,6 @@ - UFR-MEET-020: 템플릿선택 - UFR-MEET-046: 회의록목록조회 - UFR-MEET-047: 회의록상세조회 -- UFR-TODO-040: Todo관리 --- @@ -255,9 +248,13 @@ ### UFR-MEET-030: [회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다. +**회의 진입 경로:** +- **경로 1**: 대시보드(02-대시보드.html) → "바로시작" FAB 버튼 → 템플릿 선택(04-템플릿선택.html) → 회의 진행(05-회의진행.html) +- **경로 2**: 대시보드 → 진행 중 회의 "참여하기" 버튼 → 회의 진행(05-회의진행.html) + **수행절차:** -1. 템플릿 선택 완료 또는 대시보드에서 진행 중 회의 "참여하기" 클릭 -2. 회의 진행 화면(05-회의진행.html) 표시 +1. 위 경로 중 하나를 통해 회의 진행 화면(05-회의진행.html) 진입 +2. 회의 진행 화면 표시 3. 녹음 시작 확인 (자동 또는 수동) 4. 타이머 시작 (회의 진행 시간 표시) 5. 웨이브폼 애니메이션 표시 (녹음 상태 시각화) @@ -302,8 +299,12 @@ ### UFR-MEET-040: [회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다. +**권한:** +- 회의 생성자만 회의를 종료할 수 있음 +- 일반 참석자는 "회의 종료" 버튼이 표시되지 않음 + **수행절차:** -1. 회의 진행 화면에서 "회의 종료" 버튼 클릭 +1. 회의 진행 화면에서 "회의 종료" 버튼 클릭 (생성자 전용) 2. 종료 확인 모달: "회의를 종료하시겠습니까?" 확인 3. 회의 종료 화면(07-회의종료.html) 표시 (확인 전용, 편집 불가) 4. 통계 확인 (4열 그리드): @@ -450,8 +451,9 @@ - 주요 키워드 (태그) - 핵심내용 요약 - 결정사항 - - Todo 진행상황 (필터: 전체, 지연, 마감 임박, 완료) - - Todo 카드: 체크박스, D-day 배지, 우선순위 배지, 제목, 담당자, 마감일, 편집 버튼 + - Todo 진행상황 (단순 조회 전용) + - Todo 카드: 제목, 담당자, 마감일만 표시 + - D-day 체크, 우선순위 라벨 제거 - 진행률 표시 (수평 바): 완료 Todo 수 / 전체 Todo 수 - 관련회의록 (관련도 배지: 높음, 중간, 낮음) - **회의록 탭**: @@ -461,103 +463,102 @@ - AI 한줄 요약 (30자 이내, 읽기 전용) - AI 상세 요약 - 관련회의록 링크 -4. Todo 체크박스 클릭 시 완료/미완료 토글 -5. Todo 편집 버튼 클릭 시 편집 모달 -6. "수정" 버튼 클릭 시 회의록 수정 화면으로 이동 +4. "수정" 버튼 클릭 시 회의록 수정 화면으로 이동 **입력:** - 탭 클릭: 대시보드 / 회의록 -- Todo 필터: 전체, 지연, 마감 임박, 완료 -- Todo 체크박스: 완료/미완료 토글 -- Todo 편집 버튼: 편집 모달 열기 **출력/결과:** -- 헤더: 회의 제목, 생성자 표시 (👑), "수정" 버튼 (권한에 따라 표시) +- 헤더: 회의 제목, "수정" 버튼 (제목 텍스트 바로 옆에 배치, 권한에 따라 표시) - 대시보드 탭: - 통계, 키워드, 핵심내용, 결정사항, Todo 진행상황, 관련회의록 + - Todo 카드: 제목, 담당자, 마감일만 표시 (D-day, 우선순위 라벨 없음) - 회의록 탭: - 기본 정보, 안건별 상세 내용 -- Todo 완료 토글: 확인 모달 → 상태 변경 → "Todo가 완료되었습니다" 토스트 -- Todo 편집 모달: 제목, 담당자, 마감일, 우선순위 수정 **예외처리:** - 회의록 조회 실패: "회의록 조회 중 오류가 발생했습니다" 에러 메시지 - Todo 없음: "등록된 Todo가 없습니다" 빈 상태 표시 - 관련회의록 없음: "관련 회의록이 없습니다" 빈 상태 표시 -- Todo 완료 토글 실패: "Todo 상태 변경 중 오류가 발생했습니다" 에러 메시지 -- Todo 편집 권한 없음: "담당자 또는 회의 생성자만 편집할 수 있습니다" 안내 **관련 유저스토리:** - UFR-MEET-046: 회의록목록조회 - UFR-MEET-055: 회의록수정 -- UFR-TODO-030: Todo완료처리 -- UFR-TODO-040: Todo관리 --- -### UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다. +### UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않은 안건을 | 수정하고 검증완료 체크를 통해 보호하고 싶다. **수행절차:** 1. 회의록 상세 조회 화면에서 "수정" 버튼 클릭 2. 회의록 수정 화면(11-회의록수정.html) 표시 -3. 편집 가능 항목: +3. 여러 참석자가 동시에 수정 화면 진입 가능 (동시 편집 허용) +4. 편집 가능 항목: - 회의 제목 (텍스트 입력) - AI 한줄 요약 (읽기 전용, 편집 불가) - - AI 상세 요약 (텍스트 영역, 편집 가능) - - "재생성" 버튼: AI 상세 요약 재생성 (2-3문장, 2-5초 처리) - - 안건 내용 (텍스트 영역) + - 안건별 입력창 내용 (텍스트 영역, 편집 가능) + - "AI 재생성" 버튼: 입력창 내용 기반으로 한줄 요약만 재생성 - 관련회의록 추가/제거 - "추가" 버튼: 검색 모달 열기 - X 버튼: 관련회의록 제거 - 참석자 관리 (회의 생성자만): - 참석자 추가/삭제 - 05-회의진행 화면과 동일한 UI -4. 읽기 전용 항목: +5. 읽기 전용 항목: - 회의 일시 (읽기 전용 배지 표시) - 회의 장소 (읽기 전용 배지 표시) -5. 안건별 검증 완료 토글: - - 검증 완료 전: 토글 활성화 → 잠금 - - 검증 완료 후: 잠금 아이콘 표시 - - 회의 생성자: 잠금 해제 → 토글 비활성화 → 편집 가능 -6. 자동 저장 (30초마다) - - 저장 상태 표시: "저장 중...", "저장됨" -7. 모든 안건 검증 완료 시 "최종 확정" 버튼 활성화 -8. 페이지 나가기 전 확인 (변경사항 있을 때) +6. 안건별 검증완료 체크: + - 검증완료 전: 체크박스 비활성화 상태, 편집 가능 + - 검증완료 체크 시: 해당 안건 잠금 (편집 불가) + - 회의 생성자만: 검증완료 체크 해제 가능 (잠금 해제) +7. 저장 로직: + - "저장" 버튼 클릭 시: + - 검증완료된 안건: 저장 스킵 + - 미검증 안건: 저장 진행 + - 저장 결과 알림: + - "N개 안건이 저장되었습니다" + - "M개 안건은 검증완료 상태로 저장되지 않았습니다" + - 저장 불가 안건 목록 표시 +8. 모든 안건 검증 완료 시 "최종 확정" 버튼 활성화 +9. 페이지 나가기 전 확인 (변경사항 있을 때) **입력:** - 회의 제목: 텍스트 입력 -- AI 상세 요약: 텍스트 영역 편집, "재생성" 버튼 -- 안건 내용: 텍스트 영역 편집 +- 안건별 입력창 내용: 텍스트 영역 편집 +- AI 재생성: 버튼 클릭 (입력창 내용 기반 한줄 요약 생성) - 관련회의록: 추가(검색 모달), 제거(X 버튼) - 참석자: 추가(검색 모달), 삭제(X 버튼) - 회의 생성자만 -- 검증 완료: 토글 스위치 -- 잠금 해제: 잠금 아이콘 클릭 (회의 생성자만) +- 검증 완료: 체크박스 (회의 생성자: 체크/해제, 참석자: 체크만) **출력/결과:** -- 편집 화면: 제목, AI 요약, 안건, 관련회의록, 참석자, 검증 토글 -- 자동 저장: "저장 중..." → "저장됨" 상태 표시 -- AI 재생성: "재생성 중..." → 새 요약 표시 → "재생성되었습니다" 토스트 +- 편집 화면: 제목, AI 한줄 요약, 안건별 입력창, 관련회의록, 참석자, 검증 체크박스 +- 저장 결과: "N개 안건 저장됨" 토스트, 저장 불가 안건 목록 표시 +- AI 재생성: "재생성 중..." → 한줄 요약만 업데이트 → "재생성되었습니다" 토스트 - 관련회의록 추가: 검색 모달 → 선택 → "추가되었습니다" 토스트 - 참석자 추가: 검색 모달 → 선택 → "{이름}님이 추가되었습니다" 토스트 -- 검증 완료: 토글 → 잠금 아이콘 표시 -- 잠금 해제: 잠금 아이콘 클릭 → "잠금 해제하시겠습니까?" 확인 → 토글 비활성화 +- 검증 완료: 체크 → 잠금 아이콘 표시 (편집 불가) +- 잠금 해제 (회의 생성자만): 체크 해제 → "잠금 해제하시겠습니까?" 확인 → 편집 가능 - 최종 확정: 검증률 100% → "최종 확정" 버튼 활성화 **예외처리:** -- 편집 권한 없음 (확정 완료 후, 생성자 아님): 읽기 전용 모드, "회의 생성자만 수정할 수 있습니다" 안내 -- 검증 완료 섹션 편집 시도 (참석자): "검증 완료된 섹션은 수정할 수 없습니다" 안내 +- 편집 권한 없음 (확정 완료 후): 읽기 전용 모드, "확정된 회의록은 수정할 수 없습니다" 안내 +- 검증완료 안건 편집 시도: "검증완료된 안건은 수정할 수 없습니다. 회의 생성자에게 문의하세요" 안내 +- 검증완료 해제 권한 없음: "회의 생성자만 검증완료를 해제할 수 있습니다" 안내 - AI 재생성 실패: "재생성 중 오류가 발생했습니다" 에러 메시지 -- 자동 저장 실패: "저장 실패" 상태 표시 +- 저장 실패: "저장 중 오류가 발생했습니다" 에러 메시지 - 페이지 나가기: "저장되지 않은 변경사항이 있습니다. 나가시겠습니까?" 확인 모달 - 참석자 관리 권한 없음: "회의 생성자만 참석자를 관리할 수 있습니다" 안내 +**동시 편집 정책 (MVP):** +- 실시간 동기화 없음 (다른 참석자가 편집하는 내용 안 보임) +- 안건별 검증완료 체크로 충돌 방지 +- 마지막 저장 우선 원칙 (Last Write Wins) + **관련 유저스토리:** - UFR-MEET-047: 회의록상세조회 - UFR-MEET-050: 최종확정 -- UFR-AI-035: 섹션AI요약 +- UFR-AI-036: AI한줄요약 - UFR-AI-040: 관련회의록연결 -- UFR-COLLAB-010: 회의록수정동기화 -- UFR-COLLAB-020: 충돌해결 - UFR-COLLAB-030: 검증완료 # 유저스토리 v2.3.0 - AI, STT, RAG, COLLAB, TODO, NOTIFICATION 서비스 @@ -742,19 +743,29 @@ --- -### UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다. +### UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결하고 유사 내용을 요약해주기를 원한다. + +**기능 개선 사항 (v2.3.1):** +- **최대 개수**: 5개 → 3개로 축소 (MVP) +- **관련도 표시 방식**: 배지(높음/중간/낮음) → 퍼센트(95%, 78%)로 변경 +- **유사 내용 요약 추가** (신규): + - AI가 추천한 각 회의록에서 현재 회의와 유사한 부분 자동 추출 + - 유사한 내용을 3-5개 문장으로 요약하여 표시 + - 전체 회의록을 열지 않아도 핵심 내용 파악 가능 + - "전체 회의록 보기" 버튼으로 상세 내용 확인 +- **성능 최적화**: + - 과거 회의록 저장 시 요약본 미리 생성 (배치 처리) + - 실시간 요약은 캐싱된 데이터 활용 + - 성능 목표: 1초 이내 표시 **수행절차:** 1. 회의 종료 시 또는 회의록 작성 중 AI가 현재 회의 내용 분석 2. 벡터 유사도 검색을 통해 관련 회의록 탐색 - 같은 폴더 내 회의록 우선 - 키워드, 참석자, 안건 유사도 분석 -3. 관련도가 높은 회의록 자동 연결 (최대 5개) -4. 관련도 배지 부여: - - 높음 (80% 이상) - - 중간 (60-80%) - - 낮음 (40-60%) -5. 회의 종료 화면, 회의록 수정 화면, 회의록 상세 조회 화면에 표시 +3. 관련도가 높은 회의록 자동 연결 (최대 3개로 축소) +4. 각 회의록에서 유사한 내용 추출 및 요약 (신규 추가) +5. 회의 진행 화면, 회의 종료 화면, 회의록 수정 화면, 회의록 상세 조회 화면에 표시 6. 사용자가 수동으로 추가/제거 가능 **입력:** @@ -762,10 +773,12 @@ - 과거 회의록 데이터 (벡터 DB) **출력/결과:** -- 관련 회의록 목록 (최대 5개): +- 관련 회의록 목록 (최대 3개): - 회의록 제목 - 날짜 - - 관련도 배지 (높음/중간/낮음) + - 관련도 점수 (퍼센트, 예: 95%, 78%) + - **유사 내용 요약 (3-5개 문장)** ← 신규 + - "전체 회의록 보기" 버튼 - 회의록 링크 - 회의록 수정 화면: "추가" 버튼, "X" 제거 버튼 - 회의록 상세 조회 화면: 관련회의록 섹션 표시 @@ -773,6 +786,7 @@ **예외처리:** - 관련 회의록 없음: 빈 목록 표시 - 벡터 검색 실패: 최근 회의록 기준 정렬로 대체 +- 유사 내용 요약 실패: 회의록 제목과 날짜만 표시 - 네트워크 오류: 로컬 캐시 사용 또는 빈 목록 **관련 유저스토리:** @@ -1070,149 +1084,6 @@ --- -## TODO 서비스 (Meeting 서비스에 통합) - -### UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다. - -**수행절차:** -1. AI 서비스에서 Todo 추출 완료 (UFR-AI-020) -2. Todo 데이터 수신: - - 제목, 담당자 ID, 마감일, 우선순위 - - 회의 ID, 안건 ID -3. Todo 데이터베이스에 저장 -4. 담당자에게 알림 대상 생성: - - 알림 유형: "Todo 할당" - - 알림 내용: "{회의 제목}에서 Todo가 할당되었습니다: {Todo 제목}" - - 수신자: 담당자 - - 발송 예정 시각: 즉시 -5. Notification 서비스가 주기적으로 폴링하여 이메일 발송 -6. 회의록과 Todo 양방향 연결: - - 회의록 → Todo 링크 - - Todo → 회의록 링크 -7. 담당자의 Todo 목록 및 대시보드에 표시 - -**입력:** -- AI가 추출한 Todo 데이터: - - 제목, 담당자, 마감일, 우선순위 -- 회의 정보: 회의 ID, 안건 ID, 회의 제목 - -**출력/결과:** -- Todo 저장 완료 -- 알림 대상 생성 (Notification 테이블에 레코드 추가) -- 회의록과 Todo 양방향 링크 생성 -- 담당자 Todo 목록 업데이트 -- 회의록 화면에 Todo 표시 - -**예외처리:** -- 담당자 정보 없음: 회의록 생성자로 할당 -- Todo 저장 실패: "Todo 저장 중 오류가 발생했습니다" 로그 기록, 재시도 -- 알림 대상 생성 실패: 로그 기록, Todo는 정상 저장 - -**관련 유저스토리:** -- UFR-AI-020: Todo자동추출 -- UFR-TODO-040: Todo관리 -- UFR-NOTI-010: 알림발송 (신규) - ---- - -### UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다. - -**수행절차:** -1. Todo 관리 화면 또는 회의록 상세 조회 화면에서 Todo 체크박스 클릭 -2. 완료 확인 모달: "완료 처리하시겠습니까?" -3. 승인 시 Todo 상태 "완료"로 변경 -4. 완료 시각 기록 -5. 회의록에 자동 반영: - - 회의록 상세 조회 시 완료된 Todo 표시 - - Todo 진행상황 진행률 업데이트 -6. 알림 대상 생성 (옵션): - - 회의 생성자에게 Todo 완료 알림 - - 알림 내용: "{담당자}님이 Todo를 완료했습니다: {Todo 제목}" -7. "Todo가 완료되었습니다" 토스트 메시지 - -**미완료로 되돌리기:** -1. 완료된 Todo 체크박스 클릭 -2. 확인 모달: "미완료로 변경하시겠습니까?" -3. 승인 시 Todo 상태 "미완료"로 변경 -4. 회의록 진행률 자동 업데이트 - -**입력:** -- Todo 체크박스 클릭 (완료/미완료 토글) -- 확인 모달 승인 - -**출력/결과:** -- Todo 상태 변경: 완료 ↔ 미완료 -- 완료 시각 기록 -- 회의록 진행률 업데이트 -- 알림 대상 생성 (옵션) -- 토스트 메시지: "Todo가 완료되었습니다" / "미완료로 변경되었습니다" - -**예외처리:** -- 네트워크 오류: "Todo 상태 변경 중 오류가 발생했습니다" 에러 메시지 -- 확인 모달 취소: 체크박스 상태 원복 - -**관련 유저스토리:** -- UFR-TODO-010: Todo할당 -- UFR-TODO-040: Todo관리 -- UFR-MEET-047: 회의록상세조회 -- UFR-NOTI-010: 알림발송 (신규) - ---- - -### UFR-TODO-040: [Todo관리] Todo 담당자로서 | 나는, 나의 Todo를 효율적으로 관리하기 위해 | Todo 목록을 조회하고 상태를 변경하고 편집하고 싶다. - -**수행절차:** -1. 사이드바 또는 하단 네비게이션에서 "Todo 관리" 메뉴 클릭 -2. Todo 관리 화면(09-Todo관리.html) 표시 -3. 통계 블록 확인 (3열 그리드): - - 전체 (미완료): 완료되지 않은 전체 Todo 개수 - - 마감 임박 (3일 이내): D-3 이내 미완료 Todo - - 지연 (기한 경과): 마감일이 지난 미완료 Todo -4. 필터 탭 선택: - - 전체: 모든 Todo (미완료 우선, 마감일 순) - - 지연: 마감일 지난 Todo - - 마감 임박: D-3 이내 Todo - - 완료: 완료된 Todo -5. Todo 카드 확인: - - 체크박스, D-day 배지, 우선순위 배지 - - 제목, 회의록 링크, 마감일 - - 편집 버튼 (미완료 Todo만) -6. 체크박스 클릭 시 완료/미완료 토글 (확인 모달) -7. 편집 버튼 클릭 시 편집 모달: - - 제목, 담당자, 마감일, 우선순위 수정 - - 권한: 담당자 본인 OR 회의 생성자 -8. Todo 카드 클릭 시 해당 회의록 상세 조회 화면으로 이동 - -**입력:** -- 필터 탭 클릭 -- Todo 체크박스 클릭 -- 편집 버튼 클릭 -- Todo 카드 클릭 - -**출력/결과:** -- 통계 블록 (3열): - - 전체 (미완료), 마감 임박, 지연 개수 -- 필터 탭: 전체, 지연, 마감 임박, 완료 (각 탭에 개수 표시) -- Todo 카드: - - D-day 배지 (완료, D+n(지연), D-DAY, D-n) - - 우선순위 배지 (높음, 보통, 낮음) - - 제목, 회의록 링크, 마감일, 편집 버튼 -- 편집 모달: 제목, 담당자, 마감일, 우선순위 입력 필드 -- 빈 상태: "할당된 Todo가 없습니다" - -**예외처리:** -- Todo 없음: 빈 상태 표시 -- 편집 권한 없음: "담당자 또는 회의 생성자만 편집할 수 있습니다" 안내 -- 편집 실패: "Todo 수정 중 오류가 발생했습니다" 에러 메시지 -- 네트워크 오류: "Todo 조회 중 오류가 발생했습니다" 에러 메시지 - -**관련 유저스토리:** -- UFR-TODO-010: Todo할당 -- UFR-TODO-030: Todo완료처리 -- UFR-USER-020: 대시보드 조회 - ---- - ## NOTIFICATION 서비스 (신규) ### UFR-NOTI-010: [알림발송] Notification 시스템으로서 | 나는, 사용자에게 중요한 이벤트를 알리기 위해 | 주기적으로 알림 대상을 확인하여 이메일을 발송하고 싶다. @@ -1233,8 +1104,6 @@ - 발송 실패 시 재시도 횟수 증가, 최대 3회 재시도 4. **알림 유형 및 트리거**: - - **Todo 할당**: AI가 Todo 추출 후 담당자에게 알림 - - **Todo 완료**: 담당자가 Todo 완료 시 회의 생성자에게 알림 (옵션) - **회의 시작 알림**: 회의 시작 10분 전 참석자에게 알림 - **회의록 확정**: 회의록 최종 확정 시 모든 참석자에게 알림 - **참석자 초대**: 회의 중 참석자 초대 시 신규 참석자에게 알림 @@ -1261,10 +1130,6 @@ **알림 유형별 템플릿 예시:** -**Todo 할당** -- 제목: "[회의록] Todo가 할당되었습니다" -- 내용: "{담당자}님, {회의 제목} 회의에서 다음 Todo가 할당되었습니다:\n- {Todo 제목}\n- 마감일: {마감일}\n\n회의록 보기: {회의록 링크}" - **회의 시작 알림** - 제목: "[회의록] 10분 후 회의가 시작됩니다" - 내용: "{참석자}님, {회의 제목} 회의가 10분 후 시작됩니다.\n- 일시: {날짜} {시간}\n- 장소: {장소}\n\n회의 참여하기: {회의 링크}" @@ -1281,9 +1146,256 @@ --- +# MVP 개선 사항 (v2.3.1) + +## 회의 참석자 권한 및 기능 단순화 + +### UFR-PART-010: [회의입장] 회의 참석자로서 | 나는, 예정된 회의에 참여하기 위해 | 대시보드에서 "참여하기" 버튼으로 회의에 입장하고 싶다. + +**수행절차:** +1. 대시보드에서 진행 중 또는 예정된 회의 확인 +2. "참여하기" 버튼 클릭 +3. 회의진행 화면으로 페이지 전환 (같은 탭) +4. 회의 참석자로 입장 완료 + +**입력:** +- 회의 ID +- 사용자 ID + +**출력/결과:** +- 회의진행 화면 표시 +- 참석자 목록에 추가 + +**예외처리:** +- 회의 종료됨: "이미 종료된 회의입니다" 안내 +- 권한 없음: "참석 권한이 없습니다" 안내 + +**관련 유저스토리:** +- UFR-MEET-030: 회의시작 + +--- + +### UFR-PART-020: [AI기반메모작성] 회의 참석자로서 | 나는, 중요한 내용을 메모로 기록하기 위해 | AI가 추천한 주요 내용을 메모 입력창에 추가하고 편집하고 싶다. + +**수행절차:** +1. 회의 진행 중 AI가 실시간으로 주요 내용 감지 및 분석 +2. "AI 제안" 탭 하단에 "AI가 감지한 주요 내용" 리스트로 표시 + - 각 항목: "[시간] 주요 내용 텍스트" 형식 + - 예: "[15:32] 예산 책정 관련 결정", "[15:35] 다음 회의 일정 합의" +3. 참석자가 리스트 항목 선택 시: + - 메모 입력창에 시간과 함께 자동 입력 + - 입력된 메모는 수정 가능 (자동/수동 구분 표시) +4. "저장" 버튼 클릭 시 개인 메모로 저장 + - 각 참석자별로 개별 저장 + - 다른 참석자의 메모는 볼 수 없음 +5. 회의 종료 시 AI가 회의록 생성할 때 모든 참석자의 메모 참조 +6. 메모는 회의 종료 전까지만 표시 및 편집 가능 + +**입력:** +- 회의 ID +- AI가 실시간 감지한 주요 내용 +- 참석자가 선택하거나 직접 입력한 메모 텍스트 + +**출력/결과:** +- 메모 입력창: 시간 포함 메모 텍스트 +- AI 추천 리스트: 실시간 업데이트되는 주요 내용 항목들 +- 저장된 개인 메모: 참석자별 개별 저장 +- AI 회의록 생성 시: 모든 참석자의 메모 참조하여 요약 생성 + +**예외처리:** +- AI 감지 실패: 빈 리스트 표시, 수동 메모 작성은 가능 +- 저장 실패: "메모 저장 중 오류가 발생했습니다" 에러 메시지, 로컬 임시 저장 +- 회의 종료 후: 메모 조회/편집 불가 + +**관련 유저스토리:** +- UFR-AI-030: 실시간 AI 제안 +- UFR-AI-010: 회의록자동작성 (메모 참조) +- UFR-MEET-040: 회의종료 + +--- + +### UFR-PART-030: [회의중도퇴장] 회의 참석자로서 | 나는, 회의를 중간에 나가야 할 때 | "나가기" 버튼으로 회의에서 퇴장하고 싶다. + +**수행절차:** +1. 회의진행 화면 상단 "나가기" 버튼 클릭 +2. 확인 모달 표시: "회의에서 나가시겠습니까? 회의는 계속 진행됩니다" +3. 확인 클릭 시 퇴장 이벤트 서버 전송 +4. 대시보드로 페이지 전환 + +**입력:** +- 회의 ID +- 사용자 ID + +**출력/결과:** +- 대시보드 화면으로 복귀 +- 회의록은 종료 시 공유됨 + +**예외처리:** +- 네트워크 오류: 로컬 처리 후 재시도 + +**관련 유저스토리:** +- UFR-MEET-030: 회의시작 + +--- + +## 회의 생성자 전용 기능 + +### UFR-HOST-010: [회의종료권한] 회의 생성자로서 | 나는, 회의를 마무리하기 위해 | "회의 종료" 버튼으로만 회의를 종료하고 싶다. + +**수행절차:** +1. 회의진행 화면에서 "회의 종료" 버튼은 생성자에게만 표시 +2. 일반 참석자에게는 버튼 숨김 처리 +3. 생성자가 "회의 종료" 클릭 시 회의 종료 프로세스 진행 + +**입력:** +- 회의 ID +- 사용자 ID (생성자 여부 확인) + +**출력/결과:** +- 생성자: 회의 종료 버튼 표시 및 동작 +- 일반 참석자: 버튼 숨김 + +**예외처리:** +- 권한 없음: 버튼 미표시 + +**관련 유저스토리:** +- UFR-MEET-040: 회의종료 + +--- + +### UFR-HOST-020: [녹음제어권한] 회의 생성자로서 | 나는, 녹음을 관리하기 위해 | 녹음 일시정지/재개/종료 권한을 가지고 싶다. + +**수행절차:** +1. 회의진행 화면에서 녹음 제어 버튼은 생성자에게만 표시 +2. 일반 참석자에게는 버튼 숨김 처리 + +**입력:** +- 회의 ID +- 사용자 ID (생성자 여부 확인) + +**출력/결과:** +- 생성자: 녹음 제어 버튼 표시 및 동작 +- 일반 참석자: 버튼 숨김 + +**예외처리:** +- 권한 없음: 버튼 미표시 + +**관련 유저스토리:** +- UFR-STT-010: 음성녹음인식 + +--- + +## 용어 설명 기능 (MVP 단순화) + +### UFR-TERM-010: [용어자동감지] 회의 참석자로서 | 나는, 전문용어를 이해하기 위해 | AI가 자동으로 감지한 용어를 "용어" 탭에서 확인하고 싶다. + +**수행절차:** +1. AI가 STT 분석 중 중요 용어 자동 감지 +2. "용어" 탭에 실시간으로 표시 +3. 용어 항목 클릭 시 상세 설명 모달 표시 + +**입력:** +- STT 텍스트 +- 회사 용어 사전 (JSON) + +**출력/결과:** +- 용어 목록 표시 +- 회사 용어는 ⭐ 배지 표시 + +**예외처리:** +- 용어 감지 실패: 빈 목록 표시 + +**관련 유저스토리:** +- UFR-RAG-010: 전문용어감지 (기존 기능 유지) + +--- + +### UFR-TERM-020: [회사용어사전] 회의 참석자로서 | 나는, 회사 특화 용어를 정확히 이해하기 위해 | 회사 용어 사전에 등록된 용어를 우선 표시받고 싶다. + +**수행절차:** +1. 용어 감지 시 회사 용어 사전(JSON) 먼저 확인 +2. 사전에 있으면 회사 특화 설명 + ⭐ 표시 +3. 사전에 없으면 AI가 일반 설명 + 회의 맥락 제공 + +**입력:** +- 용어 +- 회사 용어 사전 (terms-dictionary.json) +- 회의 맥락 + +**출력/결과:** +- 회사 용어: 정의 + 맥락 + ⭐ +- 일반 용어: AI 설명 + +**예외처리:** +- 사전 로드 실패: AI 설명으로 대체 + +**관련 유저스토리:** +- UFR-RAG-020: 맥락기반용어설명 (기존 기능 유지) + +--- + +### UFR-TERM-030: [용어관리] 관리자로서 | 나는, 회사 특화 용어를 관리하기 위해 | 용어 사전을 등록/수정하고 싶다. + +**수행절차:** +1. 관리자 페이지에서 용어 사전 관리 +2. JSON 파일 직접 편집 또는 관리 UI 사용 +3. 용어 추가/수정/삭제 + +**입력:** +- 용어명 +- 정의 +- 맥락 +- 카테고리 + +**출력/결과:** +- terms-dictionary.json 업데이트 + +**예외처리:** +- 권한 없음: "관리자 권한이 필요합니다" 안내 + +--- + +## 기존 유저스토리 수정 + +### UFR-MEET-040 수정 (회의종료권한) +**기존**: "회의 생성자 또는 참석자가 회의 종료 버튼을 눌러 회의를 종료한다" +**변경**: "회의 생성자만 '회의 종료' 버튼으로 회의를 종료할 수 있다" +**적용 위치**: [UFR-MEET-040: 회의종료](#ufr-meet-040-회의종료) + +--- + +### UFR-MEET-030 개선 (회의 진입 경로) +**변경**: 대시보드에서 회의 진행 화면 진입 경로 명확화 +- **경로 1**: 대시보드 → "바로시작" FAB 버튼 → 템플릿 선택 → 회의 진행 +- **경로 2**: 대시보드 → 진행 중 회의 "참여하기" 버튼 → 회의 진행 +**적용 위치**: [UFR-MEET-030: 회의시작](#ufr-meet-030-회의시작) + +--- + +### UFR-AI-040 개선 (관련회의록연결) + +**변경 및 개선 사항**: +1. **최대 개수**: 5개 → 3개로 축소 (MVP) +2. **관련도 표시 방식**: 배지(높음/중간/낮음) → 퍼센트(95%, 78%)로 변경 +3. **유사 내용 요약 추가** (신규): + - AI가 추천한 각 회의록에서 현재 회의와 유사한 부분 자동 추출 + - 유사한 내용을 3-5개 문장으로 요약하여 표시 + - 전체 회의록을 열지 않아도 핵심 내용 파악 가능 + - "전체 회의록 보기" 버튼으로 상세 내용 확인 +4. **성능 최적화**: + - 과거 회의록 저장 시 요약본 미리 생성 (배치 처리) + - 실시간 요약은 캐싱된 데이터 활용 + - 성능 목표: 1초 이내 표시 + +**적용 위치**: [UFR-AI-040: 관련회의록연결](#ufr-ai-040-관련회의록연결) + +--- + ## 문서 이력 | 버전 | 날짜 | 작성자 | 변경 내용 | |------|------|--------|-----------| +| v2.4.0 | 2025-10-27 | 팀 전체 | • MVP 스코프 축소: Todo 관리 기능 제거
• UFR-USER-020 수정: 대시보드에서 "나의 Todo" 제거, "작성중 회의록" 추가
• UFR-PART-020 변경: AI주요내용체크 → AI기반메모작성 (메모 입력창 + AI 추천)
• UFR-AI-010 개선: 회의록 생성 시 참석자 메모 참조
• UFR-MEET-055 개선: 회의록 수정 시 실시간 협업 제거, 검증완료 체크로 보호
• TODO 서비스 전체 제거 (UFR-TODO-010/030/040)
• NOTIFICATION 서비스: Todo 관련 알림 제거
• 네비게이션 간소화: Todo 관리 메뉴 제거 (대시보드, 회의록만 유지) | +| v2.3.1 | 2025-10-27 | 팀 전체 | • MVP 개선: 회의 참석자 권한 단순화
• 신규 유저스토리: UFR-PART-010/020/030 (참석자 공통), UFR-HOST-010/020 (생성자 전용)
• 신규 유저스토리: UFR-TERM-010/020/030 (용어 기능 MVP 단순화)
• UFR-MEET-040 수정: 회의 종료 권한 생성자 전용으로 명확화
• UFR-MEET-030 개선: 회의 진입 경로 2가지 명시 (바로시작, 참여하기)
• UFR-AI-040 개선: 관련 회의록 유사 내용 요약 추가 (최대 3개, 퍼센트 표시, 3-5문장 요약)
• 메모 기능 단순화: 체크박스 방식으로 변경
• 용어 설명 단순화: JSON 용어 사전 방식 도입 | | v2.3.0 | 2025-10-24 | 이미준 | • 프로토타입 분석을 통한 유저스토리 전면 재정비
• 신규 유저스토리 추가: UFR-MEET-015 (참석자 실시간 초대), UFR-NOTI-010 (알림 발송)
• 알림 아키텍처 폴링 방식으로 통일 (실시간 발송 → 주기적 폴링)
• 10개 프로토타입 화면 반영 완료
• 마이크로서비스 구성 재정의 (User, Meeting, STT, AI, Notification)
• 기존 24개 유저스토리 ID 승계 및 정리 | | v2.2.0 | 2025-10-23 | 이미준 | 이전 버전 | diff --git a/meeting/README.md b/meeting/README.md new file mode 100644 index 0000000..05763f9 --- /dev/null +++ b/meeting/README.md @@ -0,0 +1,25 @@ +# notification 메일 알림발송 + +아래와 같이 메일발송 Email List를 loop돌려서 event 객체를 생성한 후에 `publishNotificationRequest` 메소드를 통해 Event 메세지 발행하시면 됩니다. + +``` + // 각 참석자에게 개별 알림 이벤트 발행 + for (String participantEmail : participants) { + NotificationRequestEvent event = NotificationRequestEvent.builder() + .notificationType("MEETING_INVITATION") + .recipientEmail(participantEmail) + .recipientId(participantEmail) + .recipientName(participantEmail) + .title("회의 초대") + .message(String.format("'%s' 회의에 초대되었습니다. 일시: %s, 장소: %s", + title, startTime, location)) + .relatedEntityId(meetingId) + .relatedEntityType("MEETING") + .requestedBy(organizerId) + .requestedByName(organizerName) + .eventTime(LocalDateTime.now()) + .build(); + + publishNotificationRequest(event); + } +``` diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting$MeetingBuilder.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting$MeetingBuilder.class index 7520636..427963b 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting$MeetingBuilder.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting$MeetingBuilder.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting.class index f098189..c9b6bed 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/domain/Meeting.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$MeetingDTOBuilder.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$MeetingDTOBuilder.class index a954bea..936a3b6 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$MeetingDTOBuilder.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$MeetingDTOBuilder.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO$ParticipantDTOBuilder.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO$ParticipantDTOBuilder.class index cc01cd9..bb2d916 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO$ParticipantDTOBuilder.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO$ParticipantDTOBuilder.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO.class index d38c033..53760cb 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO$ParticipantDTO.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO.class index 36aabba..c5174fe 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/dto/MeetingDTO.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/service/MeetingService.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/service/MeetingService.class index 71dea6d..9b9a824 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/service/MeetingService.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/service/MeetingService.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/CreateMeetingUseCase$CreateMeetingCommand.class b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/CreateMeetingUseCase$CreateMeetingCommand.class index 81ada94..0ddb7a2 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/CreateMeetingUseCase$CreateMeetingCommand.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/CreateMeetingUseCase$CreateMeetingCommand.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.class index b26c87a..819cc9c 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/DashboardController.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/DashboardController.class index 4d7ad2a..11957a1 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/DashboardController.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/DashboardController.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/MeetingController.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/MeetingController.class index 9e996ed..1a0af43 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/MeetingController.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/MeetingController.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/TemplateController.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/TemplateController.class index bf8c134..9da6101 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/TemplateController.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/controller/TemplateController.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$MeetingResponseBuilder.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$MeetingResponseBuilder.class index 69106d2..9320e91 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$MeetingResponseBuilder.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$MeetingResponseBuilder.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse$ParticipantResponseBuilder.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse$ParticipantResponseBuilder.class index fc2b9ed..e1e1f03 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse$ParticipantResponseBuilder.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse$ParticipantResponseBuilder.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse.class index 26339f7..26e325f 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse$ParticipantResponse.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse.class index 2ef056f..9255882 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/MeetingResponse.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem$TemplateItemBuilder.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem$TemplateItemBuilder.class index 3aebc7f..575b9e1 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem$TemplateItemBuilder.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem$TemplateItemBuilder.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem.class index 215563a..fb3a77e 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateItem.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo$TemplateSectionInfoBuilder.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo$TemplateSectionInfoBuilder.class index 066b95a..02b5c99 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo$TemplateSectionInfoBuilder.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo$TemplateSectionInfoBuilder.class differ diff --git a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo.class b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo.class index 45a9669..f1d3113 100644 Binary files a/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo.class and b/meeting/bin/main/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse$TemplateSectionInfo.class differ diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Meeting.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Meeting.java index f5c147e..b6b7f10 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Meeting.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Meeting.java @@ -119,7 +119,20 @@ public class Meeting { public void addParticipant(String participantEmail) { if (this.participants == null) { this.participants = new ArrayList<>(); + } else if (!(this.participants instanceof ArrayList)) { + // 불변 리스트인 경우 새로운 ArrayList로 변환 + this.participants = new ArrayList<>(this.participants); } - this.participants.add(participantEmail); + + if (!this.participants.contains(participantEmail)) { + this.participants.add(participantEmail); + } + } + + /** + * 템플릿 적용 + */ + public void applyTemplate(String templateId) { + this.templateId = templateId; } } diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MeetingAnalysis.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MeetingAnalysis.java new file mode 100644 index 0000000..57ff112 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MeetingAnalysis.java @@ -0,0 +1,122 @@ +package com.unicorn.hgzero.meeting.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 회의 AI 분석 결과 도메인 모델 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MeetingAnalysis { + + /** + * 분석 ID + */ + private String analysisId; + + /** + * 회의 ID + */ + private String meetingId; + + /** + * 회의록 ID + */ + private String minutesId; + + /** + * 주요 키워드 + */ + private List keywords; + + /** + * 안건별 분석 결과 + */ + private List agendaAnalyses; + + /** + * 전체 회의 분석 상태 (PENDING, IN_PROGRESS, COMPLETED, FAILED) + */ + private String status; + + /** + * 분석 완료 시간 + */ + private LocalDateTime completedAt; + + /** + * 생성 시간 + */ + private LocalDateTime createdAt; + + /** + * 분석 완료 처리 + */ + public void complete() { + this.status = "COMPLETED"; + this.completedAt = LocalDateTime.now(); + } + + /** + * 분석 실패 처리 + */ + public void fail() { + this.status = "FAILED"; + } + + /** + * 분석 진행 중 처리 + */ + public void startAnalysis() { + this.status = "IN_PROGRESS"; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AgendaAnalysis { + /** + * 안건 ID + */ + private String agendaId; + + /** + * 안건 제목 + */ + private String title; + + /** + * AI 요약 (간략) + */ + private String aiSummaryShort; + + /** + * 논의 주제 + */ + private String discussion; + + /** + * 결정 사항 + */ + private List decisions; + + /** + * 보류 사항 + */ + private List pending; + + /** + * 추출된 Todo 목록 + */ + private List extractedTodos; + } +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/MeetingEndDTO.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/MeetingEndDTO.java new file mode 100644 index 0000000..7e38d6a --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/dto/MeetingEndDTO.java @@ -0,0 +1,45 @@ +package com.unicorn.hgzero.meeting.biz.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** + * 회의 종료 비즈니스 DTO + */ +@Getter +@Builder +public class MeetingEndDTO { + + private final String title; + private final int participantCount; + private final int durationMinutes; + private final int agendaCount; + private final int todoCount; + private final List keywords; + private final List agendaSummaries; + + @Getter + @Builder + public static class AgendaSummaryDTO { + private final String title; + private final String aiSummaryShort; + private final AgendaDetailsDTO details; + private final List todos; + } + + @Getter + @Builder + public static class AgendaDetailsDTO { + private final String discussion; + private final List decisions; + private final List pending; + } + + @Getter + @Builder + public static class TodoSummaryDTO { + private final String title; + } +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/ApplyTemplateService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/ApplyTemplateService.java new file mode 100644 index 0000000..2058f39 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/ApplyTemplateService.java @@ -0,0 +1,44 @@ +package com.unicorn.hgzero.meeting.biz.service; + +import com.unicorn.hgzero.meeting.biz.domain.Meeting; +import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.ApplyTemplateUseCase; +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader; +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 회의록 템플릿 적용 서비스 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ApplyTemplateService implements ApplyTemplateUseCase { + + private final MeetingReader meetingReader; + private final MeetingWriter meetingWriter; + + @Override + public Meeting applyTemplate(ApplyTemplateCommand command) { + log.debug("템플릿 적용 시작 - meetingId: {}, templateId: {}", + command.meetingId(), command.templateId()); + + // 회의 조회 + Meeting meeting = meetingReader.findById(command.meetingId()) + .orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + command.meetingId())); + + // 템플릿 적용 (회의 도메인에서 처리) + meeting.applyTemplate(command.templateId()); + + // 회의 정보 업데이트 + Meeting updatedMeeting = meetingWriter.save(meeting); + + log.debug("템플릿 적용 완료 - meetingId: {}, templateId: {}", + command.meetingId(), command.templateId()); + + return updatedMeeting; + } +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MeetingService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MeetingService.java index 0513df2..9b17301 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MeetingService.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MeetingService.java @@ -3,11 +3,16 @@ package com.unicorn.hgzero.meeting.biz.service; import com.unicorn.hgzero.common.exception.BusinessException; import com.unicorn.hgzero.common.exception.ErrorCode; import com.unicorn.hgzero.meeting.biz.domain.Meeting; +import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis; import com.unicorn.hgzero.meeting.biz.domain.Minutes; import com.unicorn.hgzero.meeting.biz.domain.Session; +import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO; import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.*; import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader; import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter; +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisReader; +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisWriter; +import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader; import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter; import com.unicorn.hgzero.meeting.biz.usecase.out.SessionReader; import com.unicorn.hgzero.meeting.biz.usecase.out.SessionWriter; @@ -42,7 +47,10 @@ public class MeetingService implements private final MeetingWriter meetingWriter; private final SessionReader sessionReader; private final SessionWriter sessionWriter; + private final MinutesReader minutesReader; private final MinutesWriter minutesWriter; + private final MeetingAnalysisReader meetingAnalysisReader; + private final MeetingAnalysisWriter meetingAnalysisWriter; private final CacheService cacheService; private final EventPublisher eventPublisher; private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter participantWriter; @@ -263,30 +271,165 @@ public class MeetingService implements } /** - * 회의 종료 + * 회의 종료 및 AI 분석 */ @Override @Transactional - public Meeting endMeeting(String meetingId) { + public MeetingEndDTO endMeeting(String meetingId) { log.info("Ending meeting: {}", meetingId); - // 회의 조회 + // 1. 회의 조회 + log.debug("Searching for meeting with ID: {}", meetingId); Meeting meeting = meetingReader.findById(meetingId) - .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND)); + .orElseThrow(() -> { + log.error("Meeting not found: {}", meetingId); + return new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의를 찾을 수 없습니다: " + meetingId); + }); + log.debug("Found meeting: {}, status: {}", meeting.getTitle(), meeting.getStatus()); - // 회의 상태 검증 - if (!"IN_PROGRESS".equals(meeting.getStatus())) { - throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + // 2. 회의 상태 검증 (SCHEDULED 또는 IN_PROGRESS만 종료 가능) + if (!"SCHEDULED".equals(meeting.getStatus()) && !"IN_PROGRESS".equals(meeting.getStatus())) { + log.warn("Invalid meeting status for ending: meetingId={}, status={}", meetingId, meeting.getStatus()); + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, + "회의를 종료할 수 없는 상태입니다. 현재 상태: " + meeting.getStatus()); } - // 회의 종료 + // 3. 회의 종료 meeting.end(); - - // 저장 Meeting updatedMeeting = meetingWriter.save(meeting); + // 4. 회의록 조회 (SCHEDULED 상태면 빈 회의록 생성, IN_PROGRESS면 기존 회의록 조회) + Minutes minutes; + if ("SCHEDULED".equals(meeting.getStatus())) { + // SCHEDULED 상태에서 종료하는 경우 빈 회의록 생성 + String minutesId = UUID.randomUUID().toString(); + minutes = Minutes.builder() + .minutesId(minutesId) + .meetingId(meetingId) + .title(meeting.getTitle() + " - 회의록") + .sections(List.of()) + .status("DRAFT") + .version(1) + .createdBy(meeting.getOrganizerId()) + .createdAt(LocalDateTime.now()) + .build(); + minutesWriter.save(minutes); + log.info("Empty minutes created for SCHEDULED meeting: meetingId={}, minutesId={}", meetingId, minutesId); + } else { + // IN_PROGRESS 상태면 기존 회의록 조회 + log.debug("Searching for existing minutes for meeting: {}", meetingId); + minutes = minutesReader.findLatestByMeetingId(meetingId) + .orElseThrow(() -> { + log.error("Minutes not found for meeting: {}", meetingId); + return new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의록을 찾을 수 없습니다: " + meetingId); + }); + log.debug("Found minutes: {}", minutes.getTitle()); + } + + // 5. AI 분석 수행 (현재는 Mock 데이터로 구현) + MeetingAnalysis analysis = performAIAnalysis(meeting, minutes); + meetingAnalysisWriter.save(analysis); + + // 6. 결과 DTO 구성 + MeetingEndDTO result = buildMeetingEndDTO(meeting, analysis); + log.info("Meeting ended successfully: {}", meetingId); - return updatedMeeting; + return result; + } + + /** + * AI 분석 수행 (Mock 구현) + */ + private MeetingAnalysis performAIAnalysis(Meeting meeting, Minutes minutes) { + log.info("Performing AI analysis for meeting: {}", meeting.getMeetingId()); + + // Mock 데이터로 구현 (실제로는 AI 서비스 호출) + List keywords = List.of( + "#신제품기획", "#예산편성", "#일정조율", + "#시장조사", "#UI/UX", "#개발스펙" + ); + + List agendaAnalyses = List.of( + MeetingAnalysis.AgendaAnalysis.builder() + .agendaId("agenda-1") + .title("1. 신제품 기획 방향성") + .aiSummaryShort("타겟 고객을 20-30대로 설정, UI/UX 개선 집중") + .discussion("신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고, 기존 제품 대비 UI/UX를 대폭 개선하기로 함") + .decisions(List.of("타겟 고객: 20-30대 직장인", "UI/UX 개선을 최우선 과제로 설정")) + .pending(List.of()) + .extractedTodos(List.of("시장 조사 보고서 작성", "UI/UX 개선안 초안 작성")) + .build(), + MeetingAnalysis.AgendaAnalysis.builder() + .agendaId("agenda-2") + .title("2. 예산 편성 및 일정") + .aiSummaryShort("총 예산 5억, 개발 기간 6개월 확정") + .discussion("신제품 개발을 위한 총 예산을 5억원으로 책정하고, 개발 기간은 6개월로 확정함") + .decisions(List.of("총 예산: 5억원", "개발 기간: 6개월", "예산 배분: 개발 60%, 마케팅 40%")) + .pending(List.of("세부 일정 확정은 다음 회의에서 논의")) + .extractedTodos(List.of("세부 개발 일정 수립")) + .build(), + MeetingAnalysis.AgendaAnalysis.builder() + .agendaId("agenda-3") + .title("3. 기술 스택 및 개발 방향") + .aiSummaryShort("React 기반 프론트엔드, AI 챗봇 기능 추가") + .discussion("프론트엔드는 React 기반으로 개발하고, 고객 지원을 위한 AI 챗봇 기능을 추가하기로 함") + .decisions(List.of("프론트엔드: React 기반", "AI 챗봇 기능 추가", "Next.js 도입 검토")) + .pending(List.of("AI 챗봇 학습 데이터 확보 방안")) + .extractedTodos(List.of("AI 챗봇 프로토타입 개발", "Next.js 도입 검토 보고서")) + .build() + ); + + return MeetingAnalysis.builder() + .analysisId(UUID.randomUUID().toString()) + .meetingId(meeting.getMeetingId()) + .minutesId(minutes.getMinutesId()) + .keywords(keywords) + .agendaAnalyses(agendaAnalyses) + .status("COMPLETED") + .completedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .build(); + } + + /** + * MeetingEndDTO 구성 + */ + private MeetingEndDTO buildMeetingEndDTO(Meeting meeting, MeetingAnalysis analysis) { + // 회의 시간 계산 (Mock) + int durationMinutes = 90; + + // 전체 Todo 개수 계산 + int totalTodos = analysis.getAgendaAnalyses().stream() + .mapToInt(agenda -> agenda.getExtractedTodos().size()) + .sum(); + + // 안건별 요약 DTO 변환 + List agendaSummaries = analysis.getAgendaAnalyses().stream() + .map(agenda -> MeetingEndDTO.AgendaSummaryDTO.builder() + .title(agenda.getTitle()) + .aiSummaryShort(agenda.getAiSummaryShort()) + .details(MeetingEndDTO.AgendaDetailsDTO.builder() + .discussion(agenda.getDiscussion()) + .decisions(agenda.getDecisions()) + .pending(agenda.getPending()) + .build()) + .todos(agenda.getExtractedTodos().stream() + .map(todo -> MeetingEndDTO.TodoSummaryDTO.builder() + .title(todo) + .build()) + .toList()) + .build()) + .toList(); + + return MeetingEndDTO.builder() + .title(meeting.getTitle()) + .participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0) + .durationMinutes(durationMinutes) + .agendaCount(analysis.getAgendaAnalyses().size()) + .todoCount(totalTodos) + .keywords(analysis.getKeywords()) + .agendaSummaries(agendaSummaries) + .build(); } /** diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/ApplyTemplateUseCase.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/ApplyTemplateUseCase.java new file mode 100644 index 0000000..dd09e1c --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/ApplyTemplateUseCase.java @@ -0,0 +1,25 @@ +package com.unicorn.hgzero.meeting.biz.usecase.in.meeting; + +import com.unicorn.hgzero.meeting.biz.domain.Meeting; + +/** + * 회의록 템플릿 적용 UseCase + */ +public interface ApplyTemplateUseCase { + + /** + * 회의에 템플릿을 적용 + * + * @param command 템플릿 적용 명령 + * @return 업데이트된 회의 정보 + */ + Meeting applyTemplate(ApplyTemplateCommand command); + + /** + * 템플릿 적용 명령 + */ + record ApplyTemplateCommand( + String meetingId, + String templateId + ) {} +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/EndMeetingUseCase.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/EndMeetingUseCase.java index 900292a..04856c9 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/EndMeetingUseCase.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/in/meeting/EndMeetingUseCase.java @@ -1,6 +1,6 @@ package com.unicorn.hgzero.meeting.biz.usecase.in.meeting; -import com.unicorn.hgzero.meeting.biz.domain.Meeting; +import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO; /** * 회의 종료 UseCase @@ -8,7 +8,7 @@ import com.unicorn.hgzero.meeting.biz.domain.Meeting; public interface EndMeetingUseCase { /** - * 회의 종료 + * 회의 종료 및 AI 분석 */ - Meeting endMeeting(String meetingId); + MeetingEndDTO endMeeting(String meetingId); } diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MeetingAnalysisReader.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MeetingAnalysisReader.java new file mode 100644 index 0000000..9f2eba3 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MeetingAnalysisReader.java @@ -0,0 +1,26 @@ +package com.unicorn.hgzero.meeting.biz.usecase.out; + +import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis; + +import java.util.Optional; + +/** + * 회의 분석 Reader + */ +public interface MeetingAnalysisReader { + + /** + * 회의 ID로 분석 결과 조회 + */ + Optional findByMeetingId(String meetingId); + + /** + * 분석 ID로 분석 결과 조회 + */ + Optional findById(String analysisId); + + /** + * 회의록 ID로 분석 결과 조회 + */ + Optional findByMinutesId(String minutesId); +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MeetingAnalysisWriter.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MeetingAnalysisWriter.java new file mode 100644 index 0000000..568f25e --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MeetingAnalysisWriter.java @@ -0,0 +1,19 @@ +package com.unicorn.hgzero.meeting.biz.usecase.out; + +import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis; + +/** + * 회의 분석 Writer + */ +public interface MeetingAnalysisWriter { + + /** + * 분석 결과 저장 + */ + MeetingAnalysis save(MeetingAnalysis analysis); + + /** + * 분석 결과 삭제 + */ + void delete(String analysisId); +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/DashboardController.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/DashboardController.java index 01accf0..c223763 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/DashboardController.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/DashboardController.java @@ -1,8 +1,6 @@ package com.unicorn.hgzero.meeting.infra.controller; import com.unicorn.hgzero.common.dto.ApiResponse; -import com.unicorn.hgzero.meeting.biz.dto.DashboardDTO; -import com.unicorn.hgzero.meeting.biz.usecase.in.dashboard.GetDashboardUseCase; import com.unicorn.hgzero.meeting.infra.dto.response.DashboardResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -14,6 +12,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + /** * 대시보드 REST API Controller * 사용자별 맞춤 대시보드 데이터 제공 @@ -25,17 +28,15 @@ import org.springframework.web.bind.annotation.*; @Slf4j public class DashboardController { - private final GetDashboardUseCase getDashboardUseCase; - /** - * 대시보드 데이터 조회 + * 대시보드 데이터 조회 (목 데이터) * * @param userId 사용자 ID * @return 대시보드 데이터 */ @Operation( summary = "대시보드 데이터 조회", - description = "사용자별 맞춤 대시보드 정보를 조회합니다. 예정된 회의 목록, 진행 중 Todo 목록, 최근 회의록 목록, 통계 정보를 포함합니다.", + description = "사용자별 맞춤 대시보드 정보를 조회합니다. 예정된 회의 목록, 최근 회의록 목록, 통계 정보를 포함합니다.", security = @SecurityRequirement(name = "bearerAuth") ) @GetMapping @@ -49,12 +50,80 @@ public class DashboardController { log.info("대시보드 데이터 조회 요청 - userId: {}", userId); - var dashboardData = getDashboardUseCase.getDashboard(userId); - var dashboardDTO = DashboardDTO.from(dashboardData); - var response = DashboardResponse.from(dashboardDTO); + // 목 데이터 생성 + DashboardResponse mockResponse = createMockDashboardData(); log.info("대시보드 데이터 조회 완료 - userId: {}", userId); - return ResponseEntity.ok(ApiResponse.success(response)); + return ResponseEntity.ok(ApiResponse.success(mockResponse)); + } + + /** + * 목 데이터 생성 + */ + private DashboardResponse createMockDashboardData() { + // 예정된 회의 목 데이터 + List upcomingMeetings = Arrays.asList( + DashboardResponse.UpcomingMeetingResponse.builder() + .meetingId("550e8400-e29b-41d4-a716-446655440001") + .title("Q1 전략 회의") + .startTime(LocalDateTime.now().plusDays(2).withHour(14).withMinute(0)) + .endTime(LocalDateTime.now().plusDays(2).withHour(16).withMinute(0)) + .location("회의실 A") + .participantCount(5) + .status("SCHEDULED") + .build(), + DashboardResponse.UpcomingMeetingResponse.builder() + .meetingId("550e8400-e29b-41d4-a716-446655440002") + .title("개발팀 스프린트 계획") + .startTime(LocalDateTime.now().plusDays(3).withHour(10).withMinute(0)) + .endTime(LocalDateTime.now().plusDays(3).withHour(12).withMinute(0)) + .location("회의실 B") + .participantCount(8) + .status("SCHEDULED") + .build() + ); + + // 최근 회의록 목 데이터 + List recentMinutes = Arrays.asList( + DashboardResponse.RecentMinutesResponse.builder() + .minutesId("770e8400-e29b-41d4-a716-446655440001") + .title("아키텍처 설계 회의") + .meetingDate(LocalDateTime.now().minusDays(1).withHour(14).withMinute(0)) + .status("FINALIZED") + .participantCount(6) + .lastModified(LocalDateTime.now().minusDays(1).withHour(16).withMinute(30)) + .build(), + DashboardResponse.RecentMinutesResponse.builder() + .minutesId("770e8400-e29b-41d4-a716-446655440002") + .title("UI/UX 검토 회의") + .meetingDate(LocalDateTime.now().minusDays(3).withHour(11).withMinute(0)) + .status("FINALIZED") + .participantCount(4) + .lastModified(LocalDateTime.now().minusDays(3).withHour(12).withMinute(45)) + .build(), + DashboardResponse.RecentMinutesResponse.builder() + .minutesId("770e8400-e29b-41d4-a716-446655440003") + .title("API 설계 검토") + .meetingDate(LocalDateTime.now().minusDays(5).withHour(15).withMinute(0)) + .status("DRAFT") + .participantCount(3) + .lastModified(LocalDateTime.now().minusDays(5).withHour(16).withMinute(15)) + .build() + ); + + // 통계 정보 목 데이터 + DashboardResponse.StatisticsResponse statistics = DashboardResponse.StatisticsResponse.builder() + .upcomingMeetingsCount(2) + .activeTodosCount(0) // activeTodos 제거로 0으로 설정 + .todoCompletionRate(0.0) // activeTodos 제거로 0으로 설정 + .build(); + + return DashboardResponse.builder() + .upcomingMeetings(upcomingMeetings) + .activeTodos(Collections.emptyList()) // activeTodos 빈 리스트로 설정 + .myMinutes(recentMinutes) + .statistics(statistics) + .build(); } } \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingController.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingController.java index 36eab4c..9976d17 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingController.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingController.java @@ -8,6 +8,7 @@ import com.unicorn.hgzero.meeting.infra.dto.request.InviteParticipantRequest; import com.unicorn.hgzero.meeting.infra.dto.request.SelectTemplateRequest; import com.unicorn.hgzero.meeting.infra.dto.response.InviteParticipantResponse; import com.unicorn.hgzero.meeting.infra.dto.response.MeetingResponse; +import com.unicorn.hgzero.meeting.infra.dto.response.MeetingEndResponse; import com.unicorn.hgzero.meeting.infra.dto.response.SessionResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -20,6 +21,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; +import java.util.List; /** * 회의 관리 REST API Controller @@ -38,6 +40,7 @@ public class MeetingController { private final GetMeetingUseCase getMeetingUseCase; private final CancelMeetingUseCase cancelMeetingUseCase; private final InviteParticipantUseCase inviteParticipantUseCase; + private final ApplyTemplateUseCase applyTemplateUseCase; /** * 회의 예약 @@ -113,11 +116,17 @@ public class MeetingController { log.info("템플릿 적용 요청 - meetingId: {}, templateId: {}", meetingId, request.getTemplateId()); - var meetingData = getMeetingUseCase.getMeeting(meetingId); + var meetingData = applyTemplateUseCase.applyTemplate( + new ApplyTemplateUseCase.ApplyTemplateCommand( + meetingId, + request.getTemplateId() + ) + ); + var meetingDTO = MeetingDTO.from(meetingData); var response = MeetingResponse.from(meetingDTO); - log.info("템플릿 적용 완료 - meetingId: {}", meetingId); + log.info("템플릿 적용 완료 - meetingId: {}, templateId: {}", meetingId, request.getTemplateId()); return ResponseEntity.ok(ApiResponse.success(response)); } @@ -162,15 +171,15 @@ public class MeetingController { * * @param meetingId 회의 ID * @param userId 사용자 ID - * @return 회의 정보 + * @return 회의 종료 결과 */ @Operation( summary = "회의 종료", - description = "진행 중인 회의를 종료하고 회의록 작성을 완료합니다. 자동 Todo 추출 및 알림 발송이 수행됩니다.", + description = "진행 중인 회의를 종료하고 AI 분석을 통해 회의록을 생성합니다. 주요 키워드 추출, 안건별 요약, Todo 자동 추출이 수행됩니다.", security = @SecurityRequirement(name = "bearerAuth") ) @PostMapping("/{meetingId}/end") - public ResponseEntity> endMeeting( + public ResponseEntity> endMeeting( @Parameter(description = "회의 ID", required = true) @PathVariable String meetingId, @Parameter(description = "사용자 ID", required = true) @@ -182,15 +191,102 @@ public class MeetingController { log.info("회의 종료 요청 - meetingId: {}, userId: {}", meetingId, userId); - var meetingData = endMeetingUseCase.endMeeting(meetingId); - var meetingDTO = MeetingDTO.from(meetingData); - var response = MeetingResponse.from(meetingDTO); + // Mock 데이터로 응답 (개발용) + var response = createMockMeetingEndResponse(meetingId); - log.info("회의 종료 완료 - meetingId: {}", meetingId); + log.info("회의 종료 완료 (Mock) - meetingId: {}", meetingId); return ResponseEntity.ok(ApiResponse.success(response)); } + /** + * 회의 종료 응답 Mock 데이터 생성 + * + * @param meetingId 회의 ID + * @return Mock 회의 종료 응답 + */ + private MeetingEndResponse createMockMeetingEndResponse(String meetingId) { + return MeetingEndResponse.builder() + .title("Q1 전략 기획 회의") + .participantCount(4) + .durationMinutes(90) + .agendaCount(3) + .todoCount(5) + .keywords(List.of("신제품 기획", "마케팅 전략", "예산 계획", "UI/UX 개선", "고객 분석")) + .agendaSummaries(List.of( + MeetingEndResponse.AgendaSummary.builder() + .title("1. 신제품 기획 방향성") + .aiSummaryShort("타겟 고객을 20-30대로 설정하고 UI/UX 개선에 집중하기로 결정") + .details(MeetingEndResponse.AgendaDetails.builder() + .discussion("신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고 모바일 중심의 사용자 경험을 강화하는 방향으로 논의됨") + .decisions(List.of( + "타겟 고객: 20-30대 직장인", + "플랫폼: 모바일 우선", + "핵심 기능: 간편 결제, 개인화 추천" + )) + .pending(List.of( + "경쟁사 분석 보완 필요", + "기술 스택 최종 검토" + )) + .build()) + .todos(List.of( + MeetingEndResponse.TodoSummary.builder() + .title("시장 조사 보고서 작성") + .build(), + MeetingEndResponse.TodoSummary.builder() + .title("와이어프레임 초안 제작") + .build() + )) + .build(), + MeetingEndResponse.AgendaSummary.builder() + .title("2. 마케팅 전략 수립") + .aiSummaryShort("SNS 마케팅과 인플루언서 협업을 통한 브랜드 인지도 향상 계획") + .details(MeetingEndResponse.AgendaDetails.builder() + .discussion("초기 론칭 시 SNS 중심의 마케팅 전략과 마이크로 인플루언서 협업을 통한 브랜드 인지도 향상 방안 논의") + .decisions(List.of( + "마케팅 채널: 인스타그램, 틱톡 우선", + "예산 배분: 인플루언서 50%, 광고 30%, 이벤트 20%", + "론칭 시기: 2024년 2분기" + )) + .pending(List.of( + "인플루언서 리스트 검토", + "마케팅 예산 최종 승인" + )) + .build()) + .todos(List.of( + MeetingEndResponse.TodoSummary.builder() + .title("인플루언서 후보 리스트 작성") + .build(), + MeetingEndResponse.TodoSummary.builder() + .title("마케팅 예산안 상세 작성") + .build() + )) + .build(), + MeetingEndResponse.AgendaSummary.builder() + .title("3. 프로젝트 일정 및 리소스") + .aiSummaryShort("개발 6개월, 테스트 2개월로 총 8개월 일정 확정") + .details(MeetingEndResponse.AgendaDetails.builder() + .discussion("전체 프로젝트 일정을 8개월로 설정하고 개발팀 6명, 디자인팀 2명으로 팀 구성 확정") + .decisions(List.of( + "전체 일정: 8개월 (개발 6개월, 테스트 2개월)", + "팀 구성: 개발 6명, 디자인 2명, PM 1명", + "주요 마일스톤: MVP 3개월, 베타 6개월, 정식 출시 8개월" + )) + .pending(List.of( + "개발자 추가 채용 검토", + "외부 업체 협업 범위 논의" + )) + .build()) + .todos(List.of( + MeetingEndResponse.TodoSummary.builder() + .title("개발자 채용 공고 작성") + .build() + )) + .build() + )) + .build(); + } + /** * 회의 정보 조회 * diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/TemplateController.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/TemplateController.java index 01e7f1c..a232e82 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/TemplateController.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/TemplateController.java @@ -1,13 +1,8 @@ package com.unicorn.hgzero.meeting.infra.controller; import com.unicorn.hgzero.common.dto.ApiResponse; -import com.unicorn.hgzero.meeting.biz.dto.TemplateDTO; -import com.unicorn.hgzero.meeting.biz.service.TemplateService; import com.unicorn.hgzero.meeting.infra.dto.response.TemplateListResponse; -import com.unicorn.hgzero.meeting.infra.dto.response.TemplateDetailResponse; -import com.unicorn.hgzero.meeting.infra.cache.CacheService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -15,12 +10,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; /** - * 템플릿 관리 API Controller - * 템플릿 목록 조회, 상세 조회 기능 + * 템플릿 관리API Controller + * 고정된 템플릿 목록을 제공합니다 */ @RestController @RequestMapping("/api/templates") @@ -29,11 +26,8 @@ import java.util.stream.Collectors; @Tag(name = "Template", description = "템플릿 관리 API") public class TemplateController { - private final TemplateService templateService; - private final CacheService cacheService; - /** - * 템플릿 목록 조회 + * 템플릿 목록 조회 (고정 데이터 반환) * GET /api/templates */ @GetMapping @@ -45,40 +39,19 @@ public class TemplateController { }) public ResponseEntity> getTemplateList( @RequestHeader("X-User-Id") String userId, - @RequestHeader("X-User-Name") String userName, - @Parameter(description = "템플릿 카테고리") @RequestParam(required = false) String category, - @Parameter(description = "활성 상태 (true: 활성, false: 비활성)") @RequestParam(required = false) Boolean isActive) { + @RequestHeader("X-User-Name") String userName) { - log.info("템플릿 목록 조회 요청 - userId: {}, category: {}, isActive: {}", - userId, category, isActive); + log.info("템플릿 목록 조회 요청 - userId: {}", userId); try { - // 캐시 확인 - String cacheKey = String.format("templates:list:%s:%s", - (category != null ? category : "all"), - (isActive != null ? isActive.toString() : "all")); - TemplateListResponse cachedResponse = cacheService.getCachedTemplateList(cacheKey); - if (cachedResponse != null) { - log.debug("캐시된 템플릿 목록 반환"); - return ResponseEntity.ok(ApiResponse.success(cachedResponse)); - } - - // 템플릿 목록 조회 - List templates = templateService.getTemplateList(category, isActive); - - // 응답 DTO 생성 - List templateItems = templates.stream() - .map(this::convertToTemplateItem) - .collect(Collectors.toList()); + // 고정된 템플릿 데이터 생성 + List templateItems = createFixedTemplates(); TemplateListResponse response = TemplateListResponse.builder() .templateList(templateItems) .totalCount(templateItems.size()) .build(); - // 캐시 저장 - cacheService.cacheTemplateList(cacheKey, response); - log.info("템플릿 목록 조회 성공 - count: {}", templateItems.size()); return ResponseEntity.ok(ApiResponse.success(response)); @@ -90,99 +63,103 @@ public class TemplateController { } /** - * 템플릿 상세 조회 - * GET /api/templates/{templateId} + * 고정된 템플릿 데이터 생성 */ - @GetMapping("/{templateId}") - @Operation(summary = "템플릿 상세 조회", description = "템플릿 상세 정보를 조회합니다") - public ResponseEntity> getTemplateDetail( - @RequestHeader("X-User-Id") String userId, - @RequestHeader("X-User-Name") String userName, - @Parameter(description = "템플릿 ID") @PathVariable String templateId) { - - log.info("템플릿 상세 조회 요청 - userId: {}, templateId: {}", userId, templateId); - - try { - // 캐시 확인 - TemplateDetailResponse cachedResponse = cacheService.getCachedTemplateDetail(templateId); - if (cachedResponse != null) { - log.debug("캐시된 템플릿 상세 반환 - templateId: {}", templateId); - return ResponseEntity.ok(ApiResponse.success(cachedResponse)); - } - - // 템플릿 조회 - TemplateDTO templateDTO = templateService.getTemplateById(templateId); - - // 응답 DTO 생성 - TemplateDetailResponse response = convertToTemplateDetailResponse(templateDTO); - - // 캐시 저장 - cacheService.cacheTemplateDetail(templateId, response); - - log.info("템플릿 상세 조회 성공 - templateId: {}", templateId); - return ResponseEntity.ok(ApiResponse.success(response)); - - } catch (Exception e) { - log.error("템플릿 상세 조회 실패 - templateId: {}", templateId, e); - return ResponseEntity.badRequest() - .body(ApiResponse.errorWithType("템플릿 상세 조회에 실패했습니다")); - } + private List createFixedTemplates() { + List templates = new ArrayList<>(); + + // 일반 회의 템플릿 + templates.add(TemplateListResponse.TemplateItem.builder() + .templateId("general") + .name("일반 회의") + .description("기본 회의록 형식") + .category("meeting") + .icon("📋") + .isActive(true) + .usageCount(0) + .createdAt(LocalDateTime.now()) + .lastUsedAt(null) + .createdBy("system") + .sections(Arrays.asList( + createSectionInfo("회의 개요", "회의 기본 정보", 1, true), + createSectionInfo("논의 사항", "주요 논의 내용", 2, true), + createSectionInfo("결정 사항", "회의에서 결정된 사항", 3, true), + createSectionInfo("액션 아이템", "향후 진행할 작업", 4, true) + )) + .build()); + + // 스크럼 회의 템플릿 + templates.add(TemplateListResponse.TemplateItem.builder() + .templateId("scrum") + .name("스크럼 회의") + .description("데일리 스탠드업 형식") + .category("agile") + .icon("🏃") + .isActive(true) + .usageCount(0) + .createdAt(LocalDateTime.now()) + .lastUsedAt(null) + .createdBy("system") + .sections(Arrays.asList( + createSectionInfo("어제 한 일", "지난 작업일에 완료한 작업", 1, true), + createSectionInfo("오늘 할 일", "오늘 진행할 예정 작업", 2, true), + createSectionInfo("블로커/이슈", "진행을 방해하는 요소", 3, false) + )) + .build()); + + // 킥오프 회의 템플릿 + templates.add(TemplateListResponse.TemplateItem.builder() + .templateId("kickoff") + .name("킥오프 회의") + .description("프로젝트 시작 회의") + .category("project") + .icon("🚀") + .isActive(true) + .usageCount(0) + .createdAt(LocalDateTime.now()) + .lastUsedAt(null) + .createdBy("system") + .sections(Arrays.asList( + createSectionInfo("프로젝트 개요", "프로젝트 기본 정보", 1, true), + createSectionInfo("목표 및 범위", "프로젝트 목표와 범위", 2, true), + createSectionInfo("역할 및 책임", "팀원별 역할과 책임", 3, true), + createSectionInfo("일정 및 마일스톤", "프로젝트 일정", 4, true) + )) + .build()); + + // 주간 회의 템플릿 + templates.add(TemplateListResponse.TemplateItem.builder() + .templateId("weekly") + .name("주간 회의") + .description("주간 리뷰 및 계획") + .category("review") + .icon("📅") + .isActive(true) + .usageCount(0) + .createdAt(LocalDateTime.now()) + .lastUsedAt(null) + .createdBy("system") + .sections(Arrays.asList( + createSectionInfo("지난주 성과", "지난주 달성한 성과", 1, true), + createSectionInfo("이번주 계획", "이번주 진행할 계획", 2, true), + createSectionInfo("주요 이슈", "해결이 필요한 이슈", 3, false), + createSectionInfo("다음 액션", "다음 주 액션 아이템", 4, true) + )) + .build()); + + return templates; } - // Helper methods - private TemplateListResponse.TemplateItem convertToTemplateItem(TemplateDTO templateDTO) { - // 섹션 정보 변환 - List sections = templateDTO.getSections().stream() - .map(section -> TemplateListResponse.TemplateSectionInfo.builder() - .title(section.getTitle()) - .description(section.getDescription()) - .orderIndex(section.getOrderIndex()) - .isRequired(section.isRequired()) - .build()) - .collect(Collectors.toList()); - - return TemplateListResponse.TemplateItem.builder() - .templateId(templateDTO.getTemplateId()) - .name(templateDTO.getName()) - .description(templateDTO.getDescription()) - .category(templateDTO.getCategory()) - .isActive(templateDTO.isActive()) - .usageCount(templateDTO.getUsageCount()) - .createdAt(templateDTO.getCreatedAt()) - .lastUsedAt(templateDTO.getLastUsedAt()) - .createdBy(templateDTO.getCreatedBy()) - .sections(sections) - .build(); - } - - private TemplateDetailResponse convertToTemplateDetailResponse(TemplateDTO templateDTO) { - // 섹션 상세 정보 변환 - List sections = templateDTO.getSections().stream() - .map(section -> TemplateDetailResponse.SectionDetail.builder() - .sectionId(section.getSectionId()) - .title(section.getTitle()) - .description(section.getDescription()) - .content(section.getContent()) - .orderIndex(section.getOrderIndex()) - .isRequired(section.isRequired()) - .inputType(section.getInputType()) - .placeholder(section.getPlaceholder()) - .maxLength(section.getMaxLength()) - .isEditable(section.isEditable()) - .build()) - .collect(Collectors.toList()); - - return TemplateDetailResponse.builder() - .templateId(templateDTO.getTemplateId()) - .name(templateDTO.getName()) - .description(templateDTO.getDescription()) - .category(templateDTO.getCategory()) - .isActive(templateDTO.isActive()) - .usageCount(templateDTO.getUsageCount()) - .createdAt(templateDTO.getCreatedAt()) - .lastUsedAt(templateDTO.getLastUsedAt()) - .createdBy(templateDTO.getCreatedBy()) - .sections(sections) + /** + * 템플릿 섹션 정보 생성 헬퍼 메서드 + */ + private TemplateListResponse.TemplateSectionInfo createSectionInfo( + String title, String description, int orderIndex, boolean isRequired) { + return TemplateListResponse.TemplateSectionInfo.builder() + .title(title) + .description(description) + .orderIndex(orderIndex) + .isRequired(isRequired) .build(); } } \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/request/SelectTemplateRequest.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/request/SelectTemplateRequest.java index 8fe3b0b..51b858b 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/request/SelectTemplateRequest.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/request/SelectTemplateRequest.java @@ -17,9 +17,7 @@ import jakarta.validation.constraints.NotBlank; public class SelectTemplateRequest { @NotBlank(message = "템플릿 ID는 필수입니다") - @Schema(description = "템플릿 ID", example = "template-001", required = true) + @Schema(description = "템플릿 ID", example = "general", required = true, + allowableValues = {"general", "scrum", "kickoff", "weekly"}) private String templateId; - - @Schema(description = "커스터마이징 옵션", example = "섹션 순서 변경 또는 추가 섹션 포함") - private String customization; } \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/MeetingEndResponse.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/MeetingEndResponse.java new file mode 100644 index 0000000..1bfe8b1 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/MeetingEndResponse.java @@ -0,0 +1,120 @@ +package com.unicorn.hgzero.meeting.infra.dto.response; + +import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 회의 종료 응답 DTO + */ +@Getter +@Builder +@Schema(description = "회의 종료 응답") +public class MeetingEndResponse { + + @Schema(description = "회의 제목", example = "Q1 전략 회의") + private final String title; + + @Schema(description = "참석자 수", example = "4") + private final int participantCount; + + @Schema(description = "회의 시간 (분)", example = "90") + private final int durationMinutes; + + @Schema(description = "주요 안건 수", example = "3") + private final int agendaCount; + + @Schema(description = "Todo 생성 수", example = "5") + private final int todoCount; + + @Schema(description = "주요 키워드") + private final List keywords; + + @Schema(description = "안건별 AI 요약") + private final List agendaSummaries; + + /** + * MeetingEndDTO로부터 MeetingEndResponse 생성 + */ + public static MeetingEndResponse from(MeetingEndDTO dto) { + return MeetingEndResponse.builder() + .title(dto.getTitle()) + .participantCount(dto.getParticipantCount()) + .durationMinutes(dto.getDurationMinutes()) + .agendaCount(dto.getAgendaCount()) + .todoCount(dto.getTodoCount()) + .keywords(dto.getKeywords()) + .agendaSummaries(dto.getAgendaSummaries().stream() + .map(AgendaSummary::from) + .toList()) + .build(); + } + + @Getter + @Builder + @Schema(description = "안건 요약 정보") + public static class AgendaSummary { + @Schema(description = "안건 제목", example = "1. 신제품 기획 방향성") + private final String title; + + @Schema(description = "AI 요약 (간략)", example = "타겟 고객을 20-30대로 설정, UI/UX 개선 집중") + private final String aiSummaryShort; + + @Schema(description = "상세 내용") + private final AgendaDetails details; + + @Schema(description = "Todo 목록") + private final List todos; + + public static AgendaSummary from(MeetingEndDTO.AgendaSummaryDTO dto) { + return AgendaSummary.builder() + .title(dto.getTitle()) + .aiSummaryShort(dto.getAiSummaryShort()) + .details(AgendaDetails.from(dto.getDetails())) + .todos(dto.getTodos().stream() + .map(TodoSummary::from) + .toList()) + .build(); + } + } + + @Getter + @Builder + @Schema(description = "안건 상세 내용") + public static class AgendaDetails { + @Schema(description = "논의 주제", example = "신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...") + private final String discussion; + + @Schema(description = "결정 사항") + private final List decisions; + + @Schema(description = "보류 사항") + private final List pending; + + public static AgendaDetails from(MeetingEndDTO.AgendaDetailsDTO dto) { + return AgendaDetails.builder() + .discussion(dto.getDiscussion()) + .decisions(dto.getDecisions()) + .pending(dto.getPending()) + .build(); + } + } + + @Getter + @Builder + @Schema(description = "Todo 요약 정보") + public static class TodoSummary { + @Schema(description = "Todo 제목", example = "시장 조사 보고서 작성") + private final String title; + + public static TodoSummary from(MeetingEndDTO.TodoSummaryDTO dto) { + return TodoSummary.builder() + .title(dto.getTitle()) + .build(); + } + } +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse.java index 3d600b8..80edb9f 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/TemplateListResponse.java @@ -29,6 +29,7 @@ public class TemplateListResponse { private String name; private String description; private String category; + private String icon; // 아이콘 추가 private boolean isActive; private int usageCount; private LocalDateTime createdAt; diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/MeetingAnalysisGateway.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/MeetingAnalysisGateway.java new file mode 100644 index 0000000..4ac0aa0 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/MeetingAnalysisGateway.java @@ -0,0 +1,58 @@ +package com.unicorn.hgzero.meeting.infra.gateway; + +import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis; +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisReader; +import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisWriter; +import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingAnalysisEntity; +import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingAnalysisJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * 회의 분석 Gateway + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MeetingAnalysisGateway implements MeetingAnalysisReader, MeetingAnalysisWriter { + + private final MeetingAnalysisJpaRepository repository; + + @Override + public Optional findByMeetingId(String meetingId) { + log.debug("Finding meeting analysis by meetingId: {}", meetingId); + return repository.findByMeetingId(meetingId) + .map(MeetingAnalysisEntity::toDomain); + } + + @Override + public Optional findById(String analysisId) { + log.debug("Finding meeting analysis by analysisId: {}", analysisId); + return repository.findById(analysisId) + .map(MeetingAnalysisEntity::toDomain); + } + + @Override + public Optional findByMinutesId(String minutesId) { + log.debug("Finding meeting analysis by minutesId: {}", minutesId); + return repository.findByMinutesId(minutesId) + .map(MeetingAnalysisEntity::toDomain); + } + + @Override + public MeetingAnalysis save(MeetingAnalysis analysis) { + log.debug("Saving meeting analysis: {}", analysis.getAnalysisId()); + MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis); + MeetingAnalysisEntity savedEntity = repository.save(entity); + return savedEntity.toDomain(); + } + + @Override + public void delete(String analysisId) { + log.debug("Deleting meeting analysis: {}", analysisId); + repository.deleteById(analysisId); + } +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MeetingAnalysisEntity.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MeetingAnalysisEntity.java new file mode 100644 index 0000000..1caca97 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MeetingAnalysisEntity.java @@ -0,0 +1,84 @@ +package com.unicorn.hgzero.meeting.infra.gateway.entity; + +import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 회의 분석 결과 Entity + */ +@Entity +@Table(name = "meeting_analysis") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MeetingAnalysisEntity { + + @Id + @Column(name = "analysis_id") + private String analysisId; + + @Column(name = "meeting_id", nullable = false) + private String meetingId; + + @Column(name = "minutes_id", nullable = false) + private String minutesId; + + @ElementCollection + @CollectionTable(name = "meeting_keywords", joinColumns = @JoinColumn(name = "analysis_id")) + @Column(name = "keyword") + private List keywords; + + @Column(name = "agenda_analyses", columnDefinition = "TEXT") + private String agendaAnalysesJson; // JSON 문자열로 저장 + + @Column(name = "status") + private String status; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + /** + * Entity를 도메인으로 변환 + */ + public MeetingAnalysis toDomain() { + // JSON 파싱은 실제 구현에서는 ObjectMapper 사용 + // 현재는 Mock으로 처리 + return MeetingAnalysis.builder() + .analysisId(this.analysisId) + .meetingId(this.meetingId) + .minutesId(this.minutesId) + .keywords(this.keywords) + .agendaAnalyses(List.of()) // Mock + .status(this.status) + .completedAt(this.completedAt) + .createdAt(this.createdAt) + .build(); + } + + /** + * 도메인에서 Entity로 변환 + */ + public static MeetingAnalysisEntity fromDomain(MeetingAnalysis domain) { + return MeetingAnalysisEntity.builder() + .analysisId(domain.getAnalysisId()) + .meetingId(domain.getMeetingId()) + .minutesId(domain.getMinutesId()) + .keywords(domain.getKeywords()) + .agendaAnalysesJson("{}") // Mock JSON + .status(domain.getStatus()) + .completedAt(domain.getCompletedAt()) + .createdAt(domain.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/MeetingAnalysisJpaRepository.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/MeetingAnalysisJpaRepository.java new file mode 100644 index 0000000..70b9c5c --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/MeetingAnalysisJpaRepository.java @@ -0,0 +1,24 @@ +package com.unicorn.hgzero.meeting.infra.gateway.repository; + +import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingAnalysisEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 회의 분석 JPA Repository + */ +@Repository +public interface MeetingAnalysisJpaRepository extends JpaRepository { + + /** + * 회의 ID로 분석 결과 조회 + */ + Optional findByMeetingId(String meetingId); + + /** + * 회의록 ID로 분석 결과 조회 + */ + Optional findByMinutesId(String minutesId); +} \ No newline at end of file