This commit is contained in:
Minseo-Jo 2025-10-29 17:22:18 +09:00
commit 43633fc018
20 changed files with 2164 additions and 268 deletions

View File

@ -41,6 +41,9 @@ public enum ErrorCode {
INVALID_MEETING_TIME(HttpStatus.BAD_REQUEST, "M002", "회의 시작 시간이 종료 시간보다 늦습니다."), INVALID_MEETING_TIME(HttpStatus.BAD_REQUEST, "M002", "회의 시작 시간이 종료 시간보다 늦습니다."),
MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "M003", "회의를 찾을 수 없습니다."), MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "M003", "회의를 찾을 수 없습니다."),
INVALID_MEETING_STATUS(HttpStatus.BAD_REQUEST, "M004", "유효하지 않은 회의 상태입니다."), INVALID_MEETING_STATUS(HttpStatus.BAD_REQUEST, "M004", "유효하지 않은 회의 상태입니다."),
MEETING_NOT_IN_PROGRESS(HttpStatus.BAD_REQUEST, "M005", "회의가 진행 중이 아닙니다."),
MINUTES_NOT_FOUND(HttpStatus.NOT_FOUND, "M006", "회의록을 찾을 수 없습니다."),
AGENDA_SECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "M007", "안건 섹션을 찾을 수 없습니다."),
// 외부 시스템 에러 (4xxx) // 외부 시스템 에러 (4xxx)
EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."), EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."),

File diff suppressed because it is too large Load Diff

View File

