diff --git a/build.gradle b/build.gradle index da46c99..a899501 100644 --- a/build.gradle +++ b/build.gradle @@ -106,6 +106,9 @@ dependencies { // Email implementation 'org.springframework.boot:spring-boot-starter-mail' + // Github Webhook + implementation 'org.kohsuke:github-api:1.327' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/docker-compose.yml b/docker-compose.yml index 437b27f..eab2eee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,60 +1,60 @@ - services: - app: - build: - context: src - dockerfile: Dockerfile - image: gitdeun:latest - container_name: gitdeun - ports: - - "8080:8080" - restart: always - env_file: - - ./.env - environment: - SPRING_PROFILES_ACTIVE: dev,s3Bucket - TZ: Asia/Seoul - volumes: - - /data/logs/spring:/app/logs # 로그 마운트 - depends_on: - redis: - condition: service_healthy - networks: - - app-network - - redis: - image: redis:latest - container_name: gitdeun-redis - ports: - - "6379:6379" - volumes: - - redis-data:/data # 데이터 지속성을 위한 볼륨 추가 - command: redis-server --appendonly yes - restart: unless-stopped - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - networks: - - app-network +services: + app: + build: + context: src + dockerfile: Dockerfile + image: gitdeun:latest + container_name: gitdeun + ports: + - "8080:8080" + restart: always + env_file: + - ./.env + environment: + SPRING_PROFILES_ACTIVE: dev,s3Bucket + TZ: Asia/Seoul + volumes: + - /data/logs/spring:/app/logs # 로그 마운트 + depends_on: + redis: + condition: service_healthy + networks: + - app-network - nginx: - image: nginx:latest - container_name: gitdeun-nginx - ports: - - "80:80" - - "443:443" - volumes: - - /data/nginx/conf.d:/etc/nginx/conf.d - - /data/nginx/ssl:/etc/nginx/ssl - - /data/logs/nginx:/var/log/nginx - depends_on: - - app - networks: - - app-network + redis: + image: redis:latest + container_name: gitdeun-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data # 데이터 지속성을 위한 볼륨 추가 + command: redis-server --appendonly yes + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - app-network + nginx: + image: nginx:latest + container_name: gitdeun-nginx + ports: + - "80:80" + - "443:443" + volumes: + - /data/nginx/conf.d:/etc/nginx/conf.d + - /data/nginx/ssl:/etc/nginx/ssl + - /data/logs/nginx:/var/log/nginx + depends_on: + - app + networks: + - app-network - networks: - app-network: - name: gitdeun-network \ No newline at end of file + +networks: + app-network: + name: gitdeun-network \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java b/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java new file mode 100644 index 0000000..48ae467 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java @@ -0,0 +1,72 @@ +package com.teamEWSN.gitdeun.Application.entity; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "application", + uniqueConstraints = { + // 동일 사용자·공고에 대해 "활성" 신청 중복 방지용 (WITHDRAWN 제외) + @UniqueConstraint(name = "uk_active_application", + columnNames = {"recruitment_id", "applicant_id", "active"}) + }, + indexes = { + @Index(name = "idx_application_recruitment", columnList = "recruitment_id"), + @Index(name = "idx_application_applicant", columnList = "applicant_id"), + @Index(name = "idx_application_status", columnList = "status") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Application extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 모집공고 */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "recruitment_id", nullable = false) + private Recruitment recruitment; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "applicant_id", nullable = false, + foreignKey = @ForeignKey(name = "fk_applicant_user") // 외래키 값 명시 + ) + private User applicant; + + /** 지원 분야 (작성자의 모집 분야 중에서 선택) */ + @Enumerated(EnumType.STRING) + @Column(name = "applied_field", length = 32) + private RecruitmentField appliedField; + + /** 지원 메세지 */ + @Column(name = "message", length = 1000) + private String message; + + /** 신청 거절 사유(거절 시 선택) */ + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + /** 신청 상태 (검토 중 / 수락 / 거절 / 철회) */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private ApplicationStatus status; + + /** 활성 신청 여부 (WITHDRAWN 면 false) */ + @Column(nullable = false) + private boolean active; + + + // 상태 전이 + public void accept() { this.status = ApplicationStatus.ACCEPTED; } + public void reject(String reason) { this.status = ApplicationStatus.REJECTED; this.rejectReason = reason; } + public void withdraw() { this.status = ApplicationStatus.WITHDRAWN; this.active = false; } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/entity/ApplicationStatus.java b/src/main/java/com/teamEWSN/gitdeun/Application/entity/ApplicationStatus.java new file mode 100644 index 0000000..ff2e38c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/entity/ApplicationStatus.java @@ -0,0 +1,8 @@ +package com.teamEWSN.gitdeun.Application.entity; + +public enum ApplicationStatus { + PENDING, // 검토중 + ACCEPTED, // 수락 + REJECTED, // 거절 + WITHDRAWN // 철회 +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java new file mode 100644 index 0000000..95a2498 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java @@ -0,0 +1,112 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "recruitment", + indexes = { + @Index(name = "idx_recruitment_status", columnList = "status"), + @Index(name = "idx_recruitment_deadline", columnList = "end_at"), + @Index(name = "idx_recruitment_recruiter", columnList = "recruiter_id") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Recruitment { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 모집자 + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "recruiter_id", nullable = false, + foreignKey = @ForeignKey(name = "fk_recruitment_user") // 외래키 값 명시 + ) + private User recruiter; + + // 모집 공고 제목 (입력 필요) + @Column(nullable = false, length = 120) + private String title; + + // 연락망: 이메일 주소 (입력 필요) + @Column(length = 120) + private String contactEmail; + + // 모집일 (입력 필요) + @Column(name = "start_at", nullable = false) + private LocalDateTime startAt; + + // 모집 마감일 (입력 필요) + @Column(name = "end_at", nullable = false) + private LocalDateTime endAt; + + // 모집 내용 (입력 필요) + @Lob // 해당 필드를 DB에서 대용량 저장하도록 매핑 + @Column(nullable = false) + private String content; + + // 팀 규모(총인원) (입력 필요) + @Column(name = "team_size_total", nullable = false) + private Integer teamSizeTotal; + + // 남은 모집 인원(입력 필요) – 신청 시 1 감소, 철회/거절 시 1 증가(복원) + @Column(name = "recruit_quota", nullable = false) + private Integer recruitQuota; + + // 개발 분야 태그 (선택 필요) - BACKEND/FRONTEND/AI 등 + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "recruitment_field_tags", joinColumns = @JoinColumn(name = "recruitment_id")) + @Column(name = "field", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + @Builder.Default + private Set fieldTags = new HashSet<>(); + + // 개발 언어 태그 (선택 필요) - 화면 필터/표시용 + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "recruitment_language_tags", joinColumns = @JoinColumn(name = "recruitment_id")) + @Column(name = "language", nullable = false, length = 64) + @Enumerated(EnumType.STRING) + @Builder.Default + private Set languageTags = new HashSet<>(); + + // 모집 상태 (모집 예정 / 모집 중 / 모집 마감) + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private RecruitmentStatus status; + + // 조회수 + @Column(name = "views", nullable = false) + @Builder.Default + private int views = 0; + + // 모집 공고 이미지 (선택) + @Builder.Default + @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true) + private List recruitmentImages = new ArrayList<>(); + + /*// 지원 신청 리스트 + @OneToMany(mappedBy = "recruitment", fetch = FetchType.LAZY) + @Builder.Default + private List applications = new ArrayList<>();*/ + + // 추천 가중치용 요구 기술(언어/프레임워크/툴 등, 선택) + @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private Set requiredSkills = new HashSet<>(); + + + + public void increaseView() { this.views++; } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentField.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentField.java new file mode 100644 index 0000000..2b237da --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentField.java @@ -0,0 +1,16 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +public enum RecruitmentField { + FRONTEND, // 프론트엔드 + BACKEND, // 백엔드 + FULLSTACK, // 풀스택 + ANDROID, // 안드로이드 + IOS, // iOS + DATA, // 데이터 분석 + DEVOPS, // DevOps + AI, // 인공지능 + EMBEDDED, // 임베디드 + GAME, // 게임 + SECURITY, // 보안 + ETC; // 기타 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentImage.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentImage.java new file mode 100644 index 0000000..d26e638 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentImage.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder +@Table(name = "recruitment_image", indexes = { + @Index(name = "idx_recruitment_image_recruitment_id_deleted_at", columnList = "recruitment_id, deleted_at") +}) +@NoArgsConstructor +@AllArgsConstructor +public class RecruitmentImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recruitment_id") + private Recruitment recruitment; + + @Column(name = "image_url", length = 255) + private String imageUrl; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + // Soft delete 처리 메소드 + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java new file mode 100644 index 0000000..4903203 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "recruitment_required_skill", + uniqueConstraints = @UniqueConstraint(name = "uk_recruit_skill", columnNames = {"recruitment_id","skill"}), + indexes = { + @Index(name = "idx_recruit_skill_recruitment", columnList = "recruitment_id"), + @Index(name = "idx_recruit_skill_category", columnList = "category"), + @Index(name = "idx_rrs_cat_weight_skill", columnList = "category, weight, skill") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RecruitmentRequiredSkill { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "recruitment_id", nullable = false) + private Recruitment recruitment; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 64) + private DeveloperSkill skill; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillCategory category; // 추천은 LANGUAGE만 사용 + + @Column(nullable = false) + @Builder.Default + private double weight = 1.0; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java new file mode 100644 index 0000000..fbccbe8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java @@ -0,0 +1,7 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +public enum RecruitmentStatus { + Forthcoming, // 모집 예정 + RECRUITING, // 모집 중 + CLOSED, // 마감 +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/SkillCategory.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/SkillCategory.java new file mode 100644 index 0000000..a7b0c36 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/SkillCategory.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +public enum SkillCategory { + LANGUAGE, + FRAMEWORK, + DATABASE, + TOOLS, + ETC +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java index ba5ffc3..f7db26b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -67,6 +67,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 경로별 인가 작업 http .authorizeHttpRequests((auth) -> auth + // 내부 webhook 통신 API + .requestMatchers("/api/webhook/**").permitAll() + // 외부 공개 API(클라이언트 - JWT) .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java index 869ef43..feaf6eb 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -22,7 +22,10 @@ public class SecurityPath { "/api/repos/**", "/api/mindmaps/**", "/api/history/**", - "/api/s3/bucket/**" + "/api/invitations/**", + "/api/notifications/**", + "/api/s3/bucket/**", + "/api/proxy/**" }; // hasRole("ADMIN") diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 8a775c9..8ad6020 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -25,6 +25,10 @@ public enum ErrorCode { SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-004", "연동된 소셜 계정 정보를 찾을 수 없습니다."), USER_SETTING_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-005", "해당 아이디의 설정을 찾을 수 없습니다."), + // 개발 기술 관련 + SKILL_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "SKILL-001", "최대 10개까지만 선택 가능합니다."), + INVALID_SKILL(HttpStatus.BAD_REQUEST, "SKILL-002", "유효하지 않은 기술입니다."), + // 소셜 로그인 관련 OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "OAUTH-002", "지원하지 않는 소셜 로그인 제공자입니다."), @@ -44,6 +48,7 @@ public enum ErrorCode { // 멤버 관련 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-001", "해당 멤버를 찾을 수 없습니다."), MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER-002", "이미 마인드맵에 등록된 멤버입니다."), + OWNER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-003", "해당 소유자를 찾을 수 없습니다."), // 초대 관련 INVITATION_NOT_FOUND(HttpStatus.NOT_FOUND, "INVITE-001", "초대 정보를 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index c34e86c..faa88d1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -1,7 +1,7 @@ package com.teamEWSN.gitdeun.common.fastapi; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; -import com.teamEWSN.gitdeun.common.fastapi.dto.FastApiCommitTimeResponse; +import com.teamEWSN.gitdeun.common.fastapi.dto.ArangoDataDto; import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; import lombok.AllArgsConstructor; import lombok.Getter; @@ -21,52 +21,65 @@ public FastApiClient(@Qualifier("fastApiWebClient") WebClient webClient) { this.webClient = webClient; } - /** - * FastAPI 서버에 리포지토리 분석을 요청하고 그 결과를 받아옵니다. - * @param repoUrl 분석할 리포지토리의 URL - * @param prompt 분석에 사용할 프롬프트 - * @param type 분석 타입 (DEV, CHECK) - * @return 분석 결과 DTO - */ - public AnalysisResultDto analyze(String repoUrl, String prompt, MindmapType type) { - // FastAPI 요청 본문을 위한 내부 DTO + // FastAPI 서버에 리포지토리 분석을 요청 + public AnalysisResultDto analyze(String repoUrl, String prompt, MindmapType type, String authorizationHeader) { AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt, type); + // FastAPI 요청 본문을 위한 내부 DTO return webClient.post() .uri("/analyze") // FastAPI에 정의된 분석 엔드포인트 + .header("Authorization", authorizationHeader) .body(Mono.just(requestBody), AnalysisRequest.class) .retrieve() // 응답을 받아옴 .bodyToMono(AnalysisResultDto.class) // 응답 본문을 DTO로 변환 .block(); // 비동기 처리를 동기적으로 대기 } + // ArangoDB에서 마인드맵 데이터를 조회 + public ArangoDataDto getArangoData(String arangodbKey, String authorizationHeader) { + return webClient.get() + .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 조회 엔드포인트 + .header("Authorization", authorizationHeader) + .retrieve() + .bodyToMono(ArangoDataDto.class) + .block(); + } - /** - * FastAPI 서버에 특정 GitHub 리포지토리의 최신 커밋 시간을 요청합니다. - * @param githubRepoUrl 조회할 리포지토리의 URL - * @return 최신 커밋 시간 - */ - public LocalDateTime fetchLatestCommitTime(String githubRepoUrl) { - // FastAPI의 가벼운 엔드포인트(예: /check-commit-time)를 호출합니다. - FastApiCommitTimeResponse response = webClient.get() - .uri(uriBuilder -> uriBuilder - .path("/check-commit-time") // FastAPI에 정의된 엔드포인트 경로 - .queryParam("url", githubRepoUrl) // 쿼리 파라미터로 URL 전달 - .build()) - .retrieve() // 응답을 받아옴 - .bodyToMono(FastApiCommitTimeResponse.class) // 응답 본문을 DTO로 변환 - .block(); // 비동기 응답을 동기적으로 기다림 - - // null 체크 후 날짜 반환 - if (response == null) { - throw new RuntimeException("FastAPI 서버로부터 최신 커밋 시간 정보를 받아오지 못했습니다."); - } - return response.getLatestCommitAt(); + // ArangoDB에 마인드맵 데이터를 저장하고 키를 반환 + public String saveArangoData(String repoUrl, String mapData, String authorizationHeader) { + ArangoSaveRequest requestBody = new ArangoSaveRequest(repoUrl, mapData); + + return webClient.post() + .uri("/arango/save") // ArangoDB 데이터 저장 엔드포인트 + .header("Authorization", authorizationHeader) + .body(Mono.just(requestBody), ArangoSaveRequest.class) + .retrieve() + .bodyToMono(ArangoSaveResponse.class) + .map(ArangoSaveResponse::getArangodbKey) + .block(); } - // TODO: requestAnalysis 등 다른 FastAPI 호출 메서드들도 여기에 구현 + // ArangoDB에서 마인드맵 데이터를 업데이트 + public ArangoDataDto updateArangoData(String arangodbKey, String mapData, String authorizationHeader) { + ArangoUpdateRequest requestBody = new ArangoUpdateRequest(mapData); + return webClient.put() + .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 업데이트 엔드포인트 + .header("Authorization", authorizationHeader) + .body(Mono.just(requestBody), ArangoUpdateRequest.class) + .retrieve() + .bodyToMono(ArangoDataDto.class) + .block(); + } + // ArangoDB에서 마인드맵 데이터를 삭제 + public void deleteAnalysisData(String arangodbKey) { + webClient.delete() + .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 삭제 엔드포인트 + .retrieve() + .bodyToMono(Void.class) + .block(); + } @Getter @AllArgsConstructor @@ -75,4 +88,24 @@ private static class AnalysisRequest { private String prompt; private MindmapType type; } + + @Getter + @AllArgsConstructor + private static class ArangoSaveRequest { + private String repoUrl; + private String mapData; + } + + @Getter + @AllArgsConstructor + private static class ArangoUpdateRequest { + private String mapData; + } + + @Getter + @AllArgsConstructor + private static class ArangoSaveResponse { + private String arangodbKey; + private String status; + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java index a6777c9..9ca695a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java @@ -7,12 +7,12 @@ @Getter public class AnalysisResultDto { - // FastAPI가 반환하는 Repo 관련 정보 + // Repo 관련 정보 private String language; private String defaultBranch; private LocalDateTime githubLastUpdatedAt; - // FastAPI가 반환하는 Mindmap 관련 정보 + // Mindmap 관련 정보 private String mapData; // JSON 형태의 마인드맵 데이터 private MindmapType type; private String prompt; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/ArangoDataDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/ArangoDataDto.java new file mode 100644 index 0000000..34acea2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/ArangoDataDto.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.common.fastapi.dto; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class ArangoDataDto { + private String arangodbKey; + private String mapData; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java deleted file mode 100644 index 40f202e..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.teamEWSN.gitdeun.common.fastapi.dto; - -import lombok.Getter; -import lombok.Setter; - -import java.time.LocalDateTime; - -@Getter -@Setter // JSON 역직렬화를 위해 필요 -public class FastApiCommitTimeResponse { - private LocalDateTime latestCommitAt; -} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java index 3530db8..122740c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java @@ -6,7 +6,8 @@ @Getter @RequiredArgsConstructor public enum CacheType { - SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200); // 자동완성 캐시 + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 + USER_SKILLS("userSkills", 1, 500); // 사용자 개발기술 캐시 private final String cacheName; private final int expiredAfterWrite; // 시간(hour) 단위 diff --git a/src/main/java/com/teamEWSN/gitdeun/common/webhook/controller/WebhookController.java b/src/main/java/com/teamEWSN/gitdeun/common/webhook/controller/WebhookController.java new file mode 100644 index 0000000..052bd88 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/controller/WebhookController.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.common.webhook.controller; + +import com.teamEWSN.gitdeun.common.webhook.dto.WebhookUpdateDto; +import com.teamEWSN.gitdeun.mindmap.service.MindmapService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/webhook/mindmaps") +@RequiredArgsConstructor +public class WebhookController { + + private final MindmapService mindmapService; + + @PostMapping("/update") + public ResponseEntity updateMindmapFromWebhook(@RequestHeader("Authorization") String authorizationHeader, + @RequestBody WebhookUpdateDto updateDto) { + mindmapService.updateMindmapFromWebhook(updateDto, authorizationHeader); + return ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java b/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java new file mode 100644 index 0000000..9005c39 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.common.webhook.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class WebhookUpdateDto { + // FastAPI 콜백 페이로드와 동일 + private String repoUrl; + private String mapData; + private String language; + private String defaultBranch; + private LocalDateTime githubLastUpdatedAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/webhook/service/WebhookService.java b/src/main/java/com/teamEWSN/gitdeun/common/webhook/service/WebhookService.java new file mode 100644 index 0000000..202c8d3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/service/WebhookService.java @@ -0,0 +1,8 @@ +package com.teamEWSN.gitdeun.common.webhook.service; + + +public class WebhookService { + + + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java index 7142cfd..cd70662 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java @@ -15,6 +15,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequiredArgsConstructor @@ -35,7 +37,7 @@ public ResponseEntity inviteByEmail( // 특정 마인드맵의 전체 초대 목록 조회 (페이지네이션 적용) @GetMapping("/mindmaps/{mapId}") - public ResponseEntity> getInvitations( + public ResponseEntity> getInvitations( @PathVariable Long mapId, @AuthenticationPrincipal CustomUserDetails userDetails, @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable @@ -43,6 +45,24 @@ public ResponseEntity> getInvitations( return ResponseEntity.ok(invitationService.getInvitationsByMindmap(mapId, userDetails.getId(), pageable)); } + // ACCEPTED 상태의 초대 목록 조회 + @GetMapping("/mindmaps/{mapId}/accepted") + public ResponseEntity> getAcceptedInvitations( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(invitationService.getAcceptedInvitationsByMindmap(mapId, userDetails.getId())); + } + + // PENDING 상태의 초대 목록 조회 + @GetMapping("/mindmaps/{mapId}/pending") + public ResponseEntity> getPendingInvitations( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(invitationService.getPendingInvitationsByMindmap(mapId, userDetails.getId())); + } + // 초대 수락 @PostMapping("/{invitationId}/accept") public ResponseEntity acceptInvitation( diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java b/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java index fd05ee8..eae433d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java @@ -17,14 +17,14 @@ public interface InvitationRepository extends JpaRepository { // 특정 마인드맵의 모든 초대 목록을 페이징하여 조회 Page findByMindmapId(Long mindmapId, Pageable pageable); + // 특정 마인드맵의 특정 상태 초대 목록 조회 + List findByMindmapIdAndStatus(Long mindmapId, InvitationStatus status); + // 특정 마인드맵에 특정 유저가 이미 초대 대기중인지 확인 boolean existsByMindmapIdAndInviteeIdAndStatusAndExpiresAtAfter(Long mindmapId, Long inviteeId, InvitationStatus status, LocalDateTime now); boolean existsByMindmapIdAndInviteeIdAndStatus(Long mindmapId, Long inviteeId, InvitationStatus status); - // 사용자가 받은 모든 초대 목록 조회 - List findByInviteeIdAndStatus(Long inviteeId, InvitationStatus status); - // 고유 토큰으로 초대 정보 조회 Optional findByToken(String token); diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java index b2e9d46..6a6d6e1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java @@ -26,7 +26,9 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -40,6 +42,7 @@ public class InvitationService { private final NotificationService notificationService; private final InvitationMapper invitationMapper; + // TODO: 배포 시 이메일 주소 변경 private static final String INVITATION_BASE_URL = "http://localhost:8080/invitations/"; // private static final String INVITATION_BASE_URL = "https://gitdeun.site/invitations/"; @@ -106,6 +109,41 @@ public Page getInvitationsByMindmap(Long mapId, Long user return invitations.map(invitationMapper::toResponseDto); } + // 초대 수락 목록 조회 + @Transactional(readOnly = true) + public List getAcceptedInvitationsByMindmap(Long mapId, Long userId) { + // 권한 검증 - 최소 조회 권한이 있어야 함 + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // ACCEPTED 상태의 초대 목록 조회 + List acceptedInvitations = invitationRepository.findByMindmapIdAndStatus( + mapId, InvitationStatus.ACCEPTED); + + // 엔티티를 DTO로 변환하여 반환 + return acceptedInvitations.stream() + .map(invitationMapper::toResponseDto) + .collect(Collectors.toList()); + } + + // 초대 보류 목록 조회 + @Transactional(readOnly = true) + public List getPendingInvitationsByMindmap(Long mapId, Long userId) { + // 권한 검증 - 최소 조회 권한이 있어야 함 + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + List pendingInvitations = invitationRepository.findByMindmapIdAndStatus( + mapId, InvitationStatus.PENDING); + + // 엔티티를 DTO로 변환하여 반환 + return pendingInvitations.stream() + .map(invitationMapper::toResponseDto) + .collect(Collectors.toList()); + } + // 초대 수락 @Transactional public void acceptInvitation(Long invitationId, Long userId) { diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index aebe97a..a04dd33 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,5 +1,7 @@ package com.teamEWSN.gitdeun.mindmap.controller; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; @@ -18,15 +20,31 @@ public class MindmapController { private final MindmapService mindmapService; + private final FastApiClient fastApiClient; - - // 마인드맵 생성 (마인드맵에 한해서 owner 권한 얻음) + // 마인드맵 생성 (FastAPI 분석 기반) @PostMapping public ResponseEntity createMindmap( + @RequestHeader("Authorization") String authorizationHeader, @RequestBody MindmapCreateRequestDto request, @AuthenticationPrincipal CustomUserDetails userDetails ) { - MindmapResponseDto responseDto = mindmapService.createMindmap(request, userDetails.getId()); + // 1. FastAPI로 분석 요청 + AnalysisResultDto analysisResult = fastApiClient.analyze( + request.getRepoUrl(), + request.getPrompt(), + request.getType(), + authorizationHeader + ); + + // 2. 분석 결과로 마인드맵 생성 + MindmapResponseDto responseDto = mindmapService.createMindmapFromAnalysis( + request, + analysisResult, + userDetails.getId(), + authorizationHeader + ); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } @@ -34,9 +52,10 @@ public ResponseEntity createMindmap( @GetMapping("/{mapId}") public ResponseEntity getMindmap( @PathVariable Long mapId, - @AuthenticationPrincipal CustomUserDetails userDetails + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestHeader("Authorization") String authorizationHeader ) { - MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId()); + MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId(), authorizationHeader); return ResponseEntity.ok(responseDto); } @@ -44,9 +63,10 @@ public ResponseEntity getMindmap( @PostMapping("/{mapId}/refresh") public ResponseEntity refreshMindmap( @PathVariable Long mapId, - @AuthenticationPrincipal CustomUserDetails userDetails + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestHeader("Authorization") String authorizationHeader ) { - MindmapDetailResponseDto responseDto = mindmapService.refreshMindmap(mapId, userDetails.getId()); + MindmapDetailResponseDto responseDto = mindmapService.refreshMindmap(mapId, userDetails.getId(), authorizationHeader); return ResponseEntity.ok(responseDto); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java index 98ac218..1dcf335 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java @@ -5,17 +5,19 @@ import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController +@RequestMapping("/api/mindmaps/{mapId}/sse") @RequiredArgsConstructor public class MindmapSseController { private final MindmapSseService mindmapSseService; // 클라이언트의 특정 마인드맵의 업데이트를 구독 - @GetMapping(value = "/api/mindmaps/{mapId}/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribeToMindmapUpdates(@PathVariable Long mapId) { return mindmapSseService.subscribe(mapId); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java index 2b3818d..6b8349d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java @@ -8,7 +8,7 @@ @NoArgsConstructor public class MindmapCreateRequestDto { private String repoUrl; - private String prompt; + private String prompt; // Optional, 'DEV' 타입일 때 사용자가 입력하는 명령어 private MindmapType type; private String field; // Optional, 'CHECK' 타입일 때 사용자가 입력하는 제목 diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index 31eb2a8..02d346e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -46,7 +46,19 @@ public class Mindmap extends AuditedEntity { @Column(name = "map_data", columnDefinition = "json", nullable = false) private String mapData; + // TODO: 멤버수 제한 기능 (유료?) + @Builder.Default + @Column(name = "member_count", nullable = false) + private Integer memberCount = 1; + + // TODO: Graph RAG 조회 및 데이터 연결 + @Column(name = "arangodb_key", length = 255) + private String arangodbKey; + public void updateMapData(String newMapData) { this.mapData = newMapData; } + + public void updateArangodbKey(String arangodbKey) { this.arangodbKey = arangodbKey; } + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 0fa3b76..4ccb176 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -4,6 +4,7 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.common.webhook.dto.WebhookUpdateDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; @@ -26,6 +27,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -47,28 +49,39 @@ public class MindmapService { private final FastApiClient fastApiClient; @Transactional - public MindmapResponseDto createMindmap(MindmapCreateRequestDto req, Long userId) { + public MindmapResponseDto createMindmapFromAnalysis(MindmapCreateRequestDto req, AnalysisResultDto dto, Long userId, String authorizationHeader) { User user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - AnalysisResultDto dto = fastApiClient.analyze(req.getRepoUrl(), req.getPrompt(), req.getType()); - Repo repo = repoService.createOrUpdate(req.getRepoUrl(), dto); repoRepository.save(repo); - String field; - if (req.getType() == MindmapType.DEV) { - field = "개발용"; - } else { - if (req.getField() != null && !req.getField().isEmpty()) { - field = req.getField(); - } else { - // findNextCheckSequence 호출 시 repo 정보 제거 - long nextSeq = findNextCheckSequence(user); - field = "확인용 (" + nextSeq + ")"; + String field = determineField(req, user); + + // 1. ArangoDB에 초기 마인드맵 데이터를 저장하고 키를 받아옴 + String arangodbKey = null; + String finalMapData = dto.getMapData(); + + try { + // FastAPI를 통해 ArangoDB에 데이터 저장 + arangodbKey = fastApiClient.saveArangoData( + req.getRepoUrl(), + dto.getMapData(), + authorizationHeader + ); + + // ArangoDB에서 저장된 데이터 조회하여 최종 mapData 확정 + var arangoData = fastApiClient.getArangoData(arangodbKey, authorizationHeader); + if (arangoData != null && arangoData.getMapData() != null) { + finalMapData = arangoData.getMapData(); } + + } catch (Exception e) { + log.warn("ArangoDB 저장 중 오류 발생, 기본 데이터로 진행: {}", e.getMessage()); + // ArangoDB 저장 실패 시에도 마인드맵은 생성하되, arangodbKey는 null로 유지 } + // 2. MySQL에 마인드맵 엔티티 저장 Mindmap mindmap = Mindmap.builder() .repo(repo) .user(user) @@ -76,30 +89,40 @@ public MindmapResponseDto createMindmap(MindmapCreateRequestDto req, Long userId .branch(dto.getDefaultBranch()) .type(req.getType()) .field(field) - .mapData(dto.getMapData()) + .mapData(finalMapData) // ArangoDB에서 가져온 최종 데이터 + .arangodbKey(arangodbKey) // ArangoDB 키 저장 .build(); mindmapRepository.save(mindmap); - // 마인드맵 소유자 등록 + // 3. 마인드맵 소유자 등록 mindmapMemberRepository.save( MindmapMember.of(mindmap, user, MindmapRole.OWNER) ); - // 방문 기록 생성 + // 4. 방문 기록 생성 visitHistoryService.createVisitHistory(user, mindmap); return mindmapMapper.toResponseDto(mindmap); + } + private String determineField(MindmapCreateRequestDto req, User user) { + if (req.getType() == MindmapType.DEV) { + return "개발용"; + } else { + if (req.getField() != null && !req.getField().isEmpty()) { + return req.getField(); + } else { + long nextSeq = findNextCheckSequence(user); + return "확인용 (" + nextSeq + ")"; + } + } } /** * 특정 사용자의 "확인용 (n)" 다음 시퀀스 번호를 찾습니다. - * @param user 대상 사용자 - * @return 다음 시퀀스 번호 */ private long findNextCheckSequence(User user) { - // repo 조건이 제거된 리포지토리 메서드 호출 Optional lastCheckMindmap = mindmapRepository.findTopByUserAndTypeOrderByCreatedAtDesc(user); if (lastCheckMindmap.isEmpty()) { @@ -117,29 +140,29 @@ private long findNextCheckSequence(User user) { return 1; } - /** - * 마인드맵 상세 정보 조회 - * 저장소 업데이트는 Fast API Webhook 알림 - * 마인드맵 변경이나 리뷰 생성 시 SSE 적용을 통한 실시간 업데이트 (새로고침 x) + * 마인드맵 상세 정보 조회 - ArangoDB와 동기화된 최신 데이터 반환 */ @Transactional - public MindmapDetailResponseDto getMindmap(Long mapId, Long userId) { + public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authorizationHeader) { if (!mindmapAuthService.hasView(mapId, userId)) { - throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); // 멤버 권한 확인 + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } Mindmap mindmap = mindmapRepository.findById(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + // ArangoDB에서 최신 데이터 동기화 + syncWithArangoDB(mindmap, authorizationHeader); + return mindmapMapper.toDetailResponseDto(mindmap); } /** - * 마인드맵 새로고침 + * 마인드맵 새로고침 - ArangoDB와 완전 동기화 */ @Transactional - public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId) { + public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId, String authorizationHeader) { Mindmap mindmap = mindmapRepository.findById(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); @@ -148,16 +171,62 @@ public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - // 기존 정보로 FastAPI 재호출 - AnalysisResultDto dto = fastApiClient.analyze( - mindmap.getRepo().getGithubRepoUrl(), - mindmap.getPrompt(), - mindmap.getType() - ); + try { + // 1. FastAPI를 통해 리포지토리 재분석 + AnalysisResultDto dto = fastApiClient.analyze( + mindmap.getRepo().getGithubRepoUrl(), + mindmap.getPrompt(), + mindmap.getType(), + authorizationHeader + ); + + // 2. 리포지토리 정보 업데이트 + mindmap.getRepo().updateWithAnalysis(dto); + + // 3. ArangoDB 데이터 업데이트 또는 신규 생성 + String finalMapData = dto.getMapData(); + + if (mindmap.getArangodbKey() != null) { + // 기존 ArangoDB 데이터 업데이트 + var arangoData = fastApiClient.updateArangoData( + mindmap.getArangodbKey(), + dto.getMapData(), + authorizationHeader + ); + if (arangoData != null && arangoData.getMapData() != null) { + finalMapData = arangoData.getMapData(); + } + } else { + // ArangoDB 키가 없다면 신규 생성 + String newArangodbKey = fastApiClient.saveArangoData( + mindmap.getRepo().getGithubRepoUrl(), + dto.getMapData(), + authorizationHeader + ); + mindmap.updateArangodbKey(newArangodbKey); // Mindmap 엔티티에 이 메소드 추가 필요 + + // 저장된 데이터 조회 + var arangoData = fastApiClient.getArangoData(newArangodbKey, authorizationHeader); + if (arangoData != null && arangoData.getMapData() != null) { + finalMapData = arangoData.getMapData(); + } + } - // 데이터 최신화 - mindmap.getRepo().updateWithAnalysis(dto); - mindmap.updateMapData(dto.getMapData()); + // 4. MySQL 마인드맵 데이터 업데이트 + mindmap.updateMapData(finalMapData); + + } catch (Exception e) { + log.error("새로고침 중 ArangoDB 연동 실패: {}", e.getMessage()); + // ArangoDB 연동 실패 시에도 기본 FastAPI 결과로 업데이트 + AnalysisResultDto dto = fastApiClient.analyze( + mindmap.getRepo().getGithubRepoUrl(), + mindmap.getPrompt(), + mindmap.getType(), + authorizationHeader + ); + mindmap.getRepo().updateWithAnalysis(dto); + mindmap.updateMapData(dto.getMapData()); + } MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); @@ -168,7 +237,77 @@ public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId) { } /** - * 마인드맵 삭제 + * ArangoDB와 동기화하여 최신 마인드맵 데이터를 가져옴 + */ + private void syncWithArangoDB(Mindmap mindmap, String authorizationHeader) { + if (mindmap.getArangodbKey() == null) { + return; // ArangoDB 키가 없으면 동기화 불가 + } + + try { + var arangoData = fastApiClient.getArangoData( + mindmap.getArangodbKey(), + authorizationHeader + ); + + if (arangoData != null && arangoData.getMapData() != null) { + // ArangoDB 데이터가 MySQL 데이터와 다르면 업데이트 + if (!arangoData.getMapData().equals(mindmap.getMapData())) { + mindmap.updateMapData(arangoData.getMapData()); + log.info("마인드맵 ID {}의 데이터가 ArangoDB와 동기화되었습니다", mindmap.getId()); + } + } + } catch (Exception e) { + log.warn("ArangoDB 동기화 실패, 기존 데이터 유지: {}", e.getMessage()); + } + } + + // webhook을 통한 업데이트 + @Transactional + public void updateMindmapFromWebhook(WebhookUpdateDto dto, String authorizationHeader) { + Repo repo = repoRepository.findByGithubRepoUrl(dto.getRepoUrl()) + .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_URL)); + + List mindmapsToUpdate = repo.getMindmaps(); + + // Repo 정보 업데이트 + repo.updateWithWebhookData(dto); + + // 각 마인드맵의 ArangoDB 데이터와 MySQL 데이터 동기화 + for (Mindmap mindmap : mindmapsToUpdate) { + try { + // Webhook 데이터를 ArangoDB에 업데이트 + if (mindmap.getArangodbKey() != null) { + var arangoData = fastApiClient.updateArangoData( + mindmap.getArangodbKey(), + dto.getMapData(), + authorizationHeader + ); + + // ArangoDB에서 반환된 데이터로 MySQL 업데이트 + if (arangoData != null && arangoData.getMapData() != null) { + mindmap.updateMapData(arangoData.getMapData()); + } else { + mindmap.updateMapData(dto.getMapData()); + } + } else { + // ArangoDB 키가 없으면 직접 업데이트 + mindmap.updateMapData(dto.getMapData()); + } + } catch (Exception e) { + log.warn("Webhook 처리 중 ArangoDB 연동 실패, 직접 업데이트: {}", e.getMessage()); + mindmap.updateMapData(dto.getMapData()); + } + + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); + mindmapSseService.broadcastUpdate(mindmap.getId(), responseDto); + + log.info("Webhook으로 마인드맵 ID {} 업데이트 및 SSE 전송 완료", mindmap.getId()); + } + } + + /** + * 마인드맵 삭제 - ArangoDB 데이터도 함께 삭제 */ @Transactional public void deleteMindmap(Long mapId, Long userId) { @@ -180,7 +319,19 @@ public void deleteMindmap(Long mapId, Long userId) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } + // ArangoDB 데이터 삭제 + if (mindmap.getArangodbKey() != null) { + try { + fastApiClient.deleteAnalysisData(mindmap.getArangodbKey()); + log.info("ArangoDB에서 마인드맵 데이터 삭제 완료: {}", mindmap.getArangodbKey()); + } catch (Exception e) { + log.error("ArangoDB 데이터 삭제 실패하지만 MySQL 삭제는 진행: {}", e.getMessage()); + } + } + + // MySQL에서 마인드맵 삭제 mindmapRepository.delete(mindmap); + log.info("마인드맵 ID {} 삭제 완료", mapId); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java index 2542a19..4aeb0bb 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java @@ -23,4 +23,6 @@ public interface MindmapMemberRepository extends JpaRepository findByMindmapIdAndRole(Long mapId, MindmapRole mindmapRole); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java index eab5710..b3cc907 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java @@ -16,6 +16,7 @@ public class MindmapMemberService { private final MindmapAuthService auth; private final MindmapMemberRepository memberRepository; + // 멤버 권한 변경 @Transactional public void changeRole(Long mapId, Long memberId, @@ -26,10 +27,28 @@ public void changeRole(Long mapId, Long memberId, throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - // 대상 멤버 조회 후 role 변경 - MindmapMember member = memberRepository.findByIdAndMindmapId(memberId, mapId) + // 대상 멤버 조회 + MindmapMember targetMember = memberRepository.findByIdAndMindmapId(memberId, mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); - member.updateRole(newRole); + + // 소유자 변경 특수 케이스 처리 + if (newRole == MindmapRole.OWNER) { + // 현재 OWNER 조회 + MindmapMember currentOwner = memberRepository + .findByMindmapIdAndRole(mapId, MindmapRole.OWNER) + .orElseThrow(() -> new GlobalException(ErrorCode.OWNER_NOT_FOUND)); + + // 자기 자신에게 OWNER 재할당은 무시 + if (currentOwner.getId().equals(memberId)) { + return; + } + + // 현재 OWNER를 EDITOR로 변경 + currentOwner.updateRole(MindmapRole.EDITOR); + } + + // 대상 멤버 역할 변경 + targetMember.updateRole(newRole); } // 멤버 추방 @@ -40,4 +59,5 @@ public void removeMember(Long mapId, Long memberId, Long requesterId) { } memberRepository.deleteByIdAndMindmapId(memberId, mapId); } + } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java index e0aaa2c..a80a4c8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java @@ -11,14 +11,14 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController -@RequestMapping("/api/notifications") +@RequestMapping("/api/notifications/sse") @RequiredArgsConstructor public class NotificationSseController { private final NotificationSseService notificationSseService; // 클라이언트의 알림 구독 - @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(@AuthenticationPrincipal CustomUserDetails userDetails) { return notificationSseService.subscribe(userDetails.getId()); } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationCreateDto.java b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationCreateDto.java new file mode 100644 index 0000000..732f176 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationCreateDto.java @@ -0,0 +1,50 @@ +package com.teamEWSN.gitdeun.notification.dto; + +import com.teamEWSN.gitdeun.notification.entity.NotificationType; +import com.teamEWSN.gitdeun.user.entity.User; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class NotificationCreateDto { + private User user; + private NotificationType type; + private String message; + private Long referenceId; + private LocalDateTime expiresAt; + + // 알림 유형별 기본 만료 기간 설정 + private static LocalDateTime getDefaultExpiryDate(NotificationType type) { + LocalDateTime now = LocalDateTime.now(); + return switch (type) { + case MENTION_COMMENT -> now.plusDays(30); // 댓글 알림은 30일 + default -> now.plusDays(30); // 기본 30일 + }; + } + + // 기본 만료 기간이 적용된 알림 생성 + public static NotificationCreateDto simple(User user, NotificationType type, String message) { + return NotificationCreateDto.builder() + .user(user) + .type(type) + .message(message) + .expiresAt(getDefaultExpiryDate(type)) + .build(); + } + + // 만료 기간과 참조 ID가 지정된 알림 생성 + public static NotificationCreateDto actionable(User user, NotificationType type, + String message, Long referenceId, + LocalDateTime expiresAt) { + return NotificationCreateDto.builder() + .user(user) + .type(type) + .message(message) + .referenceId(referenceId) + .expiresAt(expiresAt != null ? expiresAt : getDefaultExpiryDate(type)) + .build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java index c447c83..04cb467 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java @@ -14,4 +14,11 @@ public class NotificationResponseDto { private boolean read; private NotificationType notificationType; private LocalDateTime createdAt; + + private Long referenceId; + private LocalDateTime expiresAt; + + public boolean isActionAvailable() { + return expiresAt != null && expiresAt.isAfter(LocalDateTime.now()); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java b/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java index bf92f71..c184bf8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java @@ -9,6 +9,8 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; +import java.time.LocalDateTime; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -38,11 +40,21 @@ public class Notification extends CreatedEntity { @Column(name = "notification_type", nullable = false) private NotificationType notificationType; // 알림 종류 + @Column(name = "reference_id") + private Long referenceId; // Invitation ID, Comment ID, Mindmap ID 등 + + @Column(name = "expires_at") + private LocalDateTime expiresAt; // 액션 가능 만료 시간 + @Builder - public Notification(User user, String message, NotificationType notificationType) { + public Notification(User user, NotificationType notificationType, + String message, Long referenceId, + LocalDateTime expiresAt) { this.user = user; this.message = message; this.notificationType = notificationType; + this.referenceId = referenceId; + this.expiresAt = expiresAt; this.read = false; } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java index 3253907..1ca41cc 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java @@ -3,6 +3,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.invitation.entity.Invitation; +import com.teamEWSN.gitdeun.notification.dto.NotificationCreateDto; import com.teamEWSN.gitdeun.notification.dto.NotificationResponseDto; import com.teamEWSN.gitdeun.notification.dto.UnreadNotificationCountDto; import com.teamEWSN.gitdeun.notification.entity.Notification; @@ -41,7 +42,14 @@ public void notifyInvitation(Invitation invitation) { String message = String.format("'%s'님이 '%s' 마인드맵으로 초대했습니다.", invitation.getInviter().getName(), invitation.getMindmap().getField()); - createAndSendNotification(invitee, NotificationType.INVITE_MINDMAP, message); + + createAndSendNotification(NotificationCreateDto.actionable( + invitee, + NotificationType.INVITE_MINDMAP, + message, + invitation.getId(), + invitation.getExpiresAt() + )); } /** @@ -53,7 +61,14 @@ public void notifyAcceptance(Invitation invitation) { String message = String.format("'%s'님이 '%s' 마인드맵 초대를 수락했습니다.", invitation.getInvitee().getName(), invitation.getMindmap().getField()); - createAndSendNotification(inviter, NotificationType.INVITE_MINDMAP, message); + + createAndSendNotification(NotificationCreateDto.actionable( + inviter, + NotificationType.INVITE_MINDMAP, + message, + invitation.getId(), + invitation.getExpiresAt() + )); } /** @@ -65,19 +80,31 @@ public void notifyLinkApprovalRequest(Invitation invitation) { String message = String.format("'%s'님이 링크를 통해 '%s' 마인드맵 참여를 요청했습니다.", invitation.getInvitee().getName(), invitation.getMindmap().getField()); - createAndSendNotification(owner, NotificationType.INVITE_MINDMAP, message); + + createAndSendNotification(NotificationCreateDto.actionable( + owner, + NotificationType.INVITE_MINDMAP, + message, + invitation.getId(), + invitation.getExpiresAt() + )); } /** - * 알림 생성 및 발송 (다른 서비스에서 호출) + * 알림 생성 및 발송 (공통 호출) */ @Transactional - public void createAndSendNotification(User user, NotificationType type, String message) { + public void createAndSendNotification(NotificationCreateDto dto) { + User user = dto.getUser(); + String message = dto.getMessage(); + Notification notification = Notification.builder() .user(user) - .notificationType(type) + .notificationType(dto.getType()) .message(message) + .referenceId(dto.getReferenceId()) + .expiresAt(dto.getExpiresAt()) .build(); notificationRepository.save(notification); @@ -86,6 +113,10 @@ public void createAndSendNotification(User user, NotificationType type, String m int unreadCount = notificationRepository.countByUserAndReadFalse(user); notificationSseService.sendUnreadCount(user.getId(), unreadCount); + + // 새 알림 전송 + notificationSseService.sendNewNotification(dto.getUser().getId(), + notificationMapper.toResponseDto(notification)); } /** @@ -147,7 +178,7 @@ public void deleteNotification(Long notificationId, Long userId) { } } - // 사용자 조회 편의 메서드 + // 사용자 조회 - 편의 메서드 private User getUserById(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java index 937d2a9..d45ea5c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java @@ -1,55 +1,86 @@ package com.teamEWSN.gitdeun.notification.service; -import com.teamEWSN.gitdeun.notification.dto.UnreadNotificationCountDto; +import com.teamEWSN.gitdeun.notification.dto.NotificationResponseDto; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; @Slf4j @Service +@RequiredArgsConstructor public class NotificationSseService { - // 스레드 안전한 자료구조를 사용하여 사용자별 Emitter를 관리 (Key: userId, Value: SseEmitter) - private final Map emitters = new ConcurrentHashMap<>(); + // 한 사용자에 대해 여러 탭/기기의 연결을 허용 + private final Map> emitters = new ConcurrentHashMap<>(); - // 클라이언트가 구독을 요청할 때 호출 - public SseEmitter subscribe(Long userId) { - SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 타임아웃을 매우 길게 설정 - emitters.put(userId, emitter); + // 1시간 + private static final long TIMEOUT_MS = 60L * 60L * 1000L; - // 연결 종료 시 Emitter 제거 - emitter.onCompletion(() -> emitters.remove(userId)); - emitter.onTimeout(() -> emitters.remove(userId)); - emitter.onError(e -> emitters.remove(userId)); + /** 클라이언트 구독 */ + public SseEmitter subscribe(Long userId) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); + // 사용자별 리스트에 emitter 추가 (동시성 안전) + emitters.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(emitter); - // 연결 성공을 알리는 더미 이벤트 전송 - try { - emitter.send(SseEmitter.event().name("connect").data("Connected to notification stream.")); - } catch (IOException e) { - log.error("SSE 연결 중 오류 발생, userId={}", userId, e); - } + // 연결 종료/에러 시 정리 + emitter.onCompletion(() -> removeEmitter(userId, emitter)); + emitter.onTimeout(() -> removeEmitter(userId, emitter)); + emitter.onError(e -> removeEmitter(userId, emitter)); + // 헬스체크/연결 확인 + sendToEmitter(emitter, "connect", "connected"); return emitter; } - // 특정 사용자에게 읽지 않은 알림 개수 전송 + /** 읽지 않은 알림 개수 전송 */ public void sendUnreadCount(Long userId, int count) { - SseEmitter emitter = emitters.get(userId); - if (emitter != null) { + sendToUser(userId, "unreadCount", count); + } + + /** 새 알림 전송 */ + public void sendNewNotification(Long userId, NotificationResponseDto notification) { + sendToUser(userId, "newNotification", notification); + } + + /** 사용자에게 이벤트 전송 */ + private void sendToUser(Long userId, String eventName, Object data) { + List userEmitters = emitters.getOrDefault(userId, Collections.emptyList()); + if (userEmitters.isEmpty()) return; + + List dead = new ArrayList<>(); + for (SseEmitter emitter : userEmitters) { try { - emitter.send(SseEmitter.event() - .name("unread-count") // 이벤트 이름 지정 - .data(new UnreadNotificationCountDto(count))); // 데이터 전송 - } catch (IOException e) { - log.error("SSE 데이터 전송 중 오류 발생, userId={}", userId, e); - // 오류 발생 시 해당 Emitter 제거 - emitters.remove(userId); + sendToEmitter(emitter, eventName, data); + } catch (Exception e) { + dead.add(emitter); + log.warn("Failed to send SSE (userId={}): {}", userId, e.toString()); } } + // 죽은 emitter 정리 + dead.forEach(em -> removeEmitter(userId, em)); + } + + /** 개별 emitter에게 전송 */ + private void sendToEmitter(SseEmitter emitter, String eventName, Object data) { + try { + emitter.send(SseEmitter.event().name(eventName).data(data)); + } catch (IOException e) { + throw new RuntimeException("SSE 전송 실패", e); + } + } + + /** emitter 제거(리스트가 비면 맵에서도 제거) */ + private void removeEmitter(Long userId, SseEmitter emitter) { + List list = emitters.get(userId); + if (list == null) return; + list.remove(emitter); + if (list.isEmpty()) emitters.remove(userId); } -} \ No newline at end of file +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java index eed00d3..59901ed 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java @@ -1,7 +1,6 @@ package com.teamEWSN.gitdeun.repo.controller; import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; -import com.teamEWSN.gitdeun.repo.dto.RepoUpdateCheckResponseDto; import com.teamEWSN.gitdeun.repo.service.RepoService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -30,14 +29,5 @@ public ResponseEntity getRepoInfo(@PathVariable Long repoId) { return ResponseEntity.ok(response); } - /** - * 특정 리포지토리에 대한 업데이트 필요 여부 확인 - */ - @GetMapping("/{repoId}/status") - public ResponseEntity getRepoUpdateStatus(@PathVariable Long repoId) { - RepoUpdateCheckResponseDto response = repoService.checkUpdateNeeded(repoId); - return ResponseEntity.ok(response); - } - } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java deleted file mode 100644 index 199cdd7..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.teamEWSN.gitdeun.repo.dto; - - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public class RepoUpdateCheckResponseDto { - private final boolean updateNeeded; -} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index 9b9b257..7803030 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -1,6 +1,8 @@ package com.teamEWSN.gitdeun.repo.entity; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.common.webhook.dto.WebhookUpdateDto; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -8,6 +10,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -33,6 +37,8 @@ public class Repo { @Column(name = "github_last_updated_at") private LocalDateTime githubLastUpdatedAt; // GitHub 브랜치 최신 커밋 시간 (commit.committer.date) + @OneToMany(mappedBy = "repo", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List mindmaps = new ArrayList<>(); @Builder public Repo(String githubRepoUrl, String language, String defaultBranch, LocalDateTime githubLastUpdatedAt) { @@ -47,4 +53,10 @@ public void updateWithAnalysis(AnalysisResultDto result) { this.defaultBranch = result.getDefaultBranch(); this.githubLastUpdatedAt = result.getGithubLastUpdatedAt(); } + + public void updateWithWebhookData(WebhookUpdateDto dto) { + this.language = dto.getLanguage(); + this.defaultBranch = dto.getDefaultBranch(); + this.githubLastUpdatedAt = dto.getGithubLastUpdatedAt(); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java index e5f1a1f..edb07f2 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -5,7 +5,6 @@ import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; -import com.teamEWSN.gitdeun.repo.dto.RepoUpdateCheckResponseDto; import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.repo.mapper.RepoMapper; import com.teamEWSN.gitdeun.repo.repository.RepoRepository; @@ -38,26 +37,6 @@ public Optional findRepoByUrl(String url) { .map(repoMapper::toResponseDto); } - /** - * 리포지토리의 최신 업데이트 상태를 실시간으로 확인 - * @param repoId 확인할 리포지토리의 ID - * @return 업데이트 필요 여부를 담은 DTO - */ - public RepoUpdateCheckResponseDto checkUpdateNeeded(Long repoId) { - // 시스템의 마지막 동기화 시간 조회 - Repo repo = repoRepository.findById(repoId) - .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_ID)); - LocalDateTime lastSyncedAt = repo.getGithubLastUpdatedAt(); - - // FastAPI의 가벼운 API를 호출하여 GitHub의 최신 커밋 시간 조회 - LocalDateTime latestCommitAt = fastApiClient.fetchLatestCommitTime(repo.getGithubRepoUrl()); - - // 두 시간을 비교하여 업데이트 필요 여부 결정 - boolean isNeeded = latestCommitAt.isAfter(lastSyncedAt); - - return new RepoUpdateCheckResponseDto(isNeeded); - } - // 마인드맵 생성 시 repo 생성 및 업데이트 @Transactional public Repo createOrUpdate(String repoUrl, AnalysisResultDto dto) { diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 07a5ff8..c9e9dde 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -34,6 +34,7 @@ public class User extends AuditedEntity { @Column(nullable = false) private Role role; + // 소셜 연동 @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List socialConnections = new ArrayList<>(); diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index 3f4a861..7ce92f3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -1,9 +1,13 @@ package com.teamEWSN.gitdeun.user.repository; import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -15,5 +19,6 @@ public interface UserRepository extends JpaRepository { // user email로 검색 Optional findByEmailAndDeletedAtIsNull(String email); - + // 닉네임으로 사용자 찾기 + Optional findByNicknameAndDeletedAtIsNull(String nickname); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/controller/UserSkillController.java b/src/main/java/com/teamEWSN/gitdeun/userskill/controller/UserSkillController.java new file mode 100644 index 0000000..7a024aa --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/controller/UserSkillController.java @@ -0,0 +1,57 @@ +package com.teamEWSN.gitdeun.userskill.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.userskill.dto.CategorizedSkillsResponseDto; +import com.teamEWSN.gitdeun.userskill.dto.CategorizedSkillsWithSelectionDto; +import com.teamEWSN.gitdeun.userskill.dto.SkillSelectionRequestDto; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import com.teamEWSN.gitdeun.userskill.service.UserSkillService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/skills") +@RequiredArgsConstructor +public class UserSkillController { + + private final UserSkillService userSkillService; + + // 선택할 개발 기술 목록 조회 + @GetMapping + public ResponseEntity getAvailableInterests() { + // Enum에서 카테고리별 displayName 값 추출 + Map> categorizedInterests = Arrays.stream(DeveloperSkill.values()) + .collect(Collectors.groupingBy( + DeveloperSkill::getCategory, + Collectors.mapping(DeveloperSkill::getDisplayName, Collectors.toList()) + )); + return ResponseEntity.ok(new CategorizedSkillsResponseDto(categorizedInterests)); + } + + // 선택한 개발 기술 목록 조회 + @GetMapping("/me") + public ResponseEntity getMySkillsWithSelection( + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); + return ResponseEntity.ok(userSkillService.getUserSkillsWithSelection(userId)); + } + + // 선택한 기술 저장 + @PostMapping("/me") + public ResponseEntity saveSkills( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody SkillSelectionRequestDto requestDto + ) { + Long userId = userDetails.getId(); + userSkillService.saveUserSkills(userId, requestDto.getAllSkills()); + return ResponseEntity.status(HttpStatus.OK).build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsResponseDto.java new file mode 100644 index 0000000..2cef3ae --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsResponseDto.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class CategorizedSkillsResponseDto { + private Map> categorizedSkills; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsWithSelectionDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsWithSelectionDto.java new file mode 100644 index 0000000..0a72b73 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsWithSelectionDto.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class CategorizedSkillsWithSelectionDto { + private Map> categorizedSkills; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/DeveloperSkillDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/DeveloperSkillDto.java new file mode 100644 index 0000000..54ef3f7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/DeveloperSkillDto.java @@ -0,0 +1,11 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class DeveloperSkillDto { + private String name; // 관심사 이름 + private boolean selected; // 선택 여부 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/SkillSelectionRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/SkillSelectionRequestDto.java new file mode 100644 index 0000000..57c03ae --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/SkillSelectionRequestDto.java @@ -0,0 +1,21 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Getter +public class SkillSelectionRequestDto { + private Map> categorizedSkills; + + public List getAllSkills() { + if (categorizedSkills == null) { + return new ArrayList<>(); + } + List allSkills = new ArrayList<>(); + categorizedSkills.values().forEach(allSkills::addAll); + return allSkills; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/entity/DeveloperSkill.java b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/DeveloperSkill.java new file mode 100644 index 0000000..d7e585d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/DeveloperSkill.java @@ -0,0 +1,49 @@ +package com.teamEWSN.gitdeun.userskill.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum DeveloperSkill { + // 프로그래밍 언어 + JAVASCRIPT("JavaScript", "LANGUAGE"), + TYPESCRIPT("TypeScript", "LANGUAGE"), + PYTHON("Python", "LANGUAGE"), + JAVA("Java", "LANGUAGE"), + KOTLIN("Kotlin", "LANGUAGE"), + GO("Go", "LANGUAGE"), + RUST("Rust", "LANGUAGE"), + CPP("C++", "LANGUAGE"), + CSHARP("C#", "LANGUAGE"), + SWIFT("Swift", "LANGUAGE"), + DART("Dart", "LANGUAGE"), + PHP("PHP", "LANGUAGE"), + RUBY("Ruby", "LANGUAGE"), + R("R", "LANGUAGE"), + OTHER("기타", "LANGUAGE"); + +// // 프레임워크/라이브러리 +// SPRING("Spring", "FRAMEWORK"), +// REACT("React", "FRAMEWORK"), +// VUE("Vue.js", "FRAMEWORK"), +// ANGULAR("Angular", "FRAMEWORK"), +// NEXTJS("Next.js", "FRAMEWORK"), +// EXPRESS("Express.js", "FRAMEWORK"), +// NESTJS("NestJS", "FRAMEWORK"), +// DJANGO("Django", "FRAMEWORK"), +// FLASK("Flask", "FRAMEWORK"), +// FASTAPI("FastAPI", "FRAMEWORK"), +// FLUTTER("Flutter", "FRAMEWORK"), +// SINATRA("sinatra", "FRAMEWORK"), +// RAILS("Rails", "FRAMEWORK"), +// SWIFTUI("SwiftUI", "FRAMEWORK"), +// LARAVEL("Laravel", "FRAMEWORK"), +// GIN("Gin", "FRAMEWORK"), +// OTHER("기타", "FRAMEWORK"); + + private final String displayName; + private final String category; + + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/entity/UserSkill.java b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/UserSkill.java new file mode 100644 index 0000000..ad25211 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/UserSkill.java @@ -0,0 +1,36 @@ +package com.teamEWSN.gitdeun.userskill.entity; + +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "user_skill", indexes = { + @Index(name = "idx_user_id", columnList = "user_id"), + @Index(name = "idx_skill", columnList = "skill") +}) +public class UserSkill { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(columnDefinition = "INT UNSIGNED") + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String skill; + + @Override + public String toString() { + return "{user_id: " + user.getId() + ", keyword: " + skill + "}"; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/repository/UserSkillRepository.java b/src/main/java/com/teamEWSN/gitdeun/userskill/repository/UserSkillRepository.java new file mode 100644 index 0000000..7065597 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/repository/UserSkillRepository.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.userskill.repository; + +import com.teamEWSN.gitdeun.userskill.entity.UserSkill; +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 java.util.List; + +@Repository +public interface UserSkillRepository extends JpaRepository { + + // 사용자 ID에 해당하는 모든 기술 항목을 조회 + List findByUserId(Long userId); + + // 사용자 ID로 기술 삭제 + @Modifying + @Query("DELETE FROM UserSkill us WHERE us.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/service/UserSkillService.java b/src/main/java/com/teamEWSN/gitdeun/userskill/service/UserSkillService.java new file mode 100644 index 0000000..73d0fdc --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/service/UserSkillService.java @@ -0,0 +1,131 @@ +package com.teamEWSN.gitdeun.userskill.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.userskill.dto.CategorizedSkillsWithSelectionDto; +import com.teamEWSN.gitdeun.userskill.dto.DeveloperSkillDto; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import com.teamEWSN.gitdeun.userskill.entity.UserSkill; +import com.teamEWSN.gitdeun.userskill.repository.UserSkillRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserSkillService { + + private final UserRepository userRepository; + private final UserSkillRepository userSkillRepository; + + // 기술 최대 선택 개수 상수화 + private static final int MAX_SKILL_COUNT = 15; + + /** + * 사용자의 기술 목록과 선택 여부를 함께 조회 + * + * @param userId 사용자 ID + * @return 카테고리별로 분류된 기술 목록 (선택 여부 포함) + */ + @Cacheable(value = "userSkills", key = "#userId") + @Transactional(readOnly = true) + public CategorizedSkillsWithSelectionDto getUserSkillsWithSelection(Long userId) { + + // 사용자가 선택한 기술 목록을 Set으로 변환 (검색 성능 향상) + Set selectedSkills = userSkillRepository.findByUserId(userId).stream() + .map(UserSkill::getSkill) + .collect(Collectors.toSet()); + + // 카테고리별로 모든 기술을 분류하고 선택 여부 표시 + Map> categorizedSkills = + Arrays.stream(DeveloperSkill.values()) + .collect(Collectors.groupingBy( + DeveloperSkill::getCategory, + LinkedHashMap::new, // 순서 보장 + Collectors.mapping( + skillEnum -> new DeveloperSkillDto( + skillEnum.getDisplayName(), + selectedSkills.contains(skillEnum.getDisplayName()) + ), + Collectors.toList() + ) + )); + + log.debug("사용자 기술 조회 완료 - userId: {}, 선택된 기술 수: {}", userId, selectedSkills.size()); + + return new CategorizedSkillsWithSelectionDto(categorizedSkills); + } + + /** + * 사용자가 선택한 기술 목록을 저장 + * + * @param userId 사용자 ID + * @param selectedSkills 선택한 기술 목록 + */ + @CacheEvict(value = "userSkills", key = "#userId") + @Transactional + public void saveUserSkills(Long userId, List selectedSkills) { + // null 체크 및 빈 리스트 처리 + if (selectedSkills == null) { + selectedSkills = Collections.emptyList(); + } + + // 비즈니스 규칙 검증 + validateSkillSelection(selectedSkills); + + // 사용자 존재 여부 확인 + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 기존 기술 목록 삭제 + List existingSkills = userSkillRepository.findByUserId(userId); + userSkillRepository.deleteAll(existingSkills); + + // 새로운 기술 목록 저장 + List newSkills = selectedSkills.stream() + .map(skill -> UserSkill.builder() + .user(user) + .skill(skill) + .build()) + .collect(Collectors.toList()); + + userSkillRepository.saveAll(newSkills); + + log.info("사용자 기술 갱신 완료 - userId: {}, 기술 수: {} -> {}", + userId, existingSkills.size(), selectedSkills.size()); + } + + /** + * 기술 선택 유효성 검증 + */ + private void validateSkillSelection(List selectedSkills) { + // 최대 선택 개수 검증 + if (selectedSkills.size() > MAX_SKILL_COUNT) { + throw new GlobalException(ErrorCode.SKILL_LIMIT_EXCEEDED); + } + + // 유효한 기술인지 검증 (Set으로 변환하여 검색 성능 향상) + Set validSkills = Arrays.stream(DeveloperSkill.values()) + .map(DeveloperSkill::getDisplayName) + .collect(Collectors.toSet()); + + // 유효하지 않은 기술 리스트 확인 + List invalidSkills = selectedSkills.stream() + .filter(skill -> !validSkills.contains(skill)) + .collect(Collectors.toList()); + + if (!invalidSkills.isEmpty()) { + log.warn("유효하지 않은 기술 선택 시도 - invalidSkills: {}", invalidSkills); + throw new GlobalException(ErrorCode.INVALID_SKILL); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c62d9e7..f76ad83 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,7 +16,7 @@ spring: jpa: show-sql: true # SQL 로그 출력 hibernate: - ddl-auto: create # create # update + ddl-auto: update # create # update properties: hibernate: format_sql: true # SQL 로그를 보기 좋게 포맷 @@ -34,7 +34,7 @@ spring: client-secret: ${GOOGLE_CLIENT_SECRET} scope: openid, email, profile github: - scope: user:email, repo + scope: user:email, repo, admin:repo_hook mail: host: smtp.gmail.com port: 587