From 79036128ecd98c904c0980d570acde4504c73486 Mon Sep 17 00:00:00 2001 From: Minseo-Jo Date: Tue, 28 Oct 2025 14:22:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Meeting=20Service=20AI=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20API=20=EA=B0=9C=EB=B0=9C=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 구현 내용 - 참석자별 회의록 조회 API (GET /api/meetings/{meetingId}/ai/participant-minutes) - 안건별 섹션 조회 API (GET /api/meetings/{meetingId}/ai/agenda-sections) - 회의 통계 조회 API (GET /api/meetings/{meetingId}/ai/statistics) ## DB 스키마 변경 - V4 마이그레이션: agenda_sections 테이블에 todos JSON 컬럼 추가 - AI가 추출한 Todo를 안건별로 저장하는 구조 ## 주요 특징 - AI Service가 한 번에 요약 + Todo 추출 - 프로토타입 기반 요구사항 반영 (불필요한 통계 제거) - Todo 수를 agenda_sections의 todos 컬럼에서 집계 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../meeting/biz/domain/AgendaSection.java | 138 +++++++++++ .../hgzero/meeting/biz/domain/Minutes.java | 5 + .../biz/service/AgendaSectionService.java | 69 ++++++ .../meeting/biz/service/MinutesService.java | 26 ++ .../biz/usecase/out/AgendaSectionReader.java | 45 ++++ .../biz/usecase/out/AgendaSectionWriter.java | 41 +++ .../biz/usecase/out/MinutesReader.java | 17 ++ .../infra/controller/MeetingAiController.java | 233 ++++++++++++++++++ .../dto/response/AgendaSectionResponse.java | 112 +++++++++ .../response/MeetingStatisticsResponse.java | 48 ++++ .../response/ParticipantMinutesResponse.java | 89 +++++++ .../infra/gateway/AgendaSectionGateway.java | 94 +++++++ .../meeting/infra/gateway/MinutesGateway.java | 15 ++ .../gateway/entity/AgendaSectionEntity.java | 132 ++++++++++ .../infra/gateway/entity/MinutesEntity.java | 5 + .../AgendaSectionJpaRepository.java | 47 ++++ .../repository/MinutesJpaRepository.java | 11 + .../V4__add_todos_to_agenda_sections.sql | 37 +++ 18 files changed, 1164 insertions(+) create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/AgendaSection.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/AgendaSectionService.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionReader.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionWriter.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingAiController.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/AgendaSectionResponse.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/MeetingStatisticsResponse.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/ParticipantMinutesResponse.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/AgendaSectionGateway.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/AgendaSectionEntity.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/AgendaSectionJpaRepository.java create mode 100644 meeting/src/main/resources/db/migration/V4__add_todos_to_agenda_sections.sql diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/AgendaSection.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/AgendaSection.java new file mode 100644 index 0000000..30370ba --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/AgendaSection.java @@ -0,0 +1,138 @@ +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 AgendaSection { + + /** + * 섹션 고유 ID + */ + private String id; + + /** + * 회의록 ID (통합 회의록 참조) + */ + private String minutesId; + + /** + * 회의 ID + */ + private String meetingId; + + /** + * 안건 번호 (1, 2, 3...) + */ + private Integer agendaNumber; + + /** + * 안건 제목 + */ + private String agendaTitle; + + /** + * AI 생성 짧은 요약 (1줄, 20자 이내) + */ + private String aiSummaryShort; + + /** + * 논의 사항 (핵심 내용 3-5문장) + */ + private String discussions; + + /** + * 결정 사항 목록 + */ + private List decisions; + + /** + * 보류 사항 목록 + */ + private List pendingItems; + + /** + * 참석자별 의견 + */ + private List opinions; + + /** + * AI 추출 Todo 목록 + */ + private List todos; + + /** + * 생성 시간 + */ + private LocalDateTime createdAt; + + /** + * 수정 시간 + */ + private LocalDateTime updatedAt; + + /** + * 참석자 의견 내부 클래스 + */ + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ParticipantOpinion { + /** + * 발언자 이름 + */ + private String speaker; + + /** + * 의견 내용 + */ + private String opinion; + } + + /** + * Todo 항목 내부 클래스 + */ + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TodoItem { + /** + * Todo 제목 + */ + private String title; + + /** + * 담당자 + */ + private String assignee; + + /** + * 마감일 (YYYY-MM-DD) + */ + private String dueDate; + + /** + * 설명 + */ + private String description; + + /** + * 우선순위 (HIGH, MEDIUM, LOW) + */ + private String priority; + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java index 73c49e2..e82054c 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java @@ -31,6 +31,11 @@ public class Minutes { */ private String meetingId; + /** + * 작성자 사용자 ID (NULL: AI 통합 회의록, NOT NULL: 참석자별 회의록) + */ + private String userId; + /** * 회의록 제목 */ diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/AgendaSectionService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/AgendaSectionService.java new file mode 100644 index 0000000..2d74a42 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/AgendaSectionService.java @@ -0,0 +1,69 @@ +package com.unicorn.hgzero.meeting.biz.service; + +import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; +import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionReader; +import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionWriter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 안건별 회의록 섹션 서비스 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class AgendaSectionService { + + private final AgendaSectionReader agendaSectionReader; + private final AgendaSectionWriter agendaSectionWriter; + + /** + * 회의 ID로 안건별 섹션 조회 + * + * @param meetingId 회의 ID + * @return 안건별 섹션 목록 (안건 번호 순 정렬) + */ + public List getAgendaSectionsByMeeting(String meetingId) { + log.info("회의 ID로 안건별 섹션 조회: {}", meetingId); + return agendaSectionReader.findByMeetingId(meetingId); + } + + /** + * 회의록 ID로 안건별 섹션 조회 + * + * @param minutesId 회의록 ID + * @return 안건별 섹션 목록 + */ + public List getAgendaSectionsByMinutes(String minutesId) { + log.info("회의록 ID로 안건별 섹션 조회: {}", minutesId); + return agendaSectionReader.findByMinutesId(minutesId); + } + + /** + * 안건별 섹션 일괄 저장 + * + * @param sections 안건 섹션 목록 + * @return 저장된 안건 섹션 목록 + */ + @Transactional + public List saveAgendaSections(List sections) { + log.info("안건별 섹션 일괄 저장: {} 개", sections.size()); + return agendaSectionWriter.saveAll(sections); + } + + /** + * 회의 ID로 안건별 섹션 전체 삭제 + * + * @param meetingId 회의 ID + */ + @Transactional + public void deleteAgendaSectionsByMeeting(String meetingId) { + log.info("회의 ID로 안건별 섹션 전체 삭제: {}", meetingId); + agendaSectionWriter.deleteByMeetingId(meetingId); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java index 1112121..8433f28 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java @@ -358,4 +358,30 @@ public class MinutesService implements .memo("") // 메모 필드는 추후 구현 .build(); } + + /** + * 회의 ID로 참석자별 회의록 조회 + * AI Service가 통합 회의록 생성 시 사용 + * + * @param meetingId 회의 ID + * @return 참석자별 회의록 목록 (user_id IS NOT NULL) + */ + @Transactional(readOnly = true) + public List getParticipantMinutesByMeeting(String meetingId) { + log.info("회의 ID로 참석자별 회의록 조회: {}", meetingId); + return minutesReader.findParticipantMinutesByMeetingId(meetingId); + } + + /** + * 회의 ID로 AI 통합 회의록 조회 + * + * @param meetingId 회의 ID + * @return AI 통합 회의록 (user_id IS NULL) + */ + @Transactional(readOnly = true) + public Minutes getConsolidatedMinutesByMeeting(String meetingId) { + log.info("회의 ID로 AI 통합 회의록 조회: {}", meetingId); + return minutesReader.findConsolidatedMinutesByMeetingId(meetingId) + .orElse(null); + } } diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionReader.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionReader.java new file mode 100644 index 0000000..35e3683 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionReader.java @@ -0,0 +1,45 @@ +package com.unicorn.hgzero.meeting.biz.usecase.out; + +import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; + +import java.util.List; +import java.util.Optional; + +/** + * 안건별 회의록 섹션 조회 인터페이스 + */ +public interface AgendaSectionReader { + + /** + * 회의 ID로 안건별 섹션 조회 + * + * @param meetingId 회의 ID + * @return 안건별 섹션 목록 (안건 번호 순 정렬) + */ + List findByMeetingId(String meetingId); + + /** + * 회의록 ID로 안건별 섹션 조회 + * + * @param minutesId 회의록 ID + * @return 안건별 섹션 목록 + */ + List findByMinutesId(String minutesId); + + /** + * 섹션 ID로 조회 + * + * @param id 섹션 ID + * @return 안건 섹션 + */ + Optional findById(String id); + + /** + * 회의 ID와 안건 번호로 섹션 조회 + * + * @param meetingId 회의 ID + * @param agendaNumber 안건 번호 + * @return 안건 섹션 + */ + Optional findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber); +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionWriter.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionWriter.java new file mode 100644 index 0000000..15116cb --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/AgendaSectionWriter.java @@ -0,0 +1,41 @@ +package com.unicorn.hgzero.meeting.biz.usecase.out; + +import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; + +import java.util.List; + +/** + * 안건별 회의록 섹션 저장 인터페이스 + */ +public interface AgendaSectionWriter { + + /** + * 안건별 섹션 저장 + * + * @param section 안건 섹션 + * @return 저장된 안건 섹션 + */ + AgendaSection save(AgendaSection section); + + /** + * 안건별 섹션 일괄 저장 + * + * @param sections 안건 섹션 목록 + * @return 저장된 안건 섹션 목록 + */ + List saveAll(List sections); + + /** + * 안건별 섹션 삭제 + * + * @param id 섹션 ID + */ + void delete(String id); + + /** + * 회의 ID로 안건별 섹션 전체 삭제 + * + * @param meetingId 회의 ID + */ + void deleteByMeetingId(String meetingId); +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MinutesReader.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MinutesReader.java index a88c06a..f3b5093 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MinutesReader.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/usecase/out/MinutesReader.java @@ -39,4 +39,21 @@ public interface MinutesReader { * 확정자 ID로 회의록 목록 조회 */ List findByFinalizedBy(String finalizedBy); + + /** + * 회의 ID로 참석자별 회의록 조회 (user_id IS NOT NULL) + * AI Service가 통합 회의록 생성 시 사용 + * + * @param meetingId 회의 ID + * @return 참석자별 회의록 목록 + */ + List findParticipantMinutesByMeetingId(String meetingId); + + /** + * 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL) + * + * @param meetingId 회의 ID + * @return AI 통합 회의록 + */ + Optional findConsolidatedMinutesByMeetingId(String meetingId); } diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingAiController.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingAiController.java new file mode 100644 index 0000000..8bfa150 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/controller/MeetingAiController.java @@ -0,0 +1,233 @@ +package com.unicorn.hgzero.meeting.infra.controller; + +import com.unicorn.hgzero.common.dto.ApiResponse; +import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; +import com.unicorn.hgzero.meeting.biz.domain.Meeting; +import com.unicorn.hgzero.meeting.biz.domain.Minutes; +import com.unicorn.hgzero.meeting.biz.domain.MinutesSection; +import com.unicorn.hgzero.meeting.biz.service.AgendaSectionService; +import com.unicorn.hgzero.meeting.biz.service.MeetingService; +import com.unicorn.hgzero.meeting.biz.service.MinutesService; +import com.unicorn.hgzero.meeting.infra.dto.response.ParticipantMinutesResponse; +import com.unicorn.hgzero.meeting.infra.dto.response.AgendaSectionResponse; +import com.unicorn.hgzero.meeting.infra.dto.response.MeetingStatisticsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 회의 AI 기능 API Controller + * 회의 종료 후 AI 통합 회의록 생성을 위한 API + */ +@RestController +@RequestMapping("/api/meetings") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Meeting AI", description = "회의 AI 통합 기능 API") +public class MeetingAiController { + + private final MinutesService minutesService; + private final AgendaSectionService agendaSectionService; + private final MeetingService meetingService; + + @GetMapping("/{meetingId}/ai/participant-minutes") + @Operation( + summary = "참석자별 회의록 조회", + description = "특정 회의의 모든 참석자가 작성한 회의록을 조회합니다. AI Service가 통합 회의록 생성 시 사용합니다." + ) + public ResponseEntity> getParticipantMinutes( + @Parameter(description = "회의 ID") @PathVariable String meetingId, + @RequestHeader("X-User-Id") String userId) { + + log.info("참석자별 회의록 조회 요청 - meetingId: {}, userId: {}", meetingId, userId); + + try { + List participantMinutes = minutesService.getParticipantMinutesByMeeting(meetingId); + + List items = participantMinutes.stream() + .map(this::convertToParticipantMinutesItem) + .collect(Collectors.toList()); + + ParticipantMinutesResponse response = ParticipantMinutesResponse.builder() + .meetingId(meetingId) + .participantMinutes(items) + .build(); + + log.info("참석자별 회의록 조회 성공 - meetingId: {}, count: {}", meetingId, items.size()); + + return ResponseEntity.ok(ApiResponse.success(response)); + + } catch (Exception e) { + log.error("참석자별 회의록 조회 실패 - meetingId: {}", meetingId, e); + return ResponseEntity.badRequest() + .body(ApiResponse.errorWithType("참석자별 회의록 조회에 실패했습니다")); + } + } + + @GetMapping("/{meetingId}/ai/agenda-sections") + @Operation( + summary = "안건별 섹션 조회", + description = "특정 회의의 안건별 AI 요약 섹션을 조회합니다. 회의 종료 화면에서 사용합니다." + ) + public ResponseEntity> getAgendaSections( + @Parameter(description = "회의 ID") @PathVariable String meetingId, + @RequestHeader("X-User-Id") String userId) { + + log.info("안건별 섹션 조회 요청 - meetingId: {}, userId: {}", meetingId, userId); + + try { + List sections = agendaSectionService.getAgendaSectionsByMeeting(meetingId); + + List items = sections.stream() + .map(this::convertToAgendaSectionItem) + .collect(Collectors.toList()); + + AgendaSectionResponse response = AgendaSectionResponse.builder() + .meetingId(meetingId) + .sections(items) + .build(); + + log.info("안건별 섹션 조회 성공 - meetingId: {}, count: {}", meetingId, items.size()); + + return ResponseEntity.ok(ApiResponse.success(response)); + + } catch (Exception e) { + log.error("안건별 섹션 조회 실패 - meetingId: {}", meetingId, e); + return ResponseEntity.badRequest() + .body(ApiResponse.errorWithType("안건별 섹션 조회에 실패했습니다")); + } + } + + @GetMapping("/{meetingId}/ai/statistics") + @Operation( + summary = "회의 통계 조회", + description = "특정 회의의 통계 정보를 조회합니다. 회의 종료 화면에서 사용합니다." + ) + public ResponseEntity> getMeetingStatistics( + @Parameter(description = "회의 ID") @PathVariable String meetingId, + @RequestHeader("X-User-Id") String userId) { + + log.info("회의 통계 조회 요청 - meetingId: {}, userId: {}", meetingId, userId); + + try { + MeetingStatisticsResponse response = buildMeetingStatistics(meetingId); + + log.info("회의 통계 조회 성공 - meetingId: {}", meetingId); + + return ResponseEntity.ok(ApiResponse.success(response)); + + } catch (Exception e) { + log.error("회의 통계 조회 실패 - meetingId: {}", meetingId, e); + return ResponseEntity.badRequest() + .body(ApiResponse.errorWithType("회의 통계 조회에 실패했습니다")); + } + } + + private ParticipantMinutesResponse.ParticipantMinutesItem convertToParticipantMinutesItem(Minutes minutes) { + List sections = null; + if (minutes.getSections() != null) { + sections = minutes.getSections().stream() + .map(this::convertToMinutesSection) + .collect(Collectors.toList()); + } + + return ParticipantMinutesResponse.ParticipantMinutesItem.builder() + .minutesId(minutes.getMinutesId()) + .userId(minutes.getUserId()) + .title(minutes.getTitle()) + .status(minutes.getStatus()) + .version(minutes.getVersion()) + .createdBy(minutes.getCreatedBy()) + .createdAt(minutes.getCreatedAt()) + .lastModifiedAt(minutes.getLastModifiedAt()) + .sections(sections) + .build(); + } + + private ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection convertToMinutesSection(MinutesSection section) { + return ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection.builder() + .sectionId(section.getSectionId()) + .title(section.getTitle()) + .type(section.getType()) + .content(section.getContent()) + .orderIndex(section.getOrder()) + .build(); + } + + private AgendaSectionResponse.AgendaSectionItem convertToAgendaSectionItem(AgendaSection section) { + List opinions = null; + if (section.getOpinions() != null) { + opinions = section.getOpinions().stream() + .map(op -> AgendaSectionResponse.AgendaSectionItem.ParticipantOpinion.builder() + .speaker(op.getSpeaker()) + .opinion(op.getOpinion()) + .build()) + .collect(Collectors.toList()); + } + + List todos = null; + if (section.getTodos() != null) { + todos = section.getTodos().stream() + .map(todo -> AgendaSectionResponse.AgendaSectionItem.TodoItem.builder() + .title(todo.getTitle()) + .assignee(todo.getAssignee()) + .dueDate(todo.getDueDate()) + .description(todo.getDescription()) + .priority(todo.getPriority()) + .build()) + .collect(Collectors.toList()); + } + + return AgendaSectionResponse.AgendaSectionItem.builder() + .id(section.getId()) + .minutesId(section.getMinutesId()) + .agendaNumber(section.getAgendaNumber()) + .agendaTitle(section.getAgendaTitle()) + .aiSummaryShort(section.getAiSummaryShort()) + .discussions(section.getDiscussions()) + .decisions(section.getDecisions()) + .pendingItems(section.getPendingItems()) + .opinions(opinions) + .todos(todos) + .createdAt(section.getCreatedAt()) + .updatedAt(section.getUpdatedAt()) + .build(); + } + + private MeetingStatisticsResponse buildMeetingStatistics(String meetingId) { + Meeting meeting = meetingService.getMeeting(meetingId); + List sections = agendaSectionService.getAgendaSectionsByMeeting(meetingId); + + // AI가 추출한 Todo 수 계산 + int todoCount = sections.stream() + .mapToInt(s -> s.getTodos() != null ? s.getTodos().size() : 0) + .sum(); + + // 회의 시간 계산 + Integer durationMinutes = null; + if (meeting.getStartedAt() != null && meeting.getEndedAt() != null) { + durationMinutes = (int) java.time.Duration.between( + meeting.getStartedAt(), + meeting.getEndedAt() + ).toMinutes(); + } + + return MeetingStatisticsResponse.builder() + .meetingId(meetingId) + .meetingTitle(meeting.getTitle()) + .startTime(meeting.getStartedAt()) + .endTime(meeting.getEndedAt()) + .durationMinutes(durationMinutes) + .participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0) + .agendaCount(sections.size()) + .todoCount(todoCount) + .build(); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/AgendaSectionResponse.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/AgendaSectionResponse.java new file mode 100644 index 0000000..2bdd8c8 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/AgendaSectionResponse.java @@ -0,0 +1,112 @@ +package com.unicorn.hgzero.meeting.infra.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 안건별 섹션 조회 응답 DTO + * 회의 종료 화면에서 사용 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "안건별 섹션 조회 응답") +public class AgendaSectionResponse { + + @Schema(description = "회의 ID", example = "MTG-2025-001") + private String meetingId; + + @Schema(description = "안건별 섹션 목록") + private List sections; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "안건별 섹션 항목") + public static class AgendaSectionItem { + + @Schema(description = "섹션 ID", example = "AGENDA-SEC-001") + private String id; + + @Schema(description = "회의록 ID", example = "MIN-CONSOLIDATED-001") + private String minutesId; + + @Schema(description = "안건 번호", example = "1") + private Integer agendaNumber; + + @Schema(description = "안건 제목", example = "Q1 마케팅 전략 수립") + private String agendaTitle; + + @Schema(description = "AI 생성 짧은 요약", example = "타겟 고객을 20-30대로 설정") + private String aiSummaryShort; + + @Schema(description = "논의 사항", example = "신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...") + private String discussions; + + @Schema(description = "결정 사항 목록") + private List decisions; + + @Schema(description = "보류 사항 목록") + private List pendingItems; + + @Schema(description = "참석자별 의견") + private List opinions; + + @Schema(description = "AI 추출 Todo 목록") + private List todos; + + @Schema(description = "생성 시간", example = "2025-01-20T16:00:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdAt; + + @Schema(description = "수정 시간", example = "2025-01-20T16:30:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime updatedAt; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "참석자 의견") + public static class ParticipantOpinion { + + @Schema(description = "발언자 이름", example = "김민준") + private String speaker; + + @Schema(description = "의견 내용", example = "타겟 고객층을 명확히 설정하여 마케팅 전략 수립 필요") + private String opinion; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "Todo 항목") + public static class TodoItem { + + @Schema(description = "Todo 제목", example = "시장 조사 보고서 작성") + private String title; + + @Schema(description = "담당자", example = "김민준") + private String assignee; + + @Schema(description = "마감일", example = "2025-02-15") + private String dueDate; + + @Schema(description = "설명", example = "20-30대 타겟 시장 조사") + private String description; + + @Schema(description = "우선순위", example = "HIGH", allowableValues = {"HIGH", "MEDIUM", "LOW"}) + private String priority; + } + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/MeetingStatisticsResponse.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/MeetingStatisticsResponse.java new file mode 100644 index 0000000..6e715b0 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/MeetingStatisticsResponse.java @@ -0,0 +1,48 @@ +package com.unicorn.hgzero.meeting.infra.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 회의 통계 조회 응답 DTO + * 회의 종료 화면에서 통계 정보 표시 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "회의 통계 조회 응답") +public class MeetingStatisticsResponse { + + @Schema(description = "회의 ID", example = "MTG-2025-001") + private String meetingId; + + @Schema(description = "회의 제목", example = "Q1 마케팅 전략 회의") + private String meetingTitle; + + @Schema(description = "회의 시작 시간", example = "2025-01-20T14:00:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime startTime; + + @Schema(description = "회의 종료 시간", example = "2025-01-20T16:00:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime endTime; + + @Schema(description = "회의 총 시간 (분)", example = "120") + private Integer durationMinutes; + + @Schema(description = "참석자 수", example = "5") + private Integer participantCount; + + @Schema(description = "안건 수", example = "3") + private Integer agendaCount; + + @Schema(description = "Todo 수", example = "12") + private Integer todoCount; +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/ParticipantMinutesResponse.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/ParticipantMinutesResponse.java new file mode 100644 index 0000000..00605a5 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/dto/response/ParticipantMinutesResponse.java @@ -0,0 +1,89 @@ +package com.unicorn.hgzero.meeting.infra.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 참석자별 회의록 조회 응답 DTO + * AI Service가 통합 회의록 생성 시 사용 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "참석자별 회의록 조회 응답") +public class ParticipantMinutesResponse { + + @Schema(description = "회의 ID", example = "MTG-2025-001") + private String meetingId; + + @Schema(description = "참석자별 회의록 목록") + private List participantMinutes; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "참석자별 회의록 항목") + public static class ParticipantMinutesItem { + + @Schema(description = "회의록 ID", example = "MIN-001") + private String minutesId; + + @Schema(description = "작성자 사용자 ID", example = "user@example.com") + private String userId; + + @Schema(description = "회의록 제목", example = "Q1 마케팅 전략 회의 - 김민준 작성") + private String title; + + @Schema(description = "회의록 상태", example = "FINALIZED", allowableValues = {"DRAFT", "FINALIZED"}) + private String status; + + @Schema(description = "버전", example = "1") + private Integer version; + + @Schema(description = "작성자 ID", example = "user@example.com") + private String createdBy; + + @Schema(description = "생성 시간", example = "2025-01-20T14:30:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdAt; + + @Schema(description = "최종 수정 시간", example = "2025-01-20T15:30:00") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime lastModifiedAt; + + @Schema(description = "회의록 섹션 목록") + private List sections; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "회의록 섹션") + public static class MinutesSection { + + @Schema(description = "섹션 ID", example = "SEC-001") + private String sectionId; + + @Schema(description = "섹션 제목", example = "주요 논의 사항") + private String title; + + @Schema(description = "섹션 유형", example = "DISCUSSION", allowableValues = {"DISCUSSION", "DECISION", "ACTION_ITEM", "NOTE"}) + private String type; + + @Schema(description = "섹션 내용", example = "Q1 마케팅 캠페인 방향성에 대한 논의가 진행되었습니다...") + private String content; + + @Schema(description = "섹션 순서", example = "1") + private Integer orderIndex; + } + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/AgendaSectionGateway.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/AgendaSectionGateway.java new file mode 100644 index 0000000..4a8dcfa --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/AgendaSectionGateway.java @@ -0,0 +1,94 @@ +package com.unicorn.hgzero.meeting.infra.gateway; + +import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; +import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionReader; +import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionWriter; +import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity; +import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 안건별 회의록 섹션 Gateway 구현체 + */ +@Component +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class AgendaSectionGateway implements AgendaSectionReader, AgendaSectionWriter { + + private final AgendaSectionJpaRepository repository; + + @Override + public List findByMeetingId(String meetingId) { + log.debug("회의 ID로 안건별 섹션 조회: {}", meetingId); + return repository.findByMeetingIdOrderByAgendaNumberAsc(meetingId).stream() + .map(AgendaSectionEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findByMinutesId(String minutesId) { + log.debug("회의록 ID로 안건별 섹션 조회: {}", minutesId); + return repository.findByMinutesIdOrderByAgendaNumberAsc(minutesId).stream() + .map(AgendaSectionEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findById(String id) { + log.debug("ID로 안건별 섹션 조회: {}", id); + return repository.findById(id) + .map(AgendaSectionEntity::toDomain); + } + + @Override + public Optional findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber) { + log.debug("회의 ID와 안건 번호로 섹션 조회: meetingId={}, agendaNumber={}", meetingId, agendaNumber); + return Optional.ofNullable(repository.findByMeetingIdAndAgendaNumber(meetingId, agendaNumber)) + .map(AgendaSectionEntity::toDomain); + } + + @Override + @Transactional + public AgendaSection save(AgendaSection section) { + log.debug("안건별 섹션 저장: {}", section.getId()); + AgendaSectionEntity entity = AgendaSectionEntity.fromDomain(section); + AgendaSectionEntity saved = repository.save(entity); + return saved.toDomain(); + } + + @Override + @Transactional + public List saveAll(List sections) { + log.debug("안건별 섹션 일괄 저장: {} 개", sections.size()); + List entities = sections.stream() + .map(AgendaSectionEntity::fromDomain) + .collect(Collectors.toList()); + + List saved = repository.saveAll(entities); + return saved.stream() + .map(AgendaSectionEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + @Transactional + public void delete(String id) { + log.debug("안건별 섹션 삭제: {}", id); + repository.deleteById(id); + } + + @Override + @Transactional + public void deleteByMeetingId(String meetingId) { + log.debug("회의 ID로 안건별 섹션 전체 삭제: {}", meetingId); + repository.deleteByMeetingId(meetingId); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/MinutesGateway.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/MinutesGateway.java index d8809e8..c3c1ff7 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/MinutesGateway.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/MinutesGateway.java @@ -64,6 +64,21 @@ public class MinutesGateway implements MinutesReader, MinutesWriter { .collect(Collectors.toList()); } + @Override + public List findParticipantMinutesByMeetingId(String meetingId) { + log.debug("회의 ID로 참석자별 회의록 조회: {}", meetingId); + return minutesJpaRepository.findByMeetingIdAndUserIdIsNotNull(meetingId).stream() + .map(MinutesEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional findConsolidatedMinutesByMeetingId(String meetingId) { + log.debug("회의 ID로 AI 통합 회의록 조회: {}", meetingId); + return minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId) + .map(MinutesEntity::toDomain); + } + @Override public Minutes save(Minutes minutes) { // 기존 엔티티 조회 (update) 또는 새로 생성 (insert) diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/AgendaSectionEntity.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/AgendaSectionEntity.java new file mode 100644 index 0000000..044b9eb --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/AgendaSectionEntity.java @@ -0,0 +1,132 @@ +package com.unicorn.hgzero.meeting.infra.gateway.entity; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.unicorn.hgzero.common.entity.BaseTimeEntity; +import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * 안건별 회의록 섹션 Entity + * PostgreSQL JSON 컬럼을 사용하여 구조화된 데이터 저장 + */ +@Entity +@Table(name = "agenda_sections") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AgendaSectionEntity extends BaseTimeEntity { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Id + @Column(name = "id", length = 36) + private String id; + + @Column(name = "minutes_id", length = 36, nullable = false) + private String minutesId; + + @Column(name = "meeting_id", length = 50, nullable = false) + private String meetingId; + + @Column(name = "agenda_number", nullable = false) + private Integer agendaNumber; + + @Column(name = "agenda_title", length = 200, nullable = false) + private String agendaTitle; + + @Column(name = "ai_summary_short", columnDefinition = "TEXT") + private String aiSummaryShort; + + @Column(name = "discussions", columnDefinition = "TEXT") + private String discussions; + + @Column(name = "decisions", columnDefinition = "json") + private String decisionsJson; + + @Column(name = "pending_items", columnDefinition = "json") + private String pendingItemsJson; + + @Column(name = "opinions", columnDefinition = "json") + private String opinionsJson; + + @Column(name = "todos", columnDefinition = "json") + private String todosJson; + + /** + * Domain 객체로 변환 + */ + public AgendaSection toDomain() { + return AgendaSection.builder() + .id(this.id) + .minutesId(this.minutesId) + .meetingId(this.meetingId) + .agendaNumber(this.agendaNumber) + .agendaTitle(this.agendaTitle) + .aiSummaryShort(this.aiSummaryShort) + .discussions(this.discussions) + .decisions(parseJsonToList(this.decisionsJson, new TypeReference>() {})) + .pendingItems(parseJsonToList(this.pendingItemsJson, new TypeReference>() {})) + .opinions(parseJsonToList(this.opinionsJson, new TypeReference>() {})) + .todos(parseJsonToList(this.todosJson, new TypeReference>() {})) + .createdAt(this.getCreatedAt()) + .updatedAt(this.getUpdatedAt()) + .build(); + } + + /** + * Domain 객체에서 Entity 생성 + */ + public static AgendaSectionEntity fromDomain(AgendaSection section) { + return AgendaSectionEntity.builder() + .id(section.getId()) + .minutesId(section.getMinutesId()) + .meetingId(section.getMeetingId()) + .agendaNumber(section.getAgendaNumber()) + .agendaTitle(section.getAgendaTitle()) + .aiSummaryShort(section.getAiSummaryShort()) + .discussions(section.getDiscussions()) + .decisionsJson(toJson(section.getDecisions())) + .pendingItemsJson(toJson(section.getPendingItems())) + .opinionsJson(toJson(section.getOpinions())) + .todosJson(toJson(section.getTodos())) + .build(); + } + + /** + * 객체를 JSON 문자열로 변환 + */ + private static String toJson(Object obj) { + if (obj == null) { + return null; + } + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 변환 실패", e); + } + } + + /** + * JSON 문자열을 리스트로 변환 + */ + private static List parseJsonToList(String json, TypeReference> typeReference) { + if (json == null || json.trim().isEmpty()) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(json, typeReference); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 파싱 실패", e); + } + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesEntity.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesEntity.java index 21fc6c6..737c865 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesEntity.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/MinutesEntity.java @@ -31,6 +31,9 @@ public class MinutesEntity extends BaseTimeEntity { @Column(name = "meeting_id", length = 50, nullable = false) private String meetingId; + @Column(name = "user_id", length = 100) + private String userId; + @Column(name = "title", length = 200, nullable = false) private String title; @@ -59,6 +62,7 @@ public class MinutesEntity extends BaseTimeEntity { return Minutes.builder() .minutesId(this.minutesId) .meetingId(this.meetingId) + .userId(this.userId) .title(this.title) .sections(this.sections.stream() .map(MinutesSectionEntity::toDomain) @@ -77,6 +81,7 @@ public class MinutesEntity extends BaseTimeEntity { return MinutesEntity.builder() .minutesId(minutes.getMinutesId()) .meetingId(minutes.getMeetingId()) + .userId(minutes.getUserId()) .title(minutes.getTitle()) .sections(minutes.getSections() != null ? minutes.getSections().stream() diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/AgendaSectionJpaRepository.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/AgendaSectionJpaRepository.java new file mode 100644 index 0000000..a2de1b1 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/AgendaSectionJpaRepository.java @@ -0,0 +1,47 @@ +package com.unicorn.hgzero.meeting.infra.gateway.repository; + +import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 안건별 회의록 섹션 JPA Repository + */ +@Repository +public interface AgendaSectionJpaRepository extends JpaRepository { + + /** + * 회의 ID로 안건별 섹션 조회 + * 안건 번호 순으로 정렬 + * + * @param meetingId 회의 ID + * @return 안건별 섹션 목록 + */ + List findByMeetingIdOrderByAgendaNumberAsc(String meetingId); + + /** + * 회의록 ID로 안건별 섹션 조회 + * + * @param minutesId 회의록 ID + * @return 안건별 섹션 목록 + */ + List findByMinutesIdOrderByAgendaNumberAsc(String minutesId); + + /** + * 회의 ID와 안건 번호로 섹션 조회 + * + * @param meetingId 회의 ID + * @param agendaNumber 안건 번호 + * @return 안건 섹션 + */ + AgendaSectionEntity findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber); + + /** + * 회의 ID로 안건별 섹션 삭제 + * + * @param meetingId 회의 ID + */ + void deleteByMeetingId(String meetingId); +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/MinutesJpaRepository.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/MinutesJpaRepository.java index 4fd50dc..0587e72 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/MinutesJpaRepository.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/repository/MinutesJpaRepository.java @@ -42,4 +42,15 @@ public interface MinutesJpaRepository extends JpaRepository findByMeetingIdAndVersion(String meetingId, Integer version); + + /** + * 회의 ID로 참석자별 회의록 조회 (user_id IS NOT NULL) + * AI Service가 통합 회의록 생성 시 사용 + */ + List findByMeetingIdAndUserIdIsNotNull(String meetingId); + + /** + * 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL) + */ + Optional findByMeetingIdAndUserIdIsNull(String meetingId); } diff --git a/meeting/src/main/resources/db/migration/V4__add_todos_to_agenda_sections.sql b/meeting/src/main/resources/db/migration/V4__add_todos_to_agenda_sections.sql new file mode 100644 index 0000000..e9e673b --- /dev/null +++ b/meeting/src/main/resources/db/migration/V4__add_todos_to_agenda_sections.sql @@ -0,0 +1,37 @@ +-- ======================================== +-- V4: agenda_sections 테이블에 todos 컬럼 추가 +-- ======================================== +-- 작성일: 2025-10-28 +-- 설명: AI가 추출한 Todo를 안건별 섹션에 저장 + +-- ======================================== +-- 1. agenda_sections 테이블에 todos 컬럼 추가 +-- ======================================== + +ALTER TABLE agenda_sections +ADD COLUMN IF NOT EXISTS todos JSON; + +-- 코멘트 추가 +COMMENT ON COLUMN agenda_sections.todos IS 'AI 추출 Todo 목록 (JSON: [{title, assignee, dueDate, description, priority}])'; + +-- ======================================== +-- 2. 샘플 데이터 구조 (참고용) +-- ======================================== +-- +-- todos JSON 구조: +-- [ +-- { +-- "title": "시장 조사 보고서 작성", +-- "assignee": "김민준", +-- "dueDate": "2025-02-15", +-- "description": "20-30대 타겟 시장 조사", +-- "priority": "HIGH" +-- }, +-- { +-- "title": "UI/UX 개선안 초안 작성", +-- "assignee": "이서연", +-- "dueDate": "2025-02-20", +-- "description": "모바일 우선 UI 개선", +-- "priority": "MEDIUM" +-- } +-- ]