@ -45,21 +45,6 @@ public class AgendaSection {
*/ */
private String aiSummaryShort; private String aiSummaryShort;
/**
* 논의사항 (JSON 형태로 저장)
*/
private String discussions;
/**
* 결정사항 (JSON 형태로 저장)
*/
private String decisions;
/**
* 의견 (JSON 형태로 저장)
*/
private String opinions;
/** /**
* 보류사항 (JSON 형태로 저장) * 보류사항 (JSON 형태로 저장)
*/ */
@ -70,6 +55,11 @@ public class AgendaSection {
*/ */
private String todos; private String todos;
/**
* 요약 (안건 내용)
*/
private String summary;
/** /**
* 생성일시 * 생성일시
*/ */

View File

@ -0,0 +1,21 @@
package com.unicorn.hgzero.meeting.biz.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의 메모 저장 요청")
public class MeetingMemoRequest {
@NotBlank(message = "회의 ID는 필수입니다.")
@Schema(description = "회의 ID", example = "meeting-123", required = true)
private String meetingId;
@NotBlank(message = "메모 내용은 필수입니다.")
@Schema(description = "메모 내용", example = "[00:15] 신제품의 타겟 고객층을 20-30대로 설정하고, 모바일 우선 전략을 취하기로 논의 중입니다.", required = true)
private String memo;
}

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.meeting.biz.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의 메모 저장 응답")
public class MeetingMemoResponse {
@Schema(description = "회의 ID", example = "meeting-123")
private String meetingId;
@Schema(description = "저장된 섹션 ID", example = "section-456")
private String sectionId;
@Schema(description = "저장 완료 메시지", example = "메모가 성공적으로 저장되었습니다.")
private String message;
public static MeetingMemoResponse of(String meetingId, String sectionId) {
return MeetingMemoResponse.builder()
.meetingId(meetingId)
.sectionId(sectionId)
.message("메모가 성공적으로 저장되었습니다.")
.build();
}
}

View File

@ -1,6 +1,9 @@
package com.unicorn.hgzero.meeting.biz.service; 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.AgendaSection; import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateAgendaSectionsRequest;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity; import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionRepository; import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -88,4 +91,50 @@ public class AgendaSectionService {
.map(AgendaSectionEntity::toDomain) .map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 안건 섹션 요약 수정
* @param agendaId 안건 ID
* @param summary 새로운 요약 내용
* @return 수정된 안건 섹션
*/
@Transactional
public AgendaSection updateAgendaSummary(String agendaId, String summary) {
log.info("안건 섹션 요약 수정 - agendaId: {}, summary length: {}",
agendaId, summary != null ? summary.length() : 0);
// 먼저 존재 여부 확인
AgendaSectionEntity entity = agendaSectionRepository.findById(agendaId)
.orElseThrow(() -> new BusinessException(ErrorCode.AGENDA_SECTION_NOT_FOUND,
"안건 섹션을 찾을 수 없습니다: " + agendaId));
// Native Query로 summary만 업데이트
agendaSectionRepository.updateSummaryById(agendaId, summary);
// 업데이트된 엔티티 다시 조회
AgendaSectionEntity updatedEntity = agendaSectionRepository.findById(agendaId)
.orElseThrow(() -> new BusinessException(ErrorCode.AGENDA_SECTION_NOT_FOUND,
"업데이트된 안건 섹션을 찾을 수 없습니다: " + agendaId));
log.info("안건 섹션 요약 수정 완료 - agendaId: {}", agendaId);
return updatedEntity.toDomain();
}
/**
* 여러 안건 섹션의 요약을 번에 수정
* @param updateItems 안건 ID와 새로운 요약 내용의 목록
* @return 수정된 안건 섹션 목록
*/
@Transactional
public List<AgendaSection> updateMultipleAgendaSummaries(
List<UpdateAgendaSectionsRequest.AgendaUpdateItem> updateItems) {
log.info("안건 섹션 일괄 수정 - 개수: {}", updateItems.size());
List<AgendaSection> updatedSections = updateItems.stream()
.map(item -> updateAgendaSummary(item.getAgendaId(), item.getContent()))
.collect(Collectors.toList());
log.info("안건 섹션 일괄 수정 완료 - 개수: {}", updatedSections.size());
return updatedSections;
}
} }

View File

@ -0,0 +1,144 @@
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.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.SaveMeetingMemoUseCase;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* 회의 메모 관리 Service
* 회의 작성되는 메모를 MinutesSection에 저장하고 관리합니다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MeetingMemoService implements SaveMeetingMemoUseCase {
private final MeetingReader meetingReader;
private final MinutesReader minutesReader;
private final MinutesSectionReader minutesSectionReader;
private final MinutesSectionWriter minutesSectionWriter;
/**
* 회의 AI 메모 저장
* @param command 메모 저장 명령
* @return 저장된 섹션 ID
*/
@Override
@Transactional
public String saveMemo(SaveMemoCommand command) {
log.info("Saving meeting memo for meetingId: {}", command.getMeetingId());
// 1. 회의 존재 여부 진행 상태 확인
Meeting meeting = meetingReader.findById(command.getMeetingId())
.orElseThrow(() -> {
log.error("Meeting not found: {}", command.getMeetingId());
return new BusinessException(ErrorCode.MEETING_NOT_FOUND);
});
// 2. 회의가 진행 중인지 확인
if (!meeting.isInProgress()) {
log.warn("Meeting is not in progress: meetingId={}", command.getMeetingId());
throw new BusinessException(ErrorCode.MEETING_NOT_IN_PROGRESS);
}
// 3. 해당 회의의 회의록 조회
List<Minutes> minutesList = minutesReader.findByMeetingId(command.getMeetingId());
if (minutesList.isEmpty()) {
log.error("Minutes not found for meetingId: {}", command.getMeetingId());
throw new BusinessException(ErrorCode.MINUTES_NOT_FOUND);
}
Minutes minutes = minutesList.get(0); // 번째 회의록 사용
// 4. 기존 메모 섹션 조회 또는 새로운 섹션 생성
MinutesSection memoSection = minutesSectionReader.findFirstByMinutesIdAndType(
minutes.getMinutesId(),
"AI_MEMO"
).orElseGet(() -> createNewMemoSection(minutes.getMinutesId()));
// 5. 메모 내용 업데이트 (기존 내용에 추가)
String updatedContent = updateMemoContent(
memoSection.getContent(),
command.getMemo(),
command.getUserId()
);
// 6. 섹션 업데이트 저장
MinutesSection updatedSection = MinutesSection.builder()
.sectionId(memoSection.getSectionId())
.minutesId(memoSection.getMinutesId())
.type("AI_MEMO")
.title("회의 메모")
.content(updatedContent)
.order(memoSection.getOrder())
.verified(false)
.locked(false)
.build();
minutesSectionWriter.save(updatedSection);
log.info("Meeting memo saved successfully: sectionId={}", updatedSection.getSectionId());
return updatedSection.getSectionId();
}
/**
* 새로운 메모 섹션 생성
*/
private MinutesSection createNewMemoSection(String minutesId) {
// 해당 회의록의 최대 order 조회
int maxOrder = minutesSectionReader.getMaxOrderByMinutesId(minutesId);
return MinutesSection.builder()
.sectionId(generateSectionId())
.minutesId(minutesId)
.type("AI_MEMO")
.title("회의 메모")
.content("")
.order(maxOrder + 1)
.verified(false)
.locked(false)
.build();
}
/**
* 메모 내용 업데이트
* 기존 내용이 있으면 추가하고, 없으면 새로 작성
*/
private String updateMemoContent(String existingContent, String newMemo, String userId) {
StringBuilder content = new StringBuilder();
if (existingContent != null && !existingContent.trim().isEmpty()) {
content.append(existingContent);
content.append("\n\n");
}
// 타임스탬프와 함께 새로운 메모 추가
content.append(String.format("[%s - %s]\n%s",
LocalDateTime.now().toString().substring(11, 19), // HH:mm:ss
userId,
newMemo));
return content.toString();
}
/**
* 섹션 ID 생성
*/
private String generateSectionId() {
return "section-" + UUID.randomUUID().toString();
}
}

View File

@ -371,6 +371,10 @@ public class MinutesService implements
log.warn("섹션 정보 변환 실패 - minutesId: {}", minutes.getMinutesId(), e); log.warn("섹션 정보 변환 실패 - minutesId: {}", minutes.getMinutesId(), e);
} }
// decisions 로깅
log.info("Minutes decisions 값 확인 - minutesId: {}, decisions: {}",
minutes.getMinutesId(), minutes.getDecisions());
return MinutesDTO.builder() return MinutesDTO.builder()
.minutesId(minutes.getMinutesId()) .minutesId(minutes.getMinutesId())
.meetingId(minutes.getMeetingId()) .meetingId(minutes.getMeetingId())
@ -387,6 +391,7 @@ public class MinutesService implements
.participantCount(participantCount) .participantCount(participantCount)
.memo("") // 메모 필드는 추후 구현 .memo("") // 메모 필드는 추후 구현
.sections(sectionDTOs) // 섹션 정보 추가 .sections(sectionDTOs) // 섹션 정보 추가
.decisions(minutes.getDecisions()) // decisions 필드 추가
.build(); .build();
} }

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.meeting.biz.usecase.in.meeting;
import lombok.Builder;
import lombok.Getter;
/**
* 회의 AI 메모 저장 UseCase
* 회의 진행 사용자가 작성한 메모를 MinutesSection의 content에 저장합니다.
*/
public interface SaveMeetingMemoUseCase {
/**
* 회의 메모 저장
* @param command 메모 저장 명령
* @return 저장된 메모의 섹션 ID
*/
String saveMemo(SaveMemoCommand command);
/**
* 메모 저장 명령
*/
@Getter
@Builder
class SaveMemoCommand {
private final String meetingId;
private final String memo;
private final String userId;
}
}

