Merge pull request #3 from hwanny1128/feat/meeting-reservation

Feat/meeting reservation
This commit is contained in:
Daewoong Jeon 2025-10-24 15:06:26 +09:00 committed by GitHub
commit cf8c8ef64f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 7266 additions and 793 deletions

View File

@ -650,7 +650,7 @@ code + .copy-button {
<script type="text/javascript"> <script type="text/javascript">
function configurationCacheProblems() { return ( function configurationCacheProblems() { return (
// begin-report-data // begin-report-data
{"diagnostics":[{"locations":[{"taskPath":":stt:test"}],"problem":[{"text":"The automatic loading of test framework implementation dependencies has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 9.0."}],"contextualLabel":"The automatic loading of test framework implementation dependencies has been deprecated.","documentationLink":"https://docs.gradle.org/8.14/userguide/upgrading_version_8.html#test_framework_implementation_dependencies","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"the-automatic-loading-of-test-framework-implementation-dependencies","displayName":"The automatic loading of test framework implementation dependencies has been deprecated."}],"solutions":[[{"text":"Declare the desired test framework directly on the test suite or explicitly declare the test framework implementation dependencies on the test's runtime classpath."}]]}],"problemsReport":{"totalProblemCount":1,"buildName":"hgzero","requestedTasks":"build","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}} {"diagnostics":[{"locations":[{"path":"/Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/cache/CacheConfig.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"/Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/cache/CacheConfig.java uses or overrides a deprecated API."}],"severity":"ADVICE","problemDetails":[{"text":"Note: /Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/cache/CacheConfig.java uses or overrides a deprecated API."}],"contextualLabel":"/Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/cache/CacheConfig.java uses or overrides a deprecated API.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.filename","displayName":"/Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/cache/CacheConfig.java uses or overrides a deprecated API."}]},{"locations":[{"path":"/Users/daewoong/home/workspace/HGZero/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/cache/CacheConfig.java"},{"taskPath":":meeting:compileJava"}],"problem":[{"text":"Recompile with -Xlint:deprecation for details."}],"severity":"ADVICE","problemDetails":[{"text":"Note: Recompile with -Xlint:deprecation for details."}],"contextualLabel":"Recompile with -Xlint:deprecation for details.","problemId":[{"name":"java","displayName":"Java compilation"},{"name":"compilation","displayName":"Compilation"},{"name":"compiler.note.deprecated.recompile","displayName":"Recompile with -Xlint:deprecation for details."}]}],"problemsReport":{"totalProblemCount":2,"buildName":"hgzero","requestedTasks":":meeting:bootRun","documentationLink":"https://docs.gradle.org/8.14/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
// end-report-data // end-report-data
);} );}
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
nohup: ./gradlew: No such file or directory

1
meeting/logs/meeting.log Normal file
View File

@ -0,0 +1 @@
nohup: ./gradlew: No such file or directory

View File

@ -48,6 +48,16 @@ public class Meeting {
*/ */
private LocalDateTime scheduledAt; private LocalDateTime scheduledAt;
/**
* 회의 종료 예정 일시
*/
private LocalDateTime endTime;
/**
* 회의 장소
*/
private String location;
/** /**
* 회의 시작 일시 * 회의 시작 일시
*/ */

View File

@ -83,8 +83,8 @@ public class MeetingDTO {
.meetingId(meeting.getMeetingId()) .meetingId(meeting.getMeetingId())
.title(meeting.getTitle()) .title(meeting.getTitle())
.startTime(meeting.getStartedAt() != null ? meeting.getStartedAt() : meeting.getScheduledAt()) .startTime(meeting.getStartedAt() != null ? meeting.getStartedAt() : meeting.getScheduledAt())
.endTime(meeting.getEndedAt())
.purpose(meeting.getPurpose()) .purpose(meeting.getPurpose())
.endTime(meeting.getEndedAt() != null ? meeting.getEndedAt() : meeting.getEndTime())
.location(meeting.getLocation()) .location(meeting.getLocation())
.agenda(meeting.getDescription()) .agenda(meeting.getDescription())
.participants(meeting.getParticipants().stream() .participants(meeting.getParticipants().stream()

View File

@ -6,6 +6,8 @@ import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.*; 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.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter; import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -32,6 +34,8 @@ public class MeetingService implements
private final MeetingReader meetingReader; private final MeetingReader meetingReader;
private final MeetingWriter meetingWriter; private final MeetingWriter meetingWriter;
private final CacheService cacheService;
private final EventPublisher eventPublisher;
/** /**
* 회의 생성 * 회의 생성
@ -41,10 +45,31 @@ public class MeetingService implements
public Meeting createMeeting(CreateMeetingCommand command) { public Meeting createMeeting(CreateMeetingCommand command) {
log.info("Creating meeting: {}", command.title()); log.info("Creating meeting: {}", command.title());
// 회의 ID 생성 // 1. 회의 시간 유효성 검사
if (command.scheduledAt().isAfter(command.endTime()) ||
command.scheduledAt().isEqual(command.endTime())) {
log.warn("Invalid meeting time: start={}, end={}",
command.scheduledAt(), command.endTime());
throw new BusinessException(ErrorCode.INVALID_MEETING_TIME);
}
// 2. 중복 회의 체크
long conflictCount = meetingReader.countConflictingMeetings(
command.organizerId(),
command.scheduledAt(),
command.endTime()
);
if (conflictCount > 0) {
log.warn("Meeting time conflict detected: organizerId={}, count={}",
command.organizerId(), conflictCount);
throw new BusinessException(ErrorCode.MEETING_TIME_CONFLICT);
}
// 3. 회의 ID 생성
String meetingId = UUID.randomUUID().toString(); String meetingId = UUID.randomUUID().toString();
// 회의 도메인 객체 생성 // 4. 회의 도메인 객체 생성
Meeting meeting = Meeting.builder() Meeting meeting = Meeting.builder()
.meetingId(meetingId) .meetingId(meetingId)
.title(command.title()) .title(command.title())
@ -52,15 +77,44 @@ public class MeetingService implements
.description(command.description()) .description(command.description())
.location(command.location()) .location(command.location())
.scheduledAt(command.scheduledAt()) .scheduledAt(command.scheduledAt())
.endTime(command.endTime())
.location(command.location())
.status("SCHEDULED") .status("SCHEDULED")
.organizerId(command.organizerId()) .organizerId(command.organizerId())
.participants(command.participants()) .participants(command.participants())
.templateId(command.templateId()) .templateId(command.templateId())
.build(); .build();
// 회의 저장 // 5. 회의 저장
Meeting savedMeeting = meetingWriter.save(meeting); Meeting savedMeeting = meetingWriter.save(meeting);
// 6. 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMeeting(meetingId, savedMeeting, 600);
log.debug("Meeting cached: meetingId={}", meetingId);
} catch (Exception e) {
log.warn("Failed to cache meeting: meetingId={}", meetingId, e);
// 캐시 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
// 7. 참석자 초대 이벤트 발행 (비동기)
try {
eventPublisher.publishMeetingCreated(
meetingId,
command.title(),
command.scheduledAt(),
command.location(),
command.participants(),
command.organizerId(),
command.organizerId() // organizerName은 나중에 User 서비스 연동 개선
);
log.debug("Meeting invitation events published: meetingId={}, participants={}",
meetingId, command.participants().size());
} catch (Exception e) {
log.error("Failed to publish meeting invitation events: meetingId={}", meetingId, e);
// 이벤트 발행 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
log.info("Meeting created successfully: {}", savedMeeting.getMeetingId()); log.info("Meeting created successfully: {}", savedMeeting.getMeetingId());
return savedMeeting; return savedMeeting;
} }
@ -73,21 +127,31 @@ public class MeetingService implements
public Meeting startMeeting(String meetingId) { public Meeting startMeeting(String meetingId) {
log.info("Starting meeting: {}", meetingId); log.info("Starting meeting: {}", meetingId);
// Redis 캐시 조회 기능 필요
// 회의 조회 // 회의 조회
Meeting meeting = meetingReader.findById(meetingId) Meeting meeting = meetingReader.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
// 권한 검증 (생성자 or 참석자)
// 회의 상태 검증 // 회의 상태 검증
if (!"SCHEDULED".equals(meeting.getStatus())) { if (!"SCHEDULED".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
} }
// 세션 생성 기능 필요
// 회의 시작 // 회의 시작
meeting.start(); meeting.start();
// 저장 // 저장
Meeting updatedMeeting = meetingWriter.save(meeting); Meeting updatedMeeting = meetingWriter.save(meeting);
// 회의록 초안 생성 필요
// 이벤트 발행 필요
log.info("Meeting started successfully: {}", meetingId); log.info("Meeting started successfully: {}", meetingId);
return updatedMeeting; return updatedMeeting;
} }

View File

@ -20,9 +20,9 @@ public interface CreateMeetingUseCase {
*/ */
record CreateMeetingCommand( record CreateMeetingCommand(
String title, String title,
String purpose,
String description, String description,
LocalDateTime scheduledAt, LocalDateTime scheduledAt,
LocalDateTime endTime,
String location, String location,
String organizerId, String organizerId,
List<String> participants, List<String> participants,

View File

@ -40,4 +40,14 @@ public interface MeetingReader {
* 템플릿 ID로 회의 목록 조회 * 템플릿 ID로 회의 목록 조회
*/ */
List<Meeting> findByTemplateId(String templateId); List<Meeting> findByTemplateId(String templateId);
/**
* 주최자의 특정 시간대 중복 회의 개수 조회
*
* @param organizerId 주최자 ID
* @param startTime 회의 시작 시간
* @param endTime 회의 종료 시간
* @return 중복 회의 개수
*/
long countConflictingMeetings(String organizerId, LocalDateTime startTime, LocalDateTime endTime);
} }

View File

@ -23,6 +23,9 @@ public class CacheConfig {
@Value("${spring.data.redis.port:6379}") @Value("${spring.data.redis.port:6379}")
private int redisPort; private int redisPort;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Value("${spring.data.redis.database:1}") @Value("${spring.data.redis.database:1}")
private int database; private int database;
@ -33,7 +36,15 @@ public class CacheConfig {
public RedisConnectionFactory redisConnectionFactory() { public RedisConnectionFactory redisConnectionFactory() {
var factory = new LettuceConnectionFactory(redisHost, redisPort); var factory = new LettuceConnectionFactory(redisHost, redisPort);
factory.setDatabase(database); factory.setDatabase(database);
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database);
// 비밀번호가 설정된 경우에만 적용
if (redisPassword != null && !redisPassword.isEmpty()) {
factory.setPassword(redisPassword);
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}, password: ****", redisHost, redisPort, database);
} else {
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database);
}
return factory; return factory;
} }

View File

@ -5,6 +5,7 @@ import com.azure.messaging.eventhubs.EventHubProducerClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -14,6 +15,7 @@ import org.springframework.context.annotation.Configuration;
*/ */
@Configuration @Configuration
@Slf4j @Slf4j
@org.springframework.boot.autoconfigure.condition.ConditionalOnExpression("'${eventhub.connection-string:}'.length() > 0")
public class EventHubConfig { public class EventHubConfig {
@Value("${eventhub.connection-string}") @Value("${eventhub.connection-string}")

View File

@ -12,11 +12,15 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
/** /**
* Spring Security 설정 * Spring Security 설정
@ -50,7 +54,8 @@ public class SecurityConfig {
// Meeting API endpoints (for testing) // Meeting API endpoints (for testing)
.requestMatchers("/api/meetings/**").permitAll() .requestMatchers("/api/meetings/**").permitAll()
// All other requests require authentication // All other requests require authentication
.anyRequest().authenticated() // .anyRequest().authenticated()
.anyRequest().permitAll()
) )
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class) UsernamePasswordAuthenticationFilter.class)
@ -71,7 +76,8 @@ public class SecurityConfig {
// 허용할 헤더 // 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList( configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept", "Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers",
"X-User-Id", "X-User-Name", "X-User-Email"
)); ));
// 자격 증명 허용 // 자격 증명 허용
@ -84,4 +90,24 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
/**
* HttpFirewall 설정
* 한글을 포함한 모든 문자를 헤더 값으로 허용
*/
@Bean
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
// 한글을 포함한 모든 문자를 허용하도록 설정
firewall.setAllowedHeaderValues(header -> true);
// URL 인코딩된 슬래시 허용
firewall.setAllowUrlEncodedSlash(true);
// 세미콜론 허용
firewall.setAllowSemicolon(true);
return firewall;
}
} }

View File

@ -32,6 +32,36 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
HttpServletResponse response, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
// 1. X-User-* 헤더를 통한 인증 (개발/테스트용)
String headerUserId = request.getHeader("X-User-Id");
String headerUserName = request.getHeader("X-User-Name");
String headerUserEmail = request.getHeader("X-User-Email");
if (StringUtils.hasText(headerUserId)) {
// X-User-* 헤더가 있으면 이를 사용하여 인증
UserPrincipal userPrincipal = UserPrincipal.builder()
.userId(headerUserId)
.username(headerUserName != null ? headerUserName : "unknown")
.email(headerUserEmail)
.authority("USER")
.build();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userPrincipal,
null,
Collections.singletonList(new SimpleGrantedAuthority("USER"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("헤더 기반 인증된 사용자: {} ({})", userPrincipal.getUsername(), headerUserId);
filterChain.doFilter(request, response);
return;
}
// 2. JWT 토큰을 통한 인증
String token = jwtTokenProvider.resolveToken(request); String token = jwtTokenProvider.resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
@ -69,7 +99,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId); log.debug("JWT 기반 인증된 사용자: {} ({})", userPrincipal.getUsername(), userId);
} }
} }

View File

@ -23,6 +23,11 @@ public class UserPrincipal {
*/ */
private final String username; private final String username;
/**
* 사용자 이메일
*/
private final String email;
/** /**
* 사용자 권한 * 사용자 권한
*/ */

View File

@ -69,6 +69,8 @@ public class MeetingController {
request.getPurpose(), request.getPurpose(),
request.getAgenda(), request.getAgenda(),
request.getStartTime(), request.getStartTime(),
request.getEndTime(),
request.getLocation(),
request.getLocation(), request.getLocation(),
userId, userId,
request.getParticipants(), request.getParticipants(),
@ -145,7 +147,9 @@ public class MeetingController {
@RequestHeader("X-User-Email") String userEmail) { @RequestHeader("X-User-Email") String userEmail) {
log.info("회의 시작 요청 - meetingId: {}, userId: {}", meetingId, userId); log.info("회의 시작 요청 - meetingId: {}, userId: {}", meetingId, userId);
// meeting id 유효성 검증 필요
var sessionData = startMeetingUseCase.startMeeting(meetingId); var sessionData = startMeetingUseCase.startMeeting(meetingId);
var response = SessionResponse.from(sessionData); var response = SessionResponse.from(sessionData);
@ -247,7 +251,7 @@ public class MeetingController {
/** /**
* 회의 참석자 초대 * 회의 참석자 초대
* *
* @param meetingId 회의 ID * @param meetingId 회의 ID
* @param userId 사용자 ID * @param userId 사용자 ID
* @param userName 사용자명 * @param userName 사용자명
@ -271,13 +275,13 @@ public class MeetingController {
@Parameter(description = "사용자 이메일", required = true) @Parameter(description = "사용자 이메일", required = true)
@RequestHeader("X-User-Email") String userEmail, @RequestHeader("X-User-Email") String userEmail,
@Valid @RequestBody InviteParticipantRequest request) { @Valid @RequestBody InviteParticipantRequest request) {
log.info("참석자 초대 요청 - meetingId: {}, email: {}, inviter: {}", log.info("참석자 초대 요청 - meetingId: {}, email: {}, inviter: {}",
meetingId, request.getEmail(), userName); meetingId, request.getEmail(), userName);
// 프론트엔드 URL 생성 (실제 환경에서는 설정에서 가져와야 ) // 프론트엔드 URL 생성 (실제 환경에서는 설정에서 가져와야 )
String frontendUrl = "https://meeting.hgzero.com/meeting/" + meetingId; String frontendUrl = "https://meeting.hgzero.com/meeting/" + meetingId;
inviteParticipantUseCase.inviteParticipant( inviteParticipantUseCase.inviteParticipant(
new InviteParticipantUseCase.InviteParticipantCommand( new InviteParticipantUseCase.InviteParticipantCommand(
meetingId, meetingId,
@ -286,11 +290,11 @@ public class MeetingController {
frontendUrl frontendUrl
) )
); );
var response = InviteParticipantResponse.of(meetingId, request.getEmail()); var response = InviteParticipantResponse.of(meetingId, request.getEmail());
log.info("참석자 초대 완료 - meetingId: {}, email: {}", meetingId, request.getEmail()); log.info("참석자 초대 완료 - meetingId: {}, email: {}", meetingId, request.getEmail());
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
} }

View File

@ -21,6 +21,7 @@ import java.util.List;
public class CreateMeetingRequest { public class CreateMeetingRequest {
@NotBlank(message = "회의 제목은 필수입니다") @NotBlank(message = "회의 제목은 필수입니다")
@jakarta.validation.constraints.Size(max = 100, message = "회의 제목은 100자를 초과할 수 없습니다")
@Schema(description = "회의 제목", example = "Q1 전략 회의", required = true) @Schema(description = "회의 제목", example = "Q1 전략 회의", required = true)
private String title; private String title;

View File

@ -22,6 +22,7 @@ import org.springframework.stereotype.Component;
*/ */
@Component @Component
@Slf4j @Slf4j
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean(name = "eventProducer")
public class EventHubPublisher implements EventPublisher { public class EventHubPublisher implements EventPublisher {
private final EventHubProducerClient eventProducer; private final EventHubProducerClient eventProducer;
@ -120,6 +121,34 @@ public class EventHubPublisher implements EventPublisher {
EventHubConstants.EVENT_TYPE_MINUTES_FINALIZED); EventHubConstants.EVENT_TYPE_MINUTES_FINALIZED);
} }
@Override
public void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, java.util.List<String> participants,
String organizerId, String organizerName) {
// 참석자에게 개별 알림 이벤트 발행
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);
}
log.info("회의 생성 알림 발행 완료 - meetingId: {}, participants count: {}",
meetingId, participants.size());
}
/** /**
* 이벤트 발행 공통 메서드 * 이벤트 발행 공통 메서드
* *

View File

@ -6,6 +6,7 @@ import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent; import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 이벤트 발행 인터페이스 * 이벤트 발행 인터페이스
@ -59,4 +60,19 @@ public interface EventPublisher {
* 회의록 확정 이벤트 발행 (편의 메서드) * 회의록 확정 이벤트 발행 (편의 메서드)
*/ */
void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName); void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName);
/**
* 회의 생성 알림 발행 (편의 메서드)
* 참석자에게 회의 초대 이메일 발송
*
* @param meetingId 회의 ID
* @param title 회의 제목
* @param startTime 회의 시작 시간
* @param location 회의 장소
* @param participants 참석자 이메일 목록
* @param organizerId 주최자 ID
* @param organizerName 주최자 이름
*/
void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, List<String> participants, String organizerId, String organizerName);
} }