View File

@ -39,4 +39,14 @@ public interface MinutesSectionReader {
* 잠금한 사용자 ID로 섹션 목록 조회 * 잠금한 사용자 ID로 섹션 목록 조회
*/ */
List<MinutesSection> findByLockedBy(String lockedBy); List<MinutesSection> findByLockedBy(String lockedBy);
/**
* 회의록 ID와 타입으로 단일 섹션 조회
*/
Optional<MinutesSection> findFirstByMinutesIdAndType(String minutesId, String type);
/**
* 회의록 ID로 최대 order 조회
*/
int getMaxOrderByMinutesId(String minutesId);
} }

View File

@ -13,6 +13,8 @@ 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.MeetingResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingEndResponse; import com.unicorn.hgzero.meeting.infra.dto.response.MeetingEndResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.SessionResponse; import com.unicorn.hgzero.meeting.infra.dto.response.SessionResponse;
import com.unicorn.hgzero.meeting.biz.dto.request.MeetingMemoRequest;
import com.unicorn.hgzero.meeting.biz.dto.response.MeetingMemoResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@ -44,6 +46,7 @@ public class MeetingController {
private final CancelMeetingUseCase cancelMeetingUseCase; private final CancelMeetingUseCase cancelMeetingUseCase;
private final InviteParticipantUseCase inviteParticipantUseCase; private final InviteParticipantUseCase inviteParticipantUseCase;
private final ApplyTemplateUseCase applyTemplateUseCase; private final ApplyTemplateUseCase applyTemplateUseCase;
private final SaveMeetingMemoUseCase saveMeetingMemoUseCase;
/** /**
* 회의 예약 * 회의 예약
@ -319,4 +322,48 @@ public class MeetingController {
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
/**
* 회의 AI 메모 저장
*
* @param meetingId 회의 ID
* @param userId 사용자 ID
* @param userName 사용자명
* @param userEmail 사용자 이메일
* @param request 메모 저장 요청
* @return 메모 저장 결과
*/
@Operation(
summary = "회의 중 AI 메모 저장",
description = "회의 진행 중 사용자가 작성한 메모를 MinutesSection에 저장합니다. AI가 감지한 주요 내용과 사용자가 직접 입력한 내용을 함께 저장할 수 있습니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@PostMapping("/{meetingId}/memo")
public ResponseEntity<ApiResponse<MeetingMemoResponse>> saveMeetingMemo(
@Parameter(description = "회의 ID", required = true)
@PathVariable String meetingId,
@Parameter(description = "사용자 ID", required = true)
@RequestHeader("X-User-Id") String userId,
@Parameter(description = "사용자명", required = true)
@RequestHeader("X-User-Name") String userName,
@Parameter(description = "사용자 이메일", required = true)
@RequestHeader("X-User-Email") String userEmail,
@Valid @RequestBody MeetingMemoRequest request) {
log.info("회의 메모 저장 요청 - meetingId: {}, userId: {}", meetingId, userId);
String sectionId = saveMeetingMemoUseCase.saveMemo(
SaveMeetingMemoUseCase.SaveMemoCommand.builder()
.meetingId(meetingId)
.memo(request.getMemo())
.userId(userId)
.build()
);
var response = MeetingMemoResponse.of(meetingId, sectionId);
log.info("회의 메모 저장 완료 - meetingId: {}, sectionId: {}", meetingId, sectionId);
return ResponseEntity.ok(ApiResponse.success(response));
}
} }

View File