View File

@ -0,0 +1,69 @@
package com.unicorn.hgzero.meeting.infra.event.publisher;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingEndedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* No-Op EventPublisher 구현체
* EventHub가 설정되지 않은 경우 사용되는 더미 구현체
*/
@Component
@Primary
@ConditionalOnMissingBean(name = "eventProducer")
@Slf4j
public class NoOpEventPublisher implements EventPublisher {
@Override
public void publishMeetingStarted(MeetingStartedEvent event) {
log.debug("[NoOp] Meeting started event: {}", event.getMeetingId());
}
@Override
public void publishMeetingEnded(MeetingEndedEvent event) {
log.debug("[NoOp] Meeting ended event: {}", event.getMeetingId());
}
@Override
public void publishTodoAssigned(TodoAssignedEvent event) {
log.debug("[NoOp] Todo assigned event: {}", event.getTodoId());
}
@Override
public void publishNotificationRequest(NotificationRequestEvent event) {
log.debug("[NoOp] Notification request event: {}", event.getRecipientId());
}
@Override
public void publishTodoAssigned(String todoId, String title, String assigneeId, String assigneeName,
String assignedBy, String assignedByName, LocalDate dueDate) {
log.debug("[NoOp] Todo assigned: todoId={}, title={}", todoId, title);
}
@Override
public void publishTodoCompleted(String todoId, String title, String assigneeId, String assigneeName,
String completedBy, String completedByName) {
log.debug("[NoOp] Todo completed: todoId={}, title={}", todoId, title);
}
@Override
public void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName) {
log.debug("[NoOp] Minutes finalized: minutesId={}, title={}", minutesId, title);
}
@Override
public void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, List<String> participants, String organizerId, String organizerName) {
log.debug("[NoOp] Meeting created: meetingId={}, title={}, participants={}",
meetingId, title, participants.size());
}
}

View File

@ -77,4 +77,9 @@ public class MeetingGateway implements MeetingReader, MeetingWriter {
public void delete(String meetingId) { public void delete(String meetingId) {
meetingJpaRepository.deleteById(meetingId); meetingJpaRepository.deleteById(meetingId);
} }
@Override
public long countConflictingMeetings(String organizerId, LocalDateTime startTime, LocalDateTime endTime) {
return meetingJpaRepository.countConflictingMeetings(organizerId, startTime, endTime);
}
} }

View File

@ -37,6 +37,12 @@ public class MeetingEntity extends BaseTimeEntity {
@Column(name = "scheduled_at", nullable = false) @Column(name = "scheduled_at", nullable = false)
private LocalDateTime scheduledAt; private LocalDateTime scheduledAt;
@Column(name = "end_time")
private LocalDateTime endTime;
@Column(name = "location", length = 200)
private String location;
@Column(name = "started_at") @Column(name = "started_at")
private LocalDateTime startedAt; private LocalDateTime startedAt;
@ -62,6 +68,8 @@ public class MeetingEntity extends BaseTimeEntity {
.title(this.title) .title(this.title)
.description(this.description) .description(this.description)
.scheduledAt(this.scheduledAt) .scheduledAt(this.scheduledAt)
.endTime(this.endTime)
.location(this.location)
.startedAt(this.startedAt) .startedAt(this.startedAt)
.endedAt(this.endedAt) .endedAt(this.endedAt)
.status(this.status) .status(this.status)
@ -77,6 +85,8 @@ public class MeetingEntity extends BaseTimeEntity {
.title(meeting.getTitle()) .title(meeting.getTitle())
.description(meeting.getDescription()) .description(meeting.getDescription())
.scheduledAt(meeting.getScheduledAt()) .scheduledAt(meeting.getScheduledAt())
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.startedAt(meeting.getStartedAt()) .startedAt(meeting.getStartedAt())
.endedAt(meeting.getEndedAt()) .endedAt(meeting.getEndedAt())
.status(meeting.getStatus()) .status(meeting.getStatus())

View File

@ -37,4 +37,26 @@ public interface MeetingJpaRepository extends JpaRepository<MeetingEntity, Strin
* 템플릿 ID로 회의 목록 조회 * 템플릿 ID로 회의 목록 조회
*/ */
List<MeetingEntity> findByTemplateId(String templateId); List<MeetingEntity> findByTemplateId(String templateId);
/**
* 주최자의 특정 시간대 중복 회의 개수 조회
* 회의 상태가 SCHEDULED 또는 IN_PROGRESS이고,
* 시간이 겹치는 회의가 있는지 확인
*
* @param organizerId 주최자 ID
* @param startTime 회의 시작 시간
* @param endTime 회의 종료 시간
* @return 중복 회의 개수
*/
@org.springframework.data.jpa.repository.Query(
"SELECT COUNT(m) FROM MeetingEntity m WHERE " +
"m.organizerId = :organizerId AND " +
"m.status IN ('SCHEDULED', 'IN_PROGRESS') AND " +
"((m.scheduledAt < :endTime AND m.endTime > :startTime))"
)
long countConflictingMeetings(
@org.springframework.data.repository.query.Param("organizerId") String organizerId,
@org.springframework.data.repository.query.Param("startTime") LocalDateTime startTime,
@org.springframework.data.repository.query.Param("endTime") LocalDateTime endTime
);
} }

View File

@ -8,7 +8,7 @@ spring:
datasource: datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.48.72}:${DB_PORT:5432}/${DB_NAME:meetingdb} url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.48.72}:${DB_PORT:5432}/${DB_NAME:meetingdb}
username: ${DB_USERNAME:hgzerouser} username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:} password: ${DB_PASSWORD:Hi5Jessica!}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
maximum-pool-size: 20 maximum-pool-size: 20
@ -35,7 +35,7 @@ spring:
redis: redis:
host: ${REDIS_HOST:20.249.177.114} host: ${REDIS_HOST:20.249.177.114}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:Hi5Jessica!}
timeout: 2000ms timeout: 2000ms
lettuce: lettuce:
pool: pool:
@ -51,7 +51,7 @@ server:
# JWT Configuration # JWT Configuration
jwt: jwt:
secret: ${JWT_SECRET:} secret: ${JWT_SECRET:hgzero-jwt-secret-key-for-dev-environment-only-do-not-use-in-production-minimum-256-bits}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600} access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800} refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800}