@ -14,6 +14,7 @@ import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
import com.unicorn.hgzero.meeting.biz.service.TodoService; import com.unicorn.hgzero.meeting.biz.service.TodoService;
import com.unicorn.hgzero.meeting.biz.service.AgendaSectionService; import com.unicorn.hgzero.meeting.biz.service.AgendaSectionService;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateMinutesRequest; import com.unicorn.hgzero.meeting.infra.dto.request.UpdateMinutesRequest;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateAgendaSectionsRequest;
import com.unicorn.hgzero.meeting.infra.dto.response.MinutesDetailResponse; import com.unicorn.hgzero.meeting.infra.dto.response.MinutesDetailResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MinutesListResponse; import com.unicorn.hgzero.meeting.infra.dto.response.MinutesListResponse;
import com.unicorn.hgzero.meeting.infra.cache.CacheService; import com.unicorn.hgzero.meeting.infra.cache.CacheService;
@ -186,7 +187,8 @@ public class MinutesController {
* PATCH /api/minutes/{minutesId} * PATCH /api/minutes/{minutesId}
*/ */
@PatchMapping("/{minutesId}") @PatchMapping("/{minutesId}")
@Operation(summary = "회의록 수정", description = "회의록 제목과 메모를 수정합니다") @Operation(summary = "회의록 수정 (사용X -> 회의록 안건별 수정 API 사용)",
description = "회의록 제목과 메모를 수정합니다. 이 API 대신 회의록 안건별 수정 API(/api/minutes/{minutesId}/agenda-sections)를 사용하세요.")
public ResponseEntity<ApiResponse<MinutesDetailResponse>> updateMinutes( public ResponseEntity<ApiResponse<MinutesDetailResponse>> updateMinutes(
@RequestHeader("X-User-Id") String userId, @RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Name") String userName, @RequestHeader("X-User-Name") String userName,
@ -415,6 +417,54 @@ public class MinutesController {
} }
} }
/**
* 회의록 안건별 수정
* PUT /api/minutes/{minutesId}/agenda-sections
*/
@PutMapping("/{minutesId}/agenda-sections")
@Operation(summary = "회의록 안건별 수정",
description = "회의록의 안건별 내용을 수정합니다. 각 안건의 summary 필드만 업데이트됩니다.")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "권한 없음"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "회의록을 찾을 수 없음")
})
public ResponseEntity<ApiResponse<String>> updateAgendaSections(
@RequestHeader("X-User-Id") String userId,
@RequestHeader(value = "X-User-Name", defaultValue = "System") String userName,
@PathVariable String minutesId,
@Valid @RequestBody UpdateAgendaSectionsRequest request) {
log.info("[API] 회의록 안건별 수정 요청 - userId: {}, minutesId: {}, agendaCount: {}",
userId, minutesId, request.getAgendas().size());
try {
// 권한 검증 제거 - 모든 사용자가 수정 가능
log.info("회의록 수정 권한 검증 스킵 - 모든 사용자 수정 가능");
// 안건별 내용 수정
List<AgendaSection> updatedSections = agendaSectionService.updateMultipleAgendaSummaries(request.getAgendas());
// 캐시 무효화
cacheService.evictCacheMinutesDetail(minutesId);
log.info("회의록 안건별 수정 성공 - minutesId: {}, updatedCount: {}",
minutesId, updatedSections.size());
return ResponseEntity.ok(ApiResponse.success("회의록이 성공적으로 수정되었습니다"));
} catch (BusinessException e) {
log.error("회의록 안건별 수정 실패 - 비즈니스 예외 - minutesId: {}", minutesId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType(e.getMessage()));
} catch (Exception e) {
log.error("회의록 안건별 수정 실패 - minutesId: {}", minutesId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("회의록 수정에 실패했습니다"));
}
}
// Helper methods // Helper methods
/** /**
@ -840,8 +890,10 @@ public class MinutesController {
.todoCount(todoProgress.getTotalCount()) .todoCount(todoProgress.getTotalCount())
.build(); .build();
// 결정사항 추출 // minutes 테이블의 decisions 텍스트 사용
List<MinutesDetailResponse.Decision> decisions = extractDecisions(agendas); String decisions = minutesDTO.getDecisions() != null ? minutesDTO.getDecisions() : "";
log.info("Dashboard decisions 값 확인 - minutesId: {}, decisions: {}",
minutesDTO.getMinutesId(), decisions);
// AI 기반 관련회의록 조회 (캐시 우선) // AI 기반 관련회의록 조회 (캐시 우선)
List<MinutesDetailResponse.RelatedMinutes> relatedMinutes = getRelatedMinutesFromAI(minutesDTO.getMinutesId()); List<MinutesDetailResponse.RelatedMinutes> relatedMinutes = getRelatedMinutesFromAI(minutesDTO.getMinutesId());
@ -883,29 +935,12 @@ public class MinutesController {
MinutesSection minutesSection = (MinutesSection) section; MinutesSection minutesSection = (MinutesSection) section;
// AI 요약 정보 구성 (현재는 기본값 사용)
MinutesDetailResponse.AiSummary aiSummary = MinutesDetailResponse.AiSummary.builder()
.content(minutesSection.getContent() != null ? minutesSection.getContent() : "AI 요약 정보 없음")
.generatedAt(LocalDateTime.now().minusMinutes(30))
.modifiedAt(LocalDateTime.now().minusMinutes(10))
.build();
// 안건 상세 내용 구성
MinutesDetailResponse.AgendaDetails details = MinutesDetailResponse.AgendaDetails.builder()
.discussions(parseDiscussions(minutesSection.getContent()))
.decisions(parseDecisions(minutesSection.getContent()))
.build();
return MinutesDetailResponse.AgendaInfo.builder() return MinutesDetailResponse.AgendaInfo.builder()
.agendaId(minutesSection.getSectionId()) .agendaId(minutesSection.getSectionId())
.title(minutesSection.getTitle() != null ? minutesSection.getTitle() : "제목 없음") .title(minutesSection.getTitle() != null ? minutesSection.getTitle() : "제목 없음")
.orderIndex(minutesSection.getOrder() != null ? minutesSection.getOrder() : 1) .orderIndex(minutesSection.getOrder() != null ? minutesSection.getOrder() : 1)
.isVerified(minutesSection.isVerified()) .aiSummary("") // MinutesSection에는 AI 요약이 없음
.verifiedBy(minutesSection.isVerified() ? "시스템" : null) .content(minutesSection.getContent() != null ? minutesSection.getContent() : "")
.verifiedAt(minutesSection.isVerified() ? LocalDateTime.now().minusHours(1) : null)
.aiSummary(aiSummary)
.details(details)
.relatedMinutes(new ArrayList<>()) // 관련 회의록은 별도 로직 필요
.build(); .build();
} }
@ -913,67 +948,15 @@ public class MinutesController {
* AgendaSection을 AgendaInfo로 변환 * AgendaSection을 AgendaInfo로 변환
*/ */
private MinutesDetailResponse.AgendaInfo convertAgendaSectionToAgendaInfo(AgendaSection agendaSection) { private MinutesDetailResponse.AgendaInfo convertAgendaSectionToAgendaInfo(AgendaSection agendaSection) {
// AI 요약 정보 구성
MinutesDetailResponse.AiSummary aiSummary = MinutesDetailResponse.AiSummary.builder()
.content(agendaSection.getAiSummaryShort() != null ? agendaSection.getAiSummaryShort() : "")
.generatedAt(agendaSection.getCreatedAt() != null ? agendaSection.getCreatedAt() : LocalDateTime.now())
.modifiedAt(agendaSection.getUpdatedAt() != null ? agendaSection.getUpdatedAt() : LocalDateTime.now())
.build();
// 안건 상세 내용 구성 - JSON 파싱
List<String> discussionsList = parseJsonToList(agendaSection.getDiscussions());
List<String> decisionsList = parseJsonToList(agendaSection.getDecisions());
MinutesDetailResponse.AgendaDetails details = MinutesDetailResponse.AgendaDetails.builder()
.discussions(discussionsList)
.decisions(decisionsList)
.build();
return MinutesDetailResponse.AgendaInfo.builder() return MinutesDetailResponse.AgendaInfo.builder()
.agendaId(agendaSection.getId()) .agendaId(agendaSection.getId())
.title(agendaSection.getAgendaTitle() != null ? agendaSection.getAgendaTitle() : "제목 없음") .title(agendaSection.getAgendaTitle() != null ? agendaSection.getAgendaTitle() : "제목 없음")
.orderIndex(agendaSection.getAgendaNumber() != null ? agendaSection.getAgendaNumber() : 1) .orderIndex(agendaSection.getAgendaNumber() != null ? agendaSection.getAgendaNumber() : 1)
.isVerified(true) // agenda_sections는 기본적으로 검증된 데이터 .aiSummary(agendaSection.getAiSummaryShort() != null ? agendaSection.getAiSummaryShort() : "")
.verifiedBy("AI") .content(agendaSection.getSummary() != null ? agendaSection.getSummary() : "")
.verifiedAt(agendaSection.getCreatedAt())
.aiSummary(aiSummary)
.details(details)
.relatedMinutes(new ArrayList<>()) // 관련 회의록은 별도 로직 필요
.build(); .build();
} }
/**
* JSON 문자열을 List<String>으로 파싱
*/
private List<String> parseJsonToList(String json) {
if (json == null || json.isEmpty()) {
return new ArrayList<>();
}
try {
// 간단한 JSON 배열 파싱
json = json.trim();
if (json.startsWith("[") && json.endsWith("]")) {
json = json.substring(1, json.length() - 1);
if (json.isEmpty()) {
return new ArrayList<>();
}
// 쉼표로 분리하고 따옴표 제거
return Arrays.stream(json.split(","))
.map(s -> s.trim())
.map(s -> s.startsWith("\"") && s.endsWith("\"") ? s.substring(1, s.length() - 1) : s)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
// JSON 형식이 아니면 그대로 줄로 반환
return List.of(json);
} catch (Exception e) {
log.warn("JSON 파싱 실패: {}", json, e);
return List.of(json); // 파싱 실패시 원본 텍스트를 그대로 반환
}
}
private MinutesDetailResponse.SimpleTodo convertToSimpleTodo(Object todo) { private MinutesDetailResponse.SimpleTodo convertToSimpleTodo(Object todo) {
@ -1014,10 +997,10 @@ public class MinutesController {
List<MinutesDetailResponse.KeyPoint> keyPoints = new ArrayList<>(); List<MinutesDetailResponse.KeyPoint> keyPoints = new ArrayList<>();
for (int i = 0; i < agendas.size() && i < 4; i++) { for (int i = 0; i < agendas.size() && i < 4; i++) {
MinutesDetailResponse.AgendaInfo agenda = agendas.get(i); MinutesDetailResponse.AgendaInfo agenda = agendas.get(i);
if (agenda.getAiSummary() != null) { if (agenda.getAiSummary() != null && !agenda.getAiSummary().isEmpty()) {
keyPoints.add(MinutesDetailResponse.KeyPoint.builder() keyPoints.add(MinutesDetailResponse.KeyPoint.builder()
.index(i + 1) .index(i + 1)
.content(agenda.getAiSummary().getContent()) .content(agenda.getAiSummary())
.build()); .build());
} }
} }
@ -1030,50 +1013,6 @@ public class MinutesController {
return keyPoints; return keyPoints;
} }
/**
* 회의록 섹션 내용에서 논의사항 추출
*/
private List<String> parseDiscussions(String content) {
if (content == null || content.trim().isEmpty()) {
return List.of("논의 내용 없음");
}
// 간단한 패턴으로 논의사항 추출 (실제로는 AI 파싱 필요)
Pattern pattern = Pattern.compile("논의[:]\\s*(.+?)(?=결정|\\n|$)", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(content);
List<String> discussions = new ArrayList<>();
while (matcher.find()) {
discussions.add(matcher.group(1).trim());
}
if (discussions.isEmpty()) {
// 전체 내용을 논의사항으로 처리
discussions.add(content.length() > 100 ? content.substring(0, 100) + "..." : content);
}
return discussions;
}
/**
* 회의록 섹션 내용에서 결정사항 추출
*/
private List<String> parseDecisions(String content) {
if (content == null || content.trim().isEmpty()) {
return new ArrayList<>();
}
// 간단한 패턴으로 결정사항 추출 (실제로는 AI 파싱 필요)
Pattern pattern = Pattern.compile("결정[:]\\s*(.+?)(?=논의|\\n|$)", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(content);
List<String> decisions = new ArrayList<>();
while (matcher.find()) {
decisions.add(matcher.group(1).trim());
}
return decisions;
}
/** /**
* 담당자 이름 조회 (실제로는 User 서비스에서 조회 필요) * 담당자 이름 조회 (실제로는 User 서비스에서 조회 필요)
@ -1126,26 +1065,6 @@ public class MinutesController {
return List.of("#AI회의록", "#음성인식", "#협업도구", "#스타트업", "#베타출시"); return List.of("#AI회의록", "#음성인식", "#협업도구", "#스타트업", "#베타출시");
} }
private List<MinutesDetailResponse.Decision> extractDecisions(List<MinutesDetailResponse.AgendaInfo> agendas) {
List<MinutesDetailResponse.Decision> decisions = new ArrayList<>();
for (MinutesDetailResponse.AgendaInfo agenda : agendas) {
if (agenda.getDetails() != null && agenda.getDetails().getDecisions() != null) {
for (String decision : agenda.getDetails().getDecisions()) {
decisions.add(MinutesDetailResponse.Decision.builder()
.content(decision)
.decidedBy("김민준")
.decidedAt(LocalDateTime.now().minusHours(2))
.background("안건 논의 결과")
.build());
}
}
}
// 실제 데이터만 사용
return decisions;
}
// === 샘플 데이터 생성 메소드들 === // === 샘플 데이터 생성 메소드들 ===
@ -1192,9 +1111,9 @@ public class MinutesController {
log.debug("AI 분석 결과로 대시보드 정보 업데이트 완료 - minutesId: {}", log.debug("AI 분석 결과로 대시보드 정보 업데이트 완료 - minutesId: {}",
minutesDTO.getMinutesId()); minutesDTO.getMinutesId());
} else { } else {
// AI 분석 결과가 없으면 비동기 분석 요청 이벤트 발행 // AI 분석 결과가 없으면 비동기 분석 요청 이벤트 발행 - 제거됨
publishAiAnalysisRequest(minutesDTO, userId, userName); // publishAiAnalysisRequest(minutesDTO, userId, userName);
log.debug("AI 분석 요청 이벤트 발행 완료 - minutesId: {}", log.debug("AI 분석 요청 이벤트 발행 스킵 - DB 값 사용 - minutesId: {}",
minutesDTO.getMinutesId()); minutesDTO.getMinutesId());
} }
@ -1260,7 +1179,7 @@ public class MinutesController {
.keyPoints(keyPoints) .keyPoints(keyPoints)
.keywords(result.getKeywords() != null ? result.getKeywords() : dashboard.getKeywords()) .keywords(result.getKeywords() != null ? result.getKeywords() : dashboard.getKeywords())
.stats(dashboard.getStats()) .stats(dashboard.getStats())
.decisions(convertAiDecisions(result.getDecisions())) .decisions(dashboard.getDecisions()) // AI 분석에서는 decisions를 변경하지 않음
.todoProgress(dashboard.getTodoProgress()) .todoProgress(dashboard.getTodoProgress())
.relatedMinutes(convertAiRelatedMinutes(result.getRelatedMinutes())) .relatedMinutes(convertAiRelatedMinutes(result.getRelatedMinutes()))
.build(); .build();
@ -1289,23 +1208,6 @@ public class MinutesController {
return response; return response;
} }
/**
* AI 결정사항을 Response 형식으로 변환
*/
private List<MinutesDetailResponse.Decision> convertAiDecisions(List<AiAnalysisDTO.Decision> aiDecisions) {
if (aiDecisions == null) {
return new ArrayList<>();
}
return aiDecisions.stream()
.map(decision -> MinutesDetailResponse.Decision.builder()
.content(decision.getContent())
.decidedBy("AI 분석")
.decidedAt(LocalDateTime.now())
.background("AI가 회의록에서 추출한 결정사항")
.build())
.collect(Collectors.toList());
}
/** /**
* AI 관련회의록을 Response 형식으로 변환 * AI 관련회의록을 Response 형식으로 변환
@ -1329,10 +1231,6 @@ public class MinutesController {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* Response 객체 필드 복사 (불변 객체 업데이트용)
*/
/** /**
* AI 분석 요청 이벤트 발행 * AI 분석 요청 이벤트 발행
*/ */

View File

@ -0,0 +1,44 @@
package com.unicorn.hgzero.meeting.infra.dto.request;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 회의록 안건별 수정 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UpdateAgendaSectionsRequest {
/**
* 수정할 안건 목록
*/
@NotNull(message = "안건 목록은 필수입니다")
private List<AgendaUpdateItem> agendas;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AgendaUpdateItem {
/**
* 안건 ID (agenda_sections.id)
*/
@NotBlank(message = "안건 ID는 필수입니다")
private String agendaId;
/**
* 안건 내용 (agenda_sections.summary)
*/
@NotBlank(message = "안건 내용은 필수입니다")
private String content;
}
}

View File

@ -72,7 +72,7 @@ public class MinutesDetailResponse {
private List<KeyPoint> keyPoints; // 핵심내용 private List<KeyPoint> keyPoints; // 핵심내용
private List<String> keywords; // 키워드 태그 private List<String> keywords; // 키워드 태그
private Statistics stats; // 통계 정보 private Statistics stats; // 통계 정보
private List<Decision> decisions; // 결정사항 private String decisions; // 결정사항 (minutes 테이블의 decisions 텍스트)
private TodoProgress todoProgress; // Todo 진행상황 private TodoProgress todoProgress; // Todo 진행상황
private List<RelatedMinutes> relatedMinutes; // 관련회의록 private List<RelatedMinutes> relatedMinutes; // 관련회의록
} }
@ -97,16 +97,6 @@ public class MinutesDetailResponse {
private int todoCount; private int todoCount;
} }
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Decision {
private String content;
private String decidedBy;
private LocalDateTime decidedAt;
private String background;
}
@Getter @Getter
@Builder @Builder
@ -155,36 +145,8 @@ public class MinutesDetailResponse {
private String agendaId; private String agendaId;
private String title; private String title;
private int orderIndex; private int orderIndex;
private boolean isVerified; private String aiSummary; // agenda_sections.ai_summary_short
private String verifiedBy; private String content; // agenda_sections.summary
private LocalDateTime verifiedAt;
// AI 요약
private AiSummary aiSummary;
// 안건 상세 내용
private AgendaDetails details;
// 관련회의록
private List<RelatedMinutes> relatedMinutes;
} }
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AiSummary {
private String content;
private LocalDateTime generatedAt;
private LocalDateTime modifiedAt;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AgendaDetails {
private List<String> discussions; // 논의 사항
private List<String> decisions; // 결정 사항
}
} }

View File

@ -4,7 +4,9 @@ import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader; import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter; import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesSectionEntity; import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository; import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesJpaRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -23,6 +25,7 @@ import java.util.stream.Collectors;
public class MinutesSectionGateway implements MinutesSectionReader, MinutesSectionWriter { public class MinutesSectionGateway implements MinutesSectionReader, MinutesSectionWriter {
private final MinutesSectionJpaRepository sectionJpaRepository; private final MinutesSectionJpaRepository sectionJpaRepository;
private final MinutesJpaRepository minutesJpaRepository;
@Override @Override
public Optional<MinutesSection> findById(String sectionId) { public Optional<MinutesSection> findById(String sectionId) {
@ -37,7 +40,6 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override
public List<MinutesSection> findByMinutesIdAndType(String minutesId, String type) { public List<MinutesSection> findByMinutesIdAndType(String minutesId, String type) {
return sectionJpaRepository.findByMinutesIdAndType(minutesId, type).stream() return sectionJpaRepository.findByMinutesIdAndType(minutesId, type).stream()
.map(MinutesSectionEntity::toDomain) .map(MinutesSectionEntity::toDomain)
@ -73,6 +75,8 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
if (entity != null) { if (entity != null) {
// 기존 엔티티 업데이트 (minutes 연관관계 유지) // 기존 엔티티 업데이트 (minutes 연관관계 유지)
entity.updateContent(section.getContent());
if (section.getLocked() != null && section.getLocked()) { if (section.getLocked() != null && section.getLocked()) {
entity.lock(section.getLockedBy()); entity.lock(section.getLockedBy());
} else if (section.getLocked() != null && !section.getLocked()) { } else if (section.getLocked() != null && !section.getLocked()) {
@ -82,7 +86,7 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
entity.verify(); entity.verify();
} }
} else { } else {
// 엔티티 생성 // 엔티티 생성 - minutes 연관관계 설정 필요
entity = MinutesSectionEntity.fromDomain(section); entity = MinutesSectionEntity.fromDomain(section);
} }
@ -90,6 +94,17 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
return savedEntity.toDomain(); return savedEntity.toDomain();
} }
@Override
public Optional<MinutesSection> findFirstByMinutesIdAndType(String minutesId, String type) {
return sectionJpaRepository.findFirstByMinutesIdAndType(minutesId, type)
.map(MinutesSectionEntity::toDomain);
}
@Override
public int getMaxOrderByMinutesId(String minutesId) {
return sectionJpaRepository.getMaxOrderByMinutesId(minutesId);
}
@Override @Override
public void delete(String sectionId) { public void delete(String sectionId) {
sectionJpaRepository.deleteById(sectionId); sectionJpaRepository.deleteById(sectionId);

View File

@ -3,11 +3,16 @@ package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity; import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection; import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/** /**
* 회의 안건 섹션 엔티티 * 안건별 회의록 섹션 Entity
* agenda_sections 테이블과 매핑 * PostgreSQL JSON 컬럼을 사용하여 구조화된 데이터 저장
*/ */
@Entity @Entity
@Table(name = "agenda_sections") @Table(name = "agenda_sections")
@ -18,7 +23,7 @@ import lombok.*;
public class AgendaSectionEntity extends BaseTimeEntity { public class AgendaSectionEntity extends BaseTimeEntity {
@Id @Id
@Column(length = 36) @Column(name = "id", length = 36)
private String id; private String id;
@Column(name = "minutes_id", length = 36, nullable = false) @Column(name = "minutes_id", length = 36, nullable = false)
@ -36,23 +41,19 @@ public class AgendaSectionEntity extends BaseTimeEntity {
@Column(name = "ai_summary_short", columnDefinition = "TEXT") @Column(name = "ai_summary_short", columnDefinition = "TEXT")
private String aiSummaryShort; private String aiSummaryShort;
@Column(name = "discussions", columnDefinition = "TEXT") @JdbcTypeCode(SqlTypes.JSON)
private String discussions; @Column(name = "pending_items", columnDefinition = "json")
@Column(name = "decisions", columnDefinition = "TEXT")
private String decisions;
@Column(name = "opinions", columnDefinition = "TEXT")
private String opinions;
@Column(name = "pending_items", columnDefinition = "TEXT")
private String pendingItems; private String pendingItems;
@Column(name = "todos", columnDefinition = "TEXT") @JdbcTypeCode(SqlTypes.JSON)
@Column(name = "todos", columnDefinition = "json")
private String todos; private String todos;
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
/** /**
* 도메인 객체로 변환 * Domain 객체로 변환
*/ */
public AgendaSection toDomain() { public AgendaSection toDomain() {
return AgendaSection.builder() return AgendaSection.builder()
@ -62,32 +63,36 @@ public class AgendaSectionEntity extends BaseTimeEntity {
.agendaNumber(this.agendaNumber) .agendaNumber(this.agendaNumber)
.agendaTitle(this.agendaTitle) .agendaTitle(this.agendaTitle)
.aiSummaryShort(this.aiSummaryShort) .aiSummaryShort(this.aiSummaryShort)
.discussions(this.discussions)
.decisions(this.decisions)
.opinions(this.opinions)
.pendingItems(this.pendingItems) .pendingItems(this.pendingItems)
.todos(this.todos) .todos(this.todos)
.summary(this.summary)
.createdAt(this.getCreatedAt()) .createdAt(this.getCreatedAt())
.updatedAt(this.getUpdatedAt()) .updatedAt(this.getUpdatedAt())
.build(); .build();
} }
/** /**
* 도메인 객체에서 엔티티 생성 * Domain 객체에서 Entity 생성
*/ */
public static AgendaSectionEntity fromDomain(AgendaSection domain) { public static AgendaSectionEntity fromDomain(AgendaSection section) {
return AgendaSectionEntity.builder() return AgendaSectionEntity.builder()
.id(domain.getId()) .id(section.getId())
.minutesId(domain.getMinutesId()) .minutesId(section.getMinutesId())
.meetingId(domain.getMeetingId()) .meetingId(section.getMeetingId())
.agendaNumber(domain.getAgendaNumber()) .agendaNumber(section.getAgendaNumber())
.agendaTitle(domain.getAgendaTitle()) .agendaTitle(section.getAgendaTitle())
.aiSummaryShort(domain.getAiSummaryShort()) .aiSummaryShort(section.getAiSummaryShort())
.discussions(domain.getDiscussions()) .pendingItems(section.getPendingItems())
.decisions(domain.getDecisions()) .todos(section.getTodos())
.opinions(domain.getOpinions()) .summary(section.getSummary())
.pendingItems(domain.getPendingItems())
.todos(domain.getTodos())
.build(); .build();
} }
/**
* 요약 내용 업데이트
*/
public void updateSummary(String summary) {
this.summary = summary;
}
} }

View File

@ -72,6 +72,7 @@ public class MinutesEntity extends BaseTimeEntity {
.decisions(this.decisions) .decisions(this.decisions)
.finalizedBy(this.finalizedBy) .finalizedBy(this.finalizedBy)
.finalizedAt(this.finalizedAt) .finalizedAt(this.finalizedAt)
.decisions(this.decisions)
.build(); .build();
} }
@ -87,6 +88,7 @@ public class MinutesEntity extends BaseTimeEntity {
.decisions(minutes.getDecisions()) .decisions(minutes.getDecisions())
.finalizedBy(minutes.getFinalizedBy()) .finalizedBy(minutes.getFinalizedBy())
.finalizedAt(minutes.getFinalizedAt()) .finalizedAt(minutes.getFinalizedAt())
.decisions(minutes.getDecisions())
.build(); .build();
} }

View File

@ -90,4 +90,8 @@ public class MinutesSectionEntity extends BaseTimeEntity {
this.locked = false; this.locked = false;
this.lockedBy = null; this.lockedBy = null;
} }
public void updateContent(String content) {
this.content = content;
}
} }

View File

@ -2,6 +2,9 @@ package com.unicorn.hgzero.meeting.infra.gateway.repository;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity; import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@ -25,4 +28,14 @@ public interface AgendaSectionRepository extends JpaRepository<AgendaSectionEnti
* @return 안건 섹션 목록 * @return 안건 섹션 목록
*/ */
List<AgendaSectionEntity> findByMeetingIdOrderByAgendaNumber(String meetingId); List<AgendaSectionEntity> findByMeetingIdOrderByAgendaNumber(String meetingId);
/**
* 안건 섹션 요약 업데이트 (Native Query 사용)
* @param agendaId 안건 ID
* @param summary 요약 내용
*/
@Modifying
@Query(value = "UPDATE agenda_sections SET summary = :summary, updated_at = CURRENT_TIMESTAMP WHERE id = :agendaId",
nativeQuery = true)
void updateSummaryById(@Param("agendaId") String agendaId, @Param("summary") String summary);
} }

View File

@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* 회의록 섹션 JPA Repository * 회의록 섹션 JPA Repository
@ -39,4 +40,15 @@ public interface MinutesSectionJpaRepository extends JpaRepository<MinutesSectio
* 잠금한 사용자 ID로 섹션 목록 조회 * 잠금한 사용자 ID로 섹션 목록 조회
*/ */
List<MinutesSectionEntity> findByLockedBy(String lockedBy); List<MinutesSectionEntity> findByLockedBy(String lockedBy);
/**
* 회의록 ID와 타입으로 단일 섹션 조회
*/
Optional<MinutesSectionEntity> findFirstByMinutesIdAndType(String minutesId, String type);
/**
* 회의록 ID로 최대 order 조회
*/
@Query("SELECT COALESCE(MAX(m.order), 0) FROM MinutesSectionEntity m WHERE m.minutesId = :minutesId")
int getMaxOrderByMinutesId(@Param("minutesId") String minutesId);
} }