diff --git "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" index 63fca71c..fa2fb7e0 100644 --- "a/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" +++ "b/.github/ISSUE_TEMPLATE/\342\231\273\357\270\217-refactor.md" @@ -1,17 +1,20 @@ --- name: "♻️ REFACTOR" about: 리팩토링 템플릿입니다. -title: "♻️ [REFACTOR]" +title: "️♻️ refactor: " labels: refactor assignees: '' --- # Title + - 리팩토링 # TODO + - [ ] 리팩토링 # etc + - 흐읍 diff --git "a/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" "b/.github/ISSUE_TEMPLATE/\342\234\250-feat.md" similarity index 74% rename from ".github/ISSUE_TEMPLATE/\342\234\250-feature.md" rename to ".github/ISSUE_TEMPLATE/\342\234\250-feat.md" index 86c4a2e7..ad636a17 100644 --- "a/.github/ISSUE_TEMPLATE/\342\234\250-feature.md" +++ "b/.github/ISSUE_TEMPLATE/\342\234\250-feat.md" @@ -1,17 +1,20 @@ --- -name: "✨ FEATURE" +name: "✨ FEAT" about: 기능 개발 이슈템플릿입니다. -title: "✨ [FEATURE]" -labels: feature +title: "✨ feat: " +labels: feat assignees: '' --- # Title + - 숨쉬기 기능 개발 # TODO + - [ ] 간지나게 숨쉬기 # etc + - 어쩌구저쩌구 diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233-bug.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233-bug.md" deleted file mode 100644 index e287fbae..00000000 --- "a/.github/ISSUE_TEMPLATE/\360\237\220\233-bug.md" +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "\U0001F41B BUG" -about: 버그 템플릿입니다. -title: "\U0001F41B [BUG]" -labels: bug -assignees: oxdjww - ---- - -# Title -- 숨쉬기 기능 버그 발견 - -# TODO -- [ ] 숨참기 - -# etc -- 흐읍 diff --git "a/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" new file mode 100644 index 00000000..87b955d8 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\220\233-fix.md" @@ -0,0 +1,20 @@ +--- +name: "🐛 FIX" +about: 버그 수정 템플릿입니다. +title: "🐛 fix: " +labels: fix +assignees: '' + +--- + +# Title + +- 숨쉬기 기능 버그 발견 + +# TODO + +- [ ] 숨참기 + +# etc + +- 흐읍 diff --git "a/.github/ISSUE_TEMPLATE/\360\237\221\267\357\270\217-ci.md" "b/.github/ISSUE_TEMPLATE/\360\237\221\267\357\270\217-ci.md" new file mode 100644 index 00000000..1b7245af --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\221\267\357\270\217-ci.md" @@ -0,0 +1,20 @@ +--- +name: "👷 CI" +about: 배포 작업 템플릿입니다. +title: "👷 ci: " +labels: ci +assignees: '' + +--- + +# Title + +- xx + +# TODO + +- [ ] 작업 내용 + +# etc + +- 흐읍 diff --git "a/.github/ISSUE_TEMPLATE/\360\237\223\235\357\270\217-docs.md" "b/.github/ISSUE_TEMPLATE/\360\237\223\235\357\270\217-docs.md" new file mode 100644 index 00000000..bdc76441 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\360\237\223\235\357\270\217-docs.md" @@ -0,0 +1,20 @@ +--- +name: "📝 DOCS" +about: 문서 작업 템플릿입니다. +title: "📝 docs: " +labels: docs +assignees: '' + +--- + +# Title + +- xx 문서 작업 + +# TODO + +- [ ] 작업 내용 + +# etc + +- 흐읍 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8c68f357..53f3ab73 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,6 @@ -# 📎 Issue Number -- +# ☝️Issue Number + +- resolves # # 🔎 Key Changes - diff --git a/.github/workflows/cd_gradle.yml b/.github/workflows/cd_gradle.yml new file mode 100644 index 00000000..40ef4488 --- /dev/null +++ b/.github/workflows/cd_gradle.yml @@ -0,0 +1,103 @@ +name: CD + +on: + workflow_dispatch: + push: + branches: [ "develop" ] + +permissions: + contents: read + +jobs: + cd: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + ## gradle 캐싱 + - name: Gradle Caching + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + ## create application.yml + - name: make application-secret.yml + run: | + mkdir -p ./api/src/main/resources + touch ./api/src/main/resources/application-secret.yml + shell: bash + - name: deliver application-secret.yml + run: echo "${{ secrets.APPLICATION_SECRET }}" > ./api/src/main/resources/application-secret.yml + shell: bash + + ## firebase-key 설정 + - name: Set FCM + env: + DATA: ${{ secrets.FIREBASE_KEY }} + run: | + mkdir -p ./core/core-infra-firebase/src/main/resources/firebase + echo $DATA > ./core/core-infra-firebase/src/main/resources/firebase/firebase-key.json + + # 빌드 및 테스트 단계. + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # gradle build + - name: Build with Gradle + run: ./gradlew build -x test + + # push 하기 위해 로그인 + - name: Docker Hub 로그인 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + #도커 빌드 & 이미지 push + - name: Docker build & Push + run: | + docker build -f Dockerfile -t ${{ secrets.DOCKER_USERNAME }}/sponus-docker . + docker push ${{ secrets.DOCKER_USERNAME }}/sponus-docker + + # Docker 파일을 EC2 서버에 배포 + - name: Deploy to Prod + uses: appleboy/ssh-action@master + id: deploy-prod + with: + host: ${{ secrets.HOST }} + username: ec2-user + key: ${{ secrets.PRIVATE_KEY }} + port: 22 + script: | + if [ ! -z "$(docker ps -q)" ]; then + docker stop $(docker ps -q) + fi + + if [ ! -z "$(docker ps -aq)" ]; then + docker rm $(docker ps -aq) + fi + + if [ ! -z "$(docker network ls -qf name=my-network)" ]; then + docker network rm my-network + fi + + echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + docker pull ${{ secrets.DOCKER_USERNAME }}/sponus-docker + docker pull redis + docker network create my-network + + docker run --name my-redis --network my-network -d redis + docker run -e SPRING_PROFILES_ACTIVE=prod -it -d -p 8080:8080 --name sponus-docker -e TZ=Asia/Seoul --network my-network ${{ secrets.DOCKER_USERNAME }}/sponus-docker + + docker system prune -f diff --git a/.github/workflows/ci_gradle.yml b/.github/workflows/ci_gradle.yml new file mode 100644 index 00000000..32dc0be8 --- /dev/null +++ b/.github/workflows/ci_gradle.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + branches: [ "develop" ] + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Test with Gradle + run: ./gradlew test diff --git a/.gitignore b/.gitignore index 524f0963..3b469f23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ +# Created by https://www.toptal.com/developers/gitignore/api/java,gradle +# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle + +*-secret.yml +**/src/main/generated/ +**/firebase/*.json + +# Mac os +*.Ds_Store + +# IntelliJ +*.idea + +### Java ### # Compiled class file *.class @@ -22,3 +36,32 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/java,gradle diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..b439576c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-jdk +ARG JAR_FILE=./api/build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "/app.jar"] diff --git a/HELP.md b/HELP.md new file mode 100644 index 00000000..2146b765 --- /dev/null +++ b/HELP.md @@ -0,0 +1,33 @@ +# Read Me First +The following was discovered as part of building this project: + +* The original package name 'com.sponus.sponus-be' is invalid and this project uses 'com.sponus.sponusbe' instead. + +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/3.2.1/gradle-plugin/reference/html/) +* [Create an OCI image](https://docs.spring.io/spring-boot/docs/3.2.1/gradle-plugin/reference/html/#build-image) +* [Spring Web](https://docs.spring.io/spring-boot/docs/3.2.1/reference/htmlsingle/index.html#web) +* [Spring Security](https://docs.spring.io/spring-boot/docs/3.2.1/reference/htmlsingle/index.html#web.security) +* [Spring Data JPA](https://docs.spring.io/spring-boot/docs/3.2.1/reference/htmlsingle/index.html#data.sql.jpa-and-spring-data) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Securing a Web Application](https://spring.io/guides/gs/securing-web/) +* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/) +* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/) +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md index 20cde727..01b55061 100644 --- a/README.md +++ b/README.md @@ -1 +1,85 @@ -# SponUs-BE \ No newline at end of file +# Spon-us | 기업과 학생의 만남 + +![sponus](https://github.com/spon-us/SponUs-BE/assets/121790935/3e0b7908-187a-486a-9802-f85d79864112) +Spon-us(스포너스)는 대학생 단체와 기업 간의 협찬, 제휴, 그리고 연계 프로그램을 원활히 이끌 어가는 서비스입니다. + +### 📎 [API 명세서](https://spon-us.notion.site/API-f9abab1bc7f448d4bcc010cfb935c5f9?pvs=4) + +
+ +## ✨ 팀원 + +| 앤디/이유제 | 마루/김대휘 | 소피/이소정 | 세헌/호세헌 | 태태/권정태 | +|:------------------------------------------------------:|:------------------------------------------------------------:|:------------------------------------------------------:|:--------------------------------------------------------:|:----------------------------------------------------:| +| | | | | : | +| Part Lead
Backend Developer | Backend Developer | Backend Developer | Backend Developer | Backend Developer | +|
[dbwp031](https://github.com/dbwp031)
|
[kimday0326](https://github.com/kimday0326)
|
[xxoznge](https://github.com/xxoznge)
|
[seheonnn](https://github.com/seheonnn)
|
[oxdjww](https://github.com/oxdjww)
| + +## 📆 프로젝트 기간 + +- 1차 개발(MVP): **2024.01.05 ~ 2024.02.19** +- 2차 개발(리빌딩): **2024.05 예정** + +
+ +## 🛠️ 기술 스택 + +### 개발 환경 + +

+ + + + + + +

+ +

+ + +

+ +

+ + + + + +

+ +### 협업 도구 + +

+ + + + + +

+ +
+ +## 🏗️ 아키텍처 + +![image](https://github.com/spon-us/SponUs-BE/assets/121790935/c7445680-0d29-41cf-9d7d-df00a698a681) + +
+ +## 커밋 컨벤션 + +| 태그이름 | 내용 | +|----------------------------|---------------------------------------------| +| :sparkles: `feat` | 새로운 기능을 추가할 경우 | +| :bug:`fix ` | 버그를 고친 경우 | +| :bug:`!hotfix` | 급하게 치명적인 버그를 고쳐야하는 경우 | +| `style` | 코드 포맷 변경, 세미 콜론 누락, 코드 수정이 없는 경우 | +| :recycle:`refactor` | 코드 리팩토링 | +| :memo:`comment` | 필요한 주석 추가 및 변경 | +| :memo:`docs` | 문서, Swagger 를 수정한 경우 | +| :hammer:`test` | 테스트 추가, 테스트 리팩토링(프로덕션 코드 변경 X) | +| `chore` | 빌드 태스트 업데이트, 패키지 매니저를 설정하는 경우(프로덕션 코드 변경 X) | +| `rename` | 파일 혹은 폴더명을 수정하거나 옮기는 작업만인 경우 | +| `remove` | 파일을 삭제하는 작업만 수행한 경우 | +| :construction_worker: `ci` | 배포 방식 수정 및 새로 추가 | +| :green_heart: `ci` | 기존 배포 스크립트 수정 | diff --git a/api/build.gradle b/api/build.gradle new file mode 100644 index 00000000..a1962050 --- /dev/null +++ b/api/build.gradle @@ -0,0 +1,19 @@ +dependencies { + + compileOnly project(':core:core-domain'); + implementation project(':core:core-infra-db'); + implementation project(':core:core-infra-s3'); + implementation project(':core:core-infra-redis'); + implementation project(':core:core-infra-email'); + implementation project(':core:core-infra-firebase'); + implementation project(':core:core-infra-security'); + + // Core + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' +} +bootJar { enabled = true } +jar { enabled = false } diff --git a/api/src/main/java/com/sponus/sponusbe/SponusBeApplication.java b/api/src/main/java/com/sponus/sponusbe/SponusBeApplication.java new file mode 100644 index 00000000..30f8139d --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/SponusBeApplication.java @@ -0,0 +1,15 @@ +package com.sponus.sponusbe; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@SpringBootApplication(scanBasePackages = {"com.sponus"}) +public class SponusBeApplication { + + public static void main(String[] args) { + SpringApplication.run(SponusBeApplication.class, args); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/auth/controller/AuthController.java b/api/src/main/java/com/sponus/sponusbe/auth/controller/AuthController.java new file mode 100644 index 00000000..d3a2d011 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/auth/controller/AuthController.java @@ -0,0 +1,27 @@ +package com.sponus.sponusbe.auth.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coreinfrasecurity.jwt.dto.JwtPair; +import com.sponus.coreinfrasecurity.jwt.util.JwtUtil; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +@RestController +public class AuthController { + + private final JwtUtil jwtUtil; + + @GetMapping("/reissue") + public ApiResponse reissueToken(@RequestHeader("RefreshToken") String refreshToken) { + return ApiResponse.onSuccess(jwtUtil.reissueToken(refreshToken)); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/controller/AnnouncementController.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/controller/AnnouncementController.java new file mode 100644 index 00000000..c14c01e4 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/controller/AnnouncementController.java @@ -0,0 +1,158 @@ +package com.sponus.sponusbe.domain.announcement.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.announcement.dto.request.AnnouncementCreateRequest; +import com.sponus.sponusbe.domain.announcement.dto.request.AnnouncementUpdateRequest; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementCreateResponse; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementDetailResponse; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementStatusUpdateResponse; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementSummaryResponse; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementUpdateResponse; +import com.sponus.sponusbe.domain.announcement.service.AnnouncementQueryService; +import com.sponus.sponusbe.domain.announcement.service.AnnouncementService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/v1/announcements") +@RestController +public class AnnouncementController { + + private final AnnouncementQueryService announcementQueryService; + private final AnnouncementService announcementService; + + @GetMapping("/recommend") + public ApiResponse> getRecommendAnnouncement() { + return ApiResponse.onSuccess(announcementQueryService.getRecommendAnnouncement()); + } + + @GetMapping("/popular") + public ApiResponse> getPopularAnnouncement() { + return ApiResponse.onSuccess(announcementQueryService.getPopularAnnouncement()); + } + + @GetMapping("/{announcementId}") + public ApiResponse getAnnouncement( + @PathVariable("announcementId") Long announcementId, + @AuthOrganization Organization authOrganization + ) { + return ApiResponse.onSuccess(announcementService.getAnnouncement(authOrganization, announcementId)); + } + + // @GetMapping("/status") + // public ApiResponse> getListAnnouncement( + // @RequestParam("status") AnnouncementStatus status) { + // return ApiResponse.onSuccess(announcementQueryService.getListAnnouncement(status)); + // } + + @GetMapping("/me/opened") + public ApiResponse> getMyOpenedAnnouncement( + @AuthOrganization Organization authOrganization) { + return ApiResponse.onSuccess(announcementQueryService.getMyOpenedAnnouncement(authOrganization)); + } + + @GetMapping("/me") + public ApiResponse> getMyAnnouncement( + @AuthOrganization Organization authOrganization) { + return ApiResponse.onSuccess(announcementQueryService.getMyAnnouncement(authOrganization)); + } + + @GetMapping + public ApiResponse> searchAnnouncement(@RequestParam("search") String keyword) { + return ApiResponse.onSuccess(announcementQueryService.searchAnnouncement(keyword)); + } + + @PostMapping(consumes = "multipart/form-data") + public ApiResponse createAnnouncement( + @AuthOrganization Organization authOrganization, + @Valid @RequestPart("request") AnnouncementCreateRequest request, + @RequestPart(value = "images") List images + ) { + return ApiResponse.onSuccess( + announcementService.createAnnouncement( + authOrganization, + request, + images + ) + ); + } + + @DeleteMapping("/{announcementId}") + public ApiResponse deleteAnnouncement( + @AuthOrganization Organization authOrganization, + @PathVariable Long announcementId) { + announcementService.deleteAnnouncement(authOrganization, announcementId); + return ApiResponse.onSuccess(null); + } + + @PatchMapping(value = "/{announcementId}", consumes = "multipart/form-data") + public ApiResponse updateAnnouncement( + @AuthOrganization Organization authOrganization, + @PathVariable Long announcementId, + @RequestPart("request") @Valid AnnouncementUpdateRequest request, + @RequestPart(value = "images", required = false) @Valid List images + ) { + return ApiResponse.onSuccess(announcementService.updateAnnouncement( + authOrganization, + announcementId, + request, + images + )); + } + + @PatchMapping("/{announcementId}/status") + public ApiResponse updateAnnouncementStatus( + @AuthOrganization Organization authOrganization, + @PathVariable Long announcementId, + @RequestBody @Valid AnnouncementStatusUpdateResponse request + ) { + return ApiResponse.onSuccess( + announcementService.updateAnnouncementStatus(authOrganization, announcementId, request)); + } + + @GetMapping("/category") + public ApiResponse> getAnnouncementByCategory( + @RequestParam(value = "category", required = false) AnnouncementCategory category, + @RequestParam(value = "type", required = false) AnnouncementType type + ) { + log.info(String.valueOf(category)); + return ApiResponse.onSuccess( + announcementQueryService.getAnnouncementByCategory(category, type)); + } + + @GetMapping("/recently_viewed_announcements") + public ApiResponse> getRecentAnnouncement( + @AuthOrganization Organization authOrganization + ) { + return ApiResponse.onSuccess( + announcementQueryService.getRecentlyViewedAnnouncement(authOrganization) + ); + } + + @PatchMapping("/{announcementId}/pullUp") + public ApiResponse pullUpAnnouncement(@PathVariable("announcementId") Long announcementId) { + announcementService.updateUpdatedAt(announcementId); + return ApiResponse.onSuccess(null); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/request/AnnouncementCreateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/request/AnnouncementCreateRequest.java new file mode 100644 index 00000000..793324ff --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/request/AnnouncementCreateRequest.java @@ -0,0 +1,34 @@ +package com.sponus.sponusbe.domain.announcement.dto.request; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; +import com.sponus.coredomain.domain.organization.Organization; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnnouncementCreateRequest( + @NotBlank(message = "[ERROR] 타이틀 입력은 필수 입니다.") + String title, + @NotNull(message = "[ERROR] 유형 입력은 필수 입니다.") + AnnouncementType type, + @NotNull(message = "[ERROR] 카테코리 입력은 필수 입니다.") + AnnouncementCategory category, + @NotBlank(message = "[ERROR] 내용 입력은 필수 입니다.") + String content +) { + + public Announcement toEntity(Organization writer) { + return Announcement.builder() + .writer(writer) + .title(title) + .type(type) + .category(category) + .content(content) + .status(AnnouncementStatus.OPENED) + .build(); + } +} + diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/request/AnnouncementUpdateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/request/AnnouncementUpdateRequest.java new file mode 100644 index 00000000..2c700a5a --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/request/AnnouncementUpdateRequest.java @@ -0,0 +1,15 @@ +package com.sponus.sponusbe.domain.announcement.dto.request; + +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; + +import lombok.Builder; + +@Builder +public record AnnouncementUpdateRequest( + String title, + AnnouncementType type, + AnnouncementCategory category, + String content +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementCreateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementCreateResponse.java new file mode 100644 index 00000000..188fa24a --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementCreateResponse.java @@ -0,0 +1,33 @@ +package com.sponus.sponusbe.domain.announcement.dto.response; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; + +import lombok.Builder; + +@Builder +public record AnnouncementCreateResponse( + Long id, + Long writerId, + String title, + AnnouncementType type, + AnnouncementCategory category, + String content, + AnnouncementStatus status, + Long viewCount +) { + public static AnnouncementCreateResponse from(Announcement announcement) { + return AnnouncementCreateResponse.builder() + .id(announcement.getId()) + .writerId(announcement.getWriter().getId()) + .title(announcement.getTitle()) + .type(announcement.getType()) + .category(announcement.getCategory()) + .content(announcement.getContent()) + .status(announcement.getStatus()) + .viewCount(announcement.getViewCount()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementDetailResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementDetailResponse.java new file mode 100644 index 00000000..123b86fe --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementDetailResponse.java @@ -0,0 +1,47 @@ +package com.sponus.sponusbe.domain.announcement.dto.response; + +import java.util.List; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; +import com.sponus.sponusbe.domain.organization.dto.OrganizationSummaryResponse; + +import lombok.Builder; + +@Builder +public record AnnouncementDetailResponse( + Long id, + OrganizationSummaryResponse writer, + String title, + AnnouncementType type, + AnnouncementCategory category, + String content, + List announcementImages, + AnnouncementStatus status, + Long viewCount, + boolean canApply // 공고 조회 시 지원 가능한지(처음 지원하는 것인지) 프론트에서 확인하기 위한 필드 +) { + public static AnnouncementDetailResponse from(Announcement announcement, boolean canApply) { + List announcementImages = announcement.getAnnouncementImages() + .stream() + .map(AnnouncementImageResponse::from) + .toList(); + + OrganizationSummaryResponse writer = OrganizationSummaryResponse.from(announcement.getWriter()); + + return AnnouncementDetailResponse.builder() + .id(announcement.getId()) + .writer(writer) + .title(announcement.getTitle()) + .type(announcement.getType()) + .category(announcement.getCategory()) + .content(announcement.getContent()) + .announcementImages(announcementImages) + .status(announcement.getStatus()) + .viewCount(announcement.getViewCount()) + .canApply(canApply) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementImageResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementImageResponse.java new file mode 100644 index 00000000..4a084e09 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementImageResponse.java @@ -0,0 +1,20 @@ +package com.sponus.sponusbe.domain.announcement.dto.response; + +import com.sponus.coredomain.domain.announcement.AnnouncementImage; + +import lombok.Builder; + +@Builder +public record AnnouncementImageResponse( + Long id, + String name, + String url +) { + public static AnnouncementImageResponse from(AnnouncementImage image) { + return AnnouncementImageResponse.builder() + .id(image.getId()) + .name(image.getName()) + .url(image.getUrl()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementStatusUpdateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementStatusUpdateResponse.java new file mode 100644 index 00000000..7e343974 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementStatusUpdateResponse.java @@ -0,0 +1,9 @@ +package com.sponus.sponusbe.domain.announcement.dto.response; + +import jakarta.validation.constraints.NotBlank; + +public record AnnouncementStatusUpdateResponse( + @NotBlank(message = "[ERROR] 공고 상태 입력은 필수 입니다.") + String status +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementSummaryResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementSummaryResponse.java new file mode 100644 index 00000000..3932f9b7 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementSummaryResponse.java @@ -0,0 +1,56 @@ +package com.sponus.sponusbe.domain.announcement.dto.response; + +import java.time.LocalDateTime; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.AnnouncementImage; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; + +import lombok.Builder; + +@Builder +public record AnnouncementSummaryResponse( + Long id, + Long writerId, + String writerName, + String title, + AnnouncementType type, + AnnouncementCategory category, + AnnouncementImageResponse mainImage, + AnnouncementStatus status, + Long viewCount, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Long saveCount +) { + public static AnnouncementSummaryResponse from(Announcement announcement) { + AnnouncementImage mainImage = announcement.getAnnouncementImages() + .stream() + .findFirst().orElseThrow(); + + return AnnouncementSummaryResponse.builder() + .id(announcement.getId()) + .writerId(announcement.getWriter().getId()) + .writerName(announcement.getWriter().getName()) + .title(announcement.getTitle()) + .type(announcement.getType()) + .category(announcement.getCategory()) + .mainImage(AnnouncementImageResponse.from(mainImage)) + .status(announcement.getStatus()) + .viewCount(announcement.getViewCount()) + .createdAt(announcement.getCreatedAt()) + .updatedAt(announcement.getUpdatedAt()) + .saveCount(announcement.getBookmarkSaveCount()) + .build(); + } + + public AnnouncementStatus getStatus() { + return status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementUpdateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementUpdateResponse.java new file mode 100644 index 00000000..cbd50f21 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/dto/response/AnnouncementUpdateResponse.java @@ -0,0 +1,33 @@ +package com.sponus.sponusbe.domain.announcement.dto.response; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; + +import lombok.Builder; + +@Builder +public record AnnouncementUpdateResponse( + Long id, + Long writerId, + String title, + AnnouncementType type, + AnnouncementCategory category, + String content, + AnnouncementStatus status, + Long viewCount +) { + public static AnnouncementUpdateResponse from(Announcement announcement) { + return AnnouncementUpdateResponse.builder() + .id(announcement.getId()) + .writerId(announcement.getWriter().getId()) + .title(announcement.getTitle()) + .type(announcement.getType()) + .category(announcement.getCategory()) + .content(announcement.getContent()) + .status(announcement.getStatus()) + .viewCount(announcement.getViewCount()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/exception/AnnouncementErrorCode.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/exception/AnnouncementErrorCode.java new file mode 100644 index 00000000..83277ec4 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/exception/AnnouncementErrorCode.java @@ -0,0 +1,30 @@ +package com.sponus.sponusbe.domain.announcement.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AnnouncementErrorCode implements BaseErrorCode { + ANNOUNCEMENT_ERROR(HttpStatus.BAD_REQUEST, "ANC4000", "공고 관련 에러"), + ANNOUNCEMENT_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "ANC4001", "이미 삭제된 공지사항입니다."), + INVALID_ORGANIZATION(HttpStatus.BAD_REQUEST, "ANC4002", "해당 단체의 공고가 아닙니다."), + CLOSED_ANNOUNCEMENT_STATUS(HttpStatus.BAD_REQUEST, "ANC4003", "마감된 공고는 수정할 수 없습니다."), + ANNOUNCEMENT_NOT_IN_PROGRESS(HttpStatus.BAD_REQUEST, "ANC4004", "진행 중인 공고가 아닙니다."), + INVALID_ANNOUNCEMENT_STATUS(HttpStatus.BAD_REQUEST, "ANC4005", "유효하지 않은 공고 상태입니다."), + ANNOUNCEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "ANC4040", "해당 공고가 존재하지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/exception/AnnouncementException.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/exception/AnnouncementException.java new file mode 100644 index 00000000..03e3b474 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/exception/AnnouncementException.java @@ -0,0 +1,10 @@ +package com.sponus.sponusbe.domain.announcement.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.sponusbe.global.exception.CustomException; + +public class AnnouncementException extends CustomException { + public AnnouncementException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/service/AnnouncementQueryService.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/service/AnnouncementQueryService.java new file mode 100644 index 00000000..6801f204 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/service/AnnouncementQueryService.java @@ -0,0 +1,113 @@ +package com.sponus.sponusbe.domain.announcement.service; + +import java.util.Comparator; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; +import com.sponus.coredomain.domain.announcement.repository.AnnouncementRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfraredis.util.RedisUtil; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementSummaryResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class AnnouncementQueryService { + + private final AnnouncementRepository announcementRepository; + private final RedisUtil redisUtil; + + public List searchAnnouncement(String keyword) { + log.info("search announcement by keyword: {}", keyword); + return announcementRepository.findByTitleContains(keyword).stream() + .map(AnnouncementSummaryResponse::from).toList(); + } + + // public List getListAnnouncement(AnnouncementStatus status) { + // List announcements = announcementRepository.findByStatus(status); + // return announcements.stream() + // .map(AnnouncementSummaryResponse::from) + // .toList(); + // } + + public List getMyOpenedAnnouncement(Organization authOrganization) { + List announcements = announcementRepository.findByWriterIdOrderByCreatedAtDesc( + authOrganization.getId()); + return announcements.stream() + .filter(announcement -> announcement.getStatus() == AnnouncementStatus.OPENED) + .map(AnnouncementSummaryResponse::from) + .toList(); + } + + public List getMyAnnouncement(Organization authOrganization) { + List announcements = announcementRepository.findByWriterIdOrderByCreatedAtDesc( + authOrganization.getId()); + return announcements.stream() + .map(AnnouncementSummaryResponse::from) + .toList(); + } + + public List getAnnouncementByCategory(AnnouncementCategory category, + AnnouncementType type) { + // 둘 다 값이 있는 경우 + if (category != null && type != null) { + log.info("category & type"); + return announcementRepository.findByCategoryAndTypeOrderByCreatedAtDesc(category, type) + .stream() + .filter(announcement -> announcement.getStatus() == AnnouncementStatus.OPENED) + .map(AnnouncementSummaryResponse::from) + .toList(); + } + // category 만 있는 경우 + else if (category != null) { + return announcementRepository.findByCategoryOrderByCreatedAtDesc(category) + .stream() + .filter(announcement -> announcement.getStatus() == AnnouncementStatus.OPENED) + .map(AnnouncementSummaryResponse::from) + .toList(); + } + // type 만 있는 경우 + else if (type != null) { + return announcementRepository.findByTypeOrderByCreatedAtDesc(type) + .stream() + .filter(announcement -> announcement.getStatus() == AnnouncementStatus.OPENED) + .map(AnnouncementSummaryResponse::from) + .toList(); + } + // 둘 다 값이 없는 경우, 전체 announcement 반환 + else { + return announcementRepository.findAll() + .stream() + .filter(announcement -> announcement.getStatus() == AnnouncementStatus.OPENED) + .map(AnnouncementSummaryResponse::from) + .sorted(Comparator.comparing(AnnouncementSummaryResponse::getCreatedAt).reversed()) + .toList(); + } + } + + public List getRecentlyViewedAnnouncement(Organization authOrganization) { + return redisUtil.getList(authOrganization.getEmail() + "_recently_viewed_list"); + } + + public List getPopularAnnouncement() { + return announcementRepository.findTop10OrderByViewCountDesc().stream() + .map(AnnouncementSummaryResponse::from) + .toList(); + } + + public List getRecommendAnnouncement() { + return announcementRepository.findOrderByRandom().stream() + .map(AnnouncementSummaryResponse::from) + .toList(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/announcement/service/AnnouncementService.java b/api/src/main/java/com/sponus/sponusbe/domain/announcement/service/AnnouncementService.java new file mode 100644 index 00000000..83ad5b5c --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/announcement/service/AnnouncementService.java @@ -0,0 +1,174 @@ +package com.sponus.sponusbe.domain.announcement.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.AnnouncementImage; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.repository.AnnouncementRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.repository.ProposeRepository; +import com.sponus.coreinfraredis.entity.AnnouncementView; +import com.sponus.coreinfraredis.repository.AnnouncementViewRepository; +import com.sponus.coreinfraredis.util.RedisUtil; +import com.sponus.coreinfras3.S3Service; +import com.sponus.sponusbe.domain.announcement.dto.request.AnnouncementCreateRequest; +import com.sponus.sponusbe.domain.announcement.dto.request.AnnouncementUpdateRequest; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementCreateResponse; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementDetailResponse; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementStatusUpdateResponse; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementUpdateResponse; +import com.sponus.sponusbe.domain.announcement.exception.AnnouncementErrorCode; +import com.sponus.sponusbe.domain.announcement.exception.AnnouncementException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Transactional +@Service +public class AnnouncementService { + + private final AnnouncementRepository announcementRepository; + private final AnnouncementViewRepository announcementViewRepository; + private final ProposeRepository proposeRepository; + private final S3Service s3Service; + private final RedisUtil redisUtil; + + public AnnouncementCreateResponse createAnnouncement( + Organization authOrganization, + AnnouncementCreateRequest request, + List images + ) { + final Announcement announcement = request.toEntity(authOrganization); + updateAnnouncementImages(announcement, images); + return AnnouncementCreateResponse.from(announcementRepository.save(announcement)); + } + + public AnnouncementDetailResponse getAnnouncement(Organization organization, Long announcementId) { + Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_FOUND)); + + AnnouncementView announcementView = announcementViewRepository.findById(announcementId.toString()) + .orElseGet(() -> AnnouncementView.builder().announcementId(announcementId.toString()).build()); + + if (!announcementView.getOrganizationIds().contains(organization.getId().toString())) { + announcementView.getOrganizationIds().add(organization.getId().toString()); + announcementViewRepository.save(announcementView); + } + + redisUtil.appendToRecentlyViewedAnnouncement(organization.getEmail() + "_recently_viewed_list", + String.valueOf(announcementId)); + + if (proposeRepository.findByProposingOrganizationIdAndAnnouncementId(organization.getId(), announcementId) + .isPresent()) { + return AnnouncementDetailResponse.from(announcement, false); + } + + return AnnouncementDetailResponse.from(announcement, true); + } + + public void deleteAnnouncement(Organization organization, Long announcementId) { + final Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_FOUND)); + if (!announcement.getWriter().getId().equals(organization.getId())) { + throw new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_FOUND); + } + announcementRepository.delete(announcement); + } + + public AnnouncementUpdateResponse updateAnnouncement( + Organization authOrganization, + Long announcementId, + AnnouncementUpdateRequest request, + List images + ) { + final Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_FOUND)); + + if (announcement.getStatus() != AnnouncementStatus.OPENED) + throw new AnnouncementException(AnnouncementErrorCode.CLOSED_ANNOUNCEMENT_STATUS); + if (!isOrganizationsAnnouncement(authOrganization.getId(), announcement)) + throw new AnnouncementException(AnnouncementErrorCode.INVALID_ORGANIZATION); + + announcement.updateInfo( + request.title(), + request.type(), + request.category(), + request.content() + ); + + // 공고는 이미지가 필수이므로, 이미지가 없는 경우에는 업데이트하지 않음 + if (images != null) + updateAnnouncementImages(announcement, images); + + return AnnouncementUpdateResponse.from(announcement); + } + + public AnnouncementUpdateResponse updateAnnouncementStatus( + Organization authOrganization, + Long announcementId, + AnnouncementStatusUpdateResponse request) { + final Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_FOUND)); + if (!isOrganizationsAnnouncement(authOrganization.getId(), announcement)) + throw new AnnouncementException(AnnouncementErrorCode.INVALID_ORGANIZATION); + + try { + announcement.updateStatus(AnnouncementStatus.of(request.status())); + } catch (Exception e) { + throw new AnnouncementException(AnnouncementErrorCode.INVALID_ANNOUNCEMENT_STATUS); + } + return AnnouncementUpdateResponse.from(announcement); + } + + public void updateAllViewedAnnouncementViewCount() { + Iterable announcementViews = announcementViewRepository.findAll(); + announcementViews.forEach(announcementView -> { + Optional optionalAnnouncement = announcementRepository.findById( + Long.parseLong(announcementView.getAnnouncementId())); + if (optionalAnnouncement.isPresent()) { + Announcement announcement = optionalAnnouncement.get(); + announcement.updateViewCount(announcementView.getOrganizationIds().size()); + } + }); + } + + public void resetAllAnnouncementViewCount() { + List announcements = announcementRepository.findAll(); + announcements.forEach(announcement -> announcement.updateViewCount(0L)); + } + + public void updateUpdatedAt(Long announcementId) { + final Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_FOUND)); + announcement.setUpdatedAt(LocalDateTime.now()); + } + + private boolean isOrganizationsAnnouncement(Long organizationId, Announcement announcement) { + return announcement.getWriter().getId().equals(organizationId); + } + + private void updateAnnouncementImages(Announcement announcement, List images) { + // 공고의 이미지는 반드시 존재해야함 + announcement.getAnnouncementImages().stream().forEach(image -> { + s3Service.deleteFile(image.getUrl()); + }); + announcement.getAnnouncementImages().clear(); + images.forEach(image -> { + final String url = s3Service.uploadFile(image); + AnnouncementImage announcementImage = AnnouncementImage.builder() + .name(image.getOriginalFilename()) + .url(url) + .build(); + announcementImage.setAnnouncement(announcement); + }); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/bookmark/controller/BookmarkController.java b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/controller/BookmarkController.java new file mode 100644 index 00000000..12f010bb --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/controller/BookmarkController.java @@ -0,0 +1,59 @@ +package com.sponus.sponusbe.domain.bookmark.controller; + +import java.util.Collections; +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.sponus.coredomain.domain.bookmark.BookmarkStatus; +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.bookmark.dto.BookmarkGetResponse; +import com.sponus.sponusbe.domain.bookmark.dto.BookmarkToggleRequest; +import com.sponus.sponusbe.domain.bookmark.dto.BookmarkToggleResponse; +import com.sponus.sponusbe.domain.bookmark.service.BookmarkQueryService; +import com.sponus.sponusbe.domain.bookmark.service.BookmarkService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/me/announcements") +public class BookmarkController { + + private final BookmarkService bookmarkService; + private final BookmarkQueryService bookmarkQueryService; + + @PostMapping("/bookmarked") + public ApiResponse bookmarkToggle( + @AuthOrganization Organization authOrganization, + @RequestBody BookmarkToggleRequest request + ) { + return ApiResponse.onSuccess(bookmarkService.bookmarkToggle(authOrganization, request)); + } + + @GetMapping("/bookmarked") + public ApiResponse> getBookmark( + @AuthOrganization Organization authOrganization, + @RequestParam(name = "sort") BookmarkStatus sortStatus + ) { + if (sortStatus == BookmarkStatus.RECENT) { + return ApiResponse.onSuccess(bookmarkQueryService.getRecentBookmark(authOrganization)); + } + + if (sortStatus == BookmarkStatus.VIEWED) { + return ApiResponse.onSuccess(bookmarkQueryService.getViewedBookmark(authOrganization)); + } + + if (sortStatus == BookmarkStatus.SAVED) { + return ApiResponse.onSuccess(bookmarkQueryService.getSavedBookmark(authOrganization)); + } + return ApiResponse.onSuccess(Collections.emptyList()); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkGetResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkGetResponse.java new file mode 100644 index 00000000..27b1f005 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkGetResponse.java @@ -0,0 +1,48 @@ +package com.sponus.sponusbe.domain.bookmark.dto; + +import java.time.LocalDateTime; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.AnnouncementImage; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; +import com.sponus.coredomain.domain.bookmark.Bookmark; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementImageResponse; + +import lombok.Builder; + +@Builder +public record BookmarkGetResponse( + Long id, + Long writerId, + String writerName, + String title, + AnnouncementType type, + AnnouncementCategory category, + AnnouncementImageResponse mainImage, + LocalDateTime createdAt, + Long viewCount, + Long saveCount +) { + + public static BookmarkGetResponse from(Announcement announcement, Bookmark bookmark) { + AnnouncementImage mainImage = announcement.getAnnouncementImages() + .stream() + .findFirst() + .orElseThrow(); + + return BookmarkGetResponse.builder() + .id(announcement.getId()) + .writerId(announcement.getWriter().getId()) + .writerName(announcement.getWriter().getName()) + .title(announcement.getTitle()) + .type(announcement.getType()) + .category(announcement.getCategory()) + .mainImage(AnnouncementImageResponse.from(mainImage)) + .createdAt(bookmark.getCreatedAt()) + .viewCount(announcement.getViewCount()) + .saveCount(bookmark.getSaveCount()) + .build(); + } +} + diff --git a/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkToggleRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkToggleRequest.java new file mode 100644 index 00000000..b0dc6bcd --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkToggleRequest.java @@ -0,0 +1,18 @@ +package com.sponus.sponusbe.domain.bookmark.dto; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.bookmark.Bookmark; +import com.sponus.coredomain.domain.organization.Organization; + +public record BookmarkToggleRequest( + Long announcementId +) { + + public Bookmark toEntity(Organization organization, Announcement announcement) { + return Bookmark.builder() + .organization(organization) + .announcement(announcement) + .build(); + } +} + diff --git a/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkToggleResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkToggleResponse.java new file mode 100644 index 00000000..5dd50d47 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/dto/BookmarkToggleResponse.java @@ -0,0 +1,23 @@ +package com.sponus.sponusbe.domain.bookmark.dto; + +import com.sponus.coredomain.domain.bookmark.Bookmark; + +import lombok.Builder; + +@Builder +public record BookmarkToggleResponse( + Long id, + Long organizationId, + Long announcementId, + Boolean bookmarked +) { + + public static BookmarkToggleResponse from(Bookmark bookmark, boolean bookmarked) { + return BookmarkToggleResponse.builder() + .id(bookmark.getId()) + .organizationId(bookmark.getOrganization().getId()) + .announcementId(bookmark.getAnnouncement().getId()) + .bookmarked(bookmarked) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/bookmark/service/BookmarkQueryService.java b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/service/BookmarkQueryService.java new file mode 100644 index 00000000..8eb54db2 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/service/BookmarkQueryService.java @@ -0,0 +1,42 @@ +package com.sponus.sponusbe.domain.bookmark.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.bookmark.Bookmark; +import com.sponus.coredomain.domain.bookmark.repository.BookmarkRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.sponusbe.domain.bookmark.dto.BookmarkGetResponse; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class BookmarkQueryService { + + private final BookmarkRepository bookmarkRepository; + + public List getRecentBookmark(Organization organization) { + return bookmarkRepository.findByOrganizationOrderByCreatedAtDesc(organization) + .stream() + .map(bookmark -> BookmarkGetResponse.from(bookmark.getAnnouncement(), bookmark)) + .toList(); + } + + public List getViewedBookmark(Organization organization) { + List bookmarks = bookmarkRepository.findByOrganizationOrderByAnnouncementViewCountDesc(organization); + return bookmarks.stream() + .map(bookmark -> BookmarkGetResponse.from(bookmark.getAnnouncement(), bookmark)) + .toList(); + } + + public List getSavedBookmark(Organization organization) { + List bookmarks = bookmarkRepository.findByOrganizationOrderBySaveCountDesc(organization); + return bookmarks.stream() + .map(bookmark -> BookmarkGetResponse.from(bookmark.getAnnouncement(), bookmark)) + .toList(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/bookmark/service/BookmarkService.java b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/service/BookmarkService.java new file mode 100644 index 00000000..b5ce5109 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/bookmark/service/BookmarkService.java @@ -0,0 +1,45 @@ +package com.sponus.sponusbe.domain.bookmark.service; + +import static com.sponus.sponusbe.domain.announcement.exception.AnnouncementErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.repository.AnnouncementRepository; +import com.sponus.coredomain.domain.bookmark.Bookmark; +import com.sponus.coredomain.domain.bookmark.repository.BookmarkRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.sponusbe.domain.announcement.exception.AnnouncementException; +import com.sponus.sponusbe.domain.bookmark.dto.BookmarkToggleRequest; +import com.sponus.sponusbe.domain.bookmark.dto.BookmarkToggleResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Transactional +@Service +public class BookmarkService { + + private final BookmarkRepository bookmarkRepository; + private final AnnouncementRepository announcementRepository; + + public BookmarkToggleResponse bookmarkToggle(Organization organization, BookmarkToggleRequest request) { + Announcement announcement = announcementRepository.findById(request.announcementId()) + .orElseThrow(() -> new AnnouncementException(ANNOUNCEMENT_NOT_FOUND)); + Bookmark existingBookmark = bookmarkRepository.findByOrganizationAndAnnouncement(organization, announcement) + .orElse(null); + + if (existingBookmark != null) { + bookmarkRepository.delete(existingBookmark); + existingBookmark.decreaseSaveCount(); + return BookmarkToggleResponse.from(existingBookmark, false); // 이미 북마크가 되어있는 경우 취소 + } else { + final Bookmark bookmark = bookmarkRepository.save(request.toEntity(organization, announcement)); + bookmark.increaseSaveCount(); + return BookmarkToggleResponse.from(bookmark, true); // 북마크가 안되어있는 경우 등록 + } + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/notification/controller/FirebaseTestController.java b/api/src/main/java/com/sponus/sponusbe/domain/notification/controller/FirebaseTestController.java new file mode 100644 index 00000000..a09f1706 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/notification/controller/FirebaseTestController.java @@ -0,0 +1,32 @@ +package com.sponus.sponusbe.domain.notification.controller; + +import java.io.IOException; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrafirebase.FirebaseService; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.notification.dto.request.NotificationTestRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RequestMapping("api/v1/notification") +@RestController +public class FirebaseTestController { + + private final FirebaseService firebaseService; + + @PostMapping("/fcm") + public String testNotification(@RequestBody NotificationTestRequest request, + @AuthOrganization Organization organization) throws IOException { + firebaseService.sendMessageTo(organization, request.title(), request.body(), null, null, null); + return "Notification test is successful !"; + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/notification/dto/request/NotificationTestRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/notification/dto/request/NotificationTestRequest.java new file mode 100644 index 00000000..d1e09d29 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/notification/dto/request/NotificationTestRequest.java @@ -0,0 +1,15 @@ +package com.sponus.sponusbe.domain.notification.dto.request; + +import com.sponus.coredomain.domain.notification.Notification; + +public record NotificationTestRequest( + String title, + String body +) { + public Notification toEntity() { + return Notification.builder() + .title(title) + .body(body) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/notification/dto/response/NotificationSummaryResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/notification/dto/response/NotificationSummaryResponse.java new file mode 100644 index 00000000..12f1c681 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/notification/dto/response/NotificationSummaryResponse.java @@ -0,0 +1,43 @@ +package com.sponus.sponusbe.domain.notification.dto.response; + +import java.time.LocalDateTime; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.notification.Notification; +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.report.Report; + +import lombok.Builder; + +@Builder +public record NotificationSummaryResponse( + Long id, + String title, + String body, + Long organizationId, + Long announcementId, + Long proposeId, + Long reportId, + boolean isRead, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static NotificationSummaryResponse from(Notification notification) { + Announcement announcement = notification.getAnnouncement(); + Propose propose = notification.getPropose(); + Report report = notification.getReport(); + + return NotificationSummaryResponse.builder() + .id(notification.getId()) + .title(notification.getTitle()) + .body(notification.getBody()) + .organizationId(notification.getOrganization().getId()) + .announcementId(announcement != null ? announcement.getId() : null) + .proposeId(propose != null ? propose.getId() : null) + .reportId(report != null ? report.getId() : null) + .isRead(notification.isRead()) + .createdAt(notification.getCreatedAt()) + .updatedAt(notification.getUpdatedAt()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/notification/exception/NotificationErrorCode.java b/api/src/main/java/com/sponus/sponusbe/domain/notification/exception/NotificationErrorCode.java new file mode 100644 index 00000000..a0c8b1c4 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/notification/exception/NotificationErrorCode.java @@ -0,0 +1,26 @@ +package com.sponus.sponusbe.domain.notification.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum NotificationErrorCode implements BaseErrorCode { + NOTIFICATION_ERROR(HttpStatus.BAD_REQUEST, "NOTI4000", "알림 관련 에러"), + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTI4001", "존재하지 않는 알림입니다."), + INVALID_ORGANIZATION(HttpStatus.BAD_REQUEST, "NOTI4002", "해당 단체의 알림이 아닙니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/notification/exception/NotificationException.java b/api/src/main/java/com/sponus/sponusbe/domain/notification/exception/NotificationException.java new file mode 100644 index 00000000..699b3934 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/notification/exception/NotificationException.java @@ -0,0 +1,11 @@ +package com.sponus.sponusbe.domain.notification.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.sponusbe.global.exception.CustomException; + +public class NotificationException extends CustomException { + + public NotificationException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/controller/OrganizationController.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/controller/OrganizationController.java new file mode 100644 index 00000000..8cbe89c1 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/controller/OrganizationController.java @@ -0,0 +1,111 @@ +package com.sponus.sponusbe.domain.organization.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.notification.dto.response.NotificationSummaryResponse; +import com.sponus.sponusbe.domain.organization.dto.OrganizationDetailGetResponse; +import com.sponus.sponusbe.domain.organization.dto.OrganizationJoinRequest; +import com.sponus.sponusbe.domain.organization.dto.OrganizationJoinResponse; +import com.sponus.sponusbe.domain.organization.dto.OrganizationSummaryResponse; +import com.sponus.sponusbe.domain.organization.dto.OrganizationUpdateRequest; +import com.sponus.sponusbe.domain.organization.service.OrganizationQueryService; +import com.sponus.sponusbe.domain.organization.service.OrganizationService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/organizations") +public class OrganizationController { + + private final OrganizationService organizationService; + private final OrganizationQueryService organizationQueryService; + + @PostMapping(value = "/join") + public ApiResponse join( + @RequestBody OrganizationJoinRequest request) { + OrganizationJoinResponse response = organizationService.join(request); + return ApiResponse.onSuccess(response); + } + + @GetMapping("/test") + public ApiResponse test(@AuthOrganization Organization organization) { + Long id = organization.getId(); + return ApiResponse.onSuccess(id); + } + + @GetMapping("/me") + public ApiResponse getMyOrganization(@AuthOrganization Organization organization) { + return ApiResponse.onSuccess(OrganizationDetailGetResponse.from(organization)); + } + + @PatchMapping(value = "/me", consumes = "multipart/form-data") + public ApiResponse updateMyOrganization( + @AuthOrganization Organization organization, + @RequestPart @Valid OrganizationUpdateRequest request, + @RequestPart(value = "attachments", required = false) MultipartFile attachment + ) { + organizationService.updateOrganization(organization.getId(), request, attachment); + return ApiResponse.onSuccess(null); + } + + @DeleteMapping("/me") + public ApiResponse deleteMyOrganization(@AuthOrganization Organization organization) { + organizationService.deactivateOrganization(organization.getId()); + return ApiResponse.onSuccess(null); + } + + @GetMapping("/{organizationId}") + public ApiResponse getOrganization(@PathVariable Long organizationId) { + return ApiResponse.onSuccess(organizationQueryService.getOrganization(organizationId)); + } + + //이메일 인증 + @PostMapping("/email") + public ApiResponse sendEmail(@RequestParam("email") String email) throws Exception { + return ApiResponse.onSuccess(organizationService.sendEmail(email)); + } + + @GetMapping + public ApiResponse> searchOrganization(@RequestParam("search") String keyword) { + return ApiResponse.onSuccess(organizationService.searchOrganization(keyword)); + } + + @GetMapping("/notifications") + public ApiResponse> getNotifications( + @AuthOrganization Organization organization) { + return ApiResponse.onSuccess(organizationQueryService.getNotifications(organization)); + } + + @DeleteMapping("/notifications/{notificationId}") + public ApiResponse deleteNotification( + @AuthOrganization Organization organization, + @PathVariable("notificationId") Long notificationId) { + organizationService.deleteNotification(organization, notificationId); + return ApiResponse.onSuccess(null); + } + + @PatchMapping("/notifications/{notificationId}") + public ApiResponse readNotification( + @AuthOrganization Organization organization, + @PathVariable("notificationId") Long notificationId) { + organizationService.readNotification(organization, notificationId); + return ApiResponse.onSuccess(null); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationDetailGetResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationDetailGetResponse.java new file mode 100644 index 00000000..a7aa8f1c --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationDetailGetResponse.java @@ -0,0 +1,60 @@ +package com.sponus.sponusbe.domain.organization.dto; + +import java.util.List; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.enums.OrganizationStatus; +import com.sponus.coredomain.domain.organization.enums.OrganizationType; +import com.sponus.coredomain.domain.organization.enums.SuborganizationType; +import com.sponus.sponusbe.domain.organizationLink.dto.response.OrganizationLinkGetResponse; +import com.sponus.sponusbe.domain.tag.dto.TagGetResponse; + +public record OrganizationDetailGetResponse( + Long organizationId, + String name, + String email, + String password, + String location, + String description, + String imageUrl, + OrganizationType organizationType, + String suborganizationType, + String managerName, + String managerPosition, + String managerEmail, + String managerPhone, + String managerAvailableDay, + String managerAvailableHour, + String managerContactPreference, + OrganizationStatus organizationStatus, + List tags, + List links +) { + public static OrganizationDetailGetResponse from(Organization organization) { + List tagGetResponses = TagGetResponse.getTagResponse(organization); + List linkGetResponses = OrganizationLinkGetResponse.getOrganizationLinkResponses( + organization); + SuborganizationType subOrganizationType = organization.getSuborganizationType(); + return new OrganizationDetailGetResponse( + organization.getId(), + organization.getName(), + organization.getEmail(), + organization.getPassword(), + organization.getLocation(), + organization.getDescription(), + organization.getImageUrl(), + organization.getOrganizationType(), + subOrganizationType != null ? subOrganizationType.getName() : null, + organization.getManagerName(), + organization.getManagerPosition(), + organization.getManagerEmail(), + organization.getManagerPhone(), + organization.getManagerAvailableDay(), + organization.getManagerAvailableHour(), + organization.getManagerContactPreference(), + organization.getOrganizationStatus(), + tagGetResponses, + linkGetResponses + ); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationJoinRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationJoinRequest.java new file mode 100644 index 00000000..27a895ef --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationJoinRequest.java @@ -0,0 +1,42 @@ +package com.sponus.sponusbe.domain.organization.dto; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.enums.OrganizationStatus; +import com.sponus.coredomain.domain.organization.enums.OrganizationType; +import com.sponus.coredomain.domain.organization.enums.SuborganizationType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record OrganizationJoinRequest( + @NotBlank(message = "[ERROR] 이름 입력은 필수 입니다.") + String name, + + @NotBlank(message = "[ERROR] 이메일 입력은 필수입니다.") + @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "[ERROR] 이메일 형식에 맞지 않습니다.") + String email, + + @NotBlank(message = "[ERROR] 비밀번호 입력은 필수 입니다.") + @Size(min = 10, message = "[ERROR] 비밀번호는 최소 10자리 이이어야 합니다.") + String password, + + @NotNull(message = "[ERROR] 단체 유형 입력은 필수입니다.") + OrganizationType organizationType, + + SuborganizationType suborganizationType +) { + + public Organization toEntity(String encodedPassword) { + return Organization.builder() + .name(name) + .email(email) + .password(encodedPassword) + .organizationType(organizationType) + .suborganizationType(suborganizationType) + .organizationStatus(OrganizationStatus.ACTIVE) + .build(); + } +} + diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationJoinResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationJoinResponse.java new file mode 100644 index 00000000..0afc511a --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationJoinResponse.java @@ -0,0 +1,21 @@ +package com.sponus.sponusbe.domain.organization.dto; + +import com.sponus.coredomain.domain.organization.Organization; + +import lombok.Builder; + +@Builder +public record OrganizationJoinResponse( + Long id, + String email, + String name +) { + + public static OrganizationJoinResponse from(Organization organization) { + return OrganizationJoinResponse.builder() + .id(organization.getId()) + .email(organization.getEmail()) + .name(organization.getName()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationSummaryResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationSummaryResponse.java new file mode 100644 index 00000000..b2e4605d --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationSummaryResponse.java @@ -0,0 +1,28 @@ +package com.sponus.sponusbe.domain.organization.dto; + +import java.util.List; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.sponusbe.domain.tag.dto.TagGetResponse; + +import lombok.Builder; + +@Builder +public record OrganizationSummaryResponse( + Long id, + String name, + String image, + List tags +) { + public static OrganizationSummaryResponse from(Organization organization) { + List tags = TagGetResponse.getTagResponse(organization); + + return OrganizationSummaryResponse.builder() + .id(organization.getId()) + .name(organization.getName()) + .image(organization.getImageUrl()) + .tags(tags) + .build(); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationUpdateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationUpdateRequest.java new file mode 100644 index 00000000..c2cb994f --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/dto/OrganizationUpdateRequest.java @@ -0,0 +1,22 @@ +package com.sponus.sponusbe.domain.organization.dto; + +import com.sponus.coredomain.domain.organization.enums.OrganizationType; +import com.sponus.coredomain.domain.organization.enums.SuborganizationType; + +public record OrganizationUpdateRequest( + String name, + String email, + String password, + String location, + String description, + OrganizationType organizationType, + SuborganizationType suborganizationType, + String managerName, + String managerPosition, + String managerEmail, + String managerPhone, + String managerAvailableDay, + String managerAvailableHour, + String managerContactPreference +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/exception/OrganizationErrorCode.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/exception/OrganizationErrorCode.java new file mode 100644 index 00000000..50ab5724 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/exception/OrganizationErrorCode.java @@ -0,0 +1,27 @@ +package com.sponus.sponusbe.domain.organization.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum OrganizationErrorCode implements BaseErrorCode { + ORGANIZATION_ERROR(HttpStatus.BAD_REQUEST, "ORG4000", "단체 관련 에러"), + INVALID_FORMAT(HttpStatus.BAD_REQUEST, "ORG4001", "잘못된 형식입니다."), + ORGANIZATION_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "ORG4002", "중복된 단체 이메일입니다."), + ORGANIZATION_NOT_FOUND(HttpStatus.NOT_FOUND, "ORG4040", "존재하지 않는 단체입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/exception/OrganizationException.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/exception/OrganizationException.java new file mode 100644 index 00000000..3b265c12 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/exception/OrganizationException.java @@ -0,0 +1,11 @@ +package com.sponus.sponusbe.domain.organization.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.sponusbe.global.exception.CustomException; + +public class OrganizationException extends CustomException { + + public OrganizationException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/service/OrganizationQueryService.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/service/OrganizationQueryService.java new file mode 100644 index 00000000..ae714ced --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/service/OrganizationQueryService.java @@ -0,0 +1,39 @@ +package com.sponus.sponusbe.domain.organization.service; + +import static com.sponus.sponusbe.domain.organization.exception.OrganizationErrorCode.*; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.notification.repository.NotificationRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.repository.OrganizationRepository; +import com.sponus.sponusbe.domain.notification.dto.response.NotificationSummaryResponse; +import com.sponus.sponusbe.domain.organization.dto.OrganizationDetailGetResponse; +import com.sponus.sponusbe.domain.organization.exception.OrganizationException; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class OrganizationQueryService { + private final OrganizationRepository organizationRepository; + private final NotificationRepository notificationRepository; + + public OrganizationDetailGetResponse getOrganization(Long organizationId) { + Organization organization = organizationRepository.findById(organizationId) + .orElseThrow(() -> new OrganizationException(ORGANIZATION_NOT_FOUND)); + + return OrganizationDetailGetResponse.from(organization); + } + + public List getNotifications(Organization organization) { + return notificationRepository.findByOrganizationOrderByCreatedAtDesc(organization) + .stream() + .map(NotificationSummaryResponse::from) + .toList(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organization/service/OrganizationService.java b/api/src/main/java/com/sponus/sponusbe/domain/organization/service/OrganizationService.java new file mode 100644 index 00000000..0aca1fa0 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organization/service/OrganizationService.java @@ -0,0 +1,99 @@ +package com.sponus.sponusbe.domain.organization.service; + +import static com.sponus.sponusbe.domain.organization.exception.OrganizationErrorCode.*; + +import java.util.List; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.notification.Notification; +import com.sponus.coredomain.domain.notification.repository.NotificationRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.repository.OrganizationRepository; +import com.sponus.coreinfraemail.EmailUtil; +import com.sponus.coreinfras3.S3Service; +import com.sponus.sponusbe.domain.notification.exception.NotificationErrorCode; +import com.sponus.sponusbe.domain.notification.exception.NotificationException; +import com.sponus.sponusbe.domain.organization.dto.OrganizationJoinRequest; +import com.sponus.sponusbe.domain.organization.dto.OrganizationJoinResponse; +import com.sponus.sponusbe.domain.organization.dto.OrganizationSummaryResponse; +import com.sponus.sponusbe.domain.organization.dto.OrganizationUpdateRequest; +import com.sponus.sponusbe.domain.organization.exception.OrganizationException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Transactional +@RequiredArgsConstructor +@Service +public class OrganizationService { + + private final OrganizationRepository organizationRepository; + private final NotificationRepository notificationRepository; + private final PasswordEncoder passwordEncoder; + private final S3Service s3Service; + private final EmailUtil emailUtil; + + public OrganizationJoinResponse join(OrganizationJoinRequest request) { + final Organization organization = organizationRepository.save( + request.toEntity(passwordEncoder.encode(request.password()))); + return OrganizationJoinResponse.from(organization); + } + + public void updateOrganization(Long organizationId, OrganizationUpdateRequest request, MultipartFile attachment) { + Organization organization = organizationRepository.findById(organizationId) + .orElseThrow(() -> new OrganizationException(ORGANIZATION_NOT_FOUND)); + + organization.update( + request.name(), request.email(), request.password(), request.location(), request.description(), + request.organizationType(), request.suborganizationType(), request.managerName(), request.managerPosition(), + request.managerEmail(), request.managerPhone(), request.managerAvailableDay(), + request.managerAvailableHour(), + request.managerContactPreference() + ); + if (attachment != null) { + s3Service.deleteFile(organization.getImageUrl()); + String newUrl = s3Service.uploadFile(attachment); + organization.updateImageUrl(newUrl); + } + } + + public void deactivateOrganization(Long organizationId) { + Organization organization = organizationRepository.findById(organizationId) + .orElseThrow(() -> new OrganizationException(ORGANIZATION_NOT_FOUND)); + organization.deactivate(); + } + + public String sendEmail(String to) throws Exception { + return emailUtil.sendEmail(to); + } + + public List searchOrganization(String keyword) { + return organizationRepository.findByNameContains(keyword) + .stream() + .map(OrganizationSummaryResponse::from) + .toList(); + } + + public void deleteNotification(Organization organization, Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + if (!notification.getOrganization().getId().equals(organization.getId())) { + throw new NotificationException(NotificationErrorCode.INVALID_ORGANIZATION); + } + notificationRepository.delete(notification); + } + + public void readNotification(Organization organization, Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new NotificationException(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + if (!notification.getOrganization().getId().equals(organization.getId())) { + throw new NotificationException(NotificationErrorCode.INVALID_ORGANIZATION); + } + notification.setRead(true); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/controller/OrganizationLinkController.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/controller/OrganizationLinkController.java new file mode 100644 index 00000000..e4fcefdb --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/controller/OrganizationLinkController.java @@ -0,0 +1,57 @@ +package com.sponus.sponusbe.domain.organizationLink.controller; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.organizationLink.dto.request.OrganizationLinkCreateRequest; +import com.sponus.sponusbe.domain.organizationLink.dto.request.OrganizationLinkUpdateRequest; +import com.sponus.sponusbe.domain.organizationLink.dto.response.OrganizationLinkCreateResponse; +import com.sponus.sponusbe.domain.organizationLink.dto.response.OrganizationLinkGetResponse; +import com.sponus.sponusbe.domain.organizationLink.service.OrganizationLinkQueryService; +import com.sponus.sponusbe.domain.organizationLink.service.OrganizationLinkService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/organization-links") +@RestController +public class OrganizationLinkController { + private final OrganizationLinkService organizationLinkService; + private final OrganizationLinkQueryService organizationLinkQueryService; + + @PostMapping + public ApiResponse createOrganizationLink( + @AuthOrganization Organization organization, + @RequestBody OrganizationLinkCreateRequest request) { + + return ApiResponse.onSuccess(organizationLinkService.createOrganizationLink(organization.getId(), request)); + } + + @GetMapping("/{organizationLinkId}") + public ApiResponse getOrganizationLink( + @PathVariable("organizationLinkId") Long organizationLinkId) { + return ApiResponse.onSuccess(organizationLinkQueryService.getOrganizationLink(organizationLinkId)); + } + + @PatchMapping("/{organizationLinkId}") + public ApiResponse updateOrganizationLink(@PathVariable("organizationLinkId") Long organizationLinkId, + @RequestBody OrganizationLinkUpdateRequest request) { + organizationLinkService.updateOrganizationLink(organizationLinkId, request); + return ApiResponse.onSuccess(null); + } + + @DeleteMapping("/{organizationLinkId}") + public ApiResponse deleteOrganizationLink(@PathVariable("organizationLinkId") Long organizationLinkId) { + organizationLinkService.deleteOrganizationLink(organizationLinkId); + return ApiResponse.onSuccess(null); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/request/OrganizationLinkCreateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/request/OrganizationLinkCreateRequest.java new file mode 100644 index 00000000..8179a460 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/request/OrganizationLinkCreateRequest.java @@ -0,0 +1,7 @@ +package com.sponus.sponusbe.domain.organizationLink.dto.request; + +public record OrganizationLinkCreateRequest( + String name, + String url +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/request/OrganizationLinkUpdateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/request/OrganizationLinkUpdateRequest.java new file mode 100644 index 00000000..b2569313 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/request/OrganizationLinkUpdateRequest.java @@ -0,0 +1,7 @@ +package com.sponus.sponusbe.domain.organizationLink.dto.request; + +public record OrganizationLinkUpdateRequest( + String name, + String url +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/response/OrganizationLinkCreateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/response/OrganizationLinkCreateResponse.java new file mode 100644 index 00000000..a21609ff --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/response/OrganizationLinkCreateResponse.java @@ -0,0 +1,6 @@ +package com.sponus.sponusbe.domain.organizationLink.dto.response; + +public record OrganizationLinkCreateResponse( + Long organizationLinkId +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/response/OrganizationLinkGetResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/response/OrganizationLinkGetResponse.java new file mode 100644 index 00000000..d2a56b50 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/dto/response/OrganizationLinkGetResponse.java @@ -0,0 +1,28 @@ +package com.sponus.sponusbe.domain.organizationLink.dto.response; + +import java.util.List; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.OrganizationLink; + +public record OrganizationLinkGetResponse( + Long organizationLinkId, + Long organizationId, + String name, + String url +) { + public static OrganizationLinkGetResponse from(OrganizationLink organizationLink) { + return new OrganizationLinkGetResponse( + organizationLink.getId(), + organizationLink.getOrganization().getId(), + organizationLink.getName(), + organizationLink.getUrl() + ); + } + + public static List getOrganizationLinkResponses(Organization organization) { + return organization.getOrganizationLinks().stream() + .map(OrganizationLinkGetResponse::from) + .toList(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/exception/OrganizationLinkErrorCode.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/exception/OrganizationLinkErrorCode.java new file mode 100644 index 00000000..c7dcb96b --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/exception/OrganizationLinkErrorCode.java @@ -0,0 +1,26 @@ +package com.sponus.sponusbe.domain.organizationLink.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum OrganizationLinkErrorCode implements BaseErrorCode { + ORGANIZATION_ERROR(HttpStatus.BAD_REQUEST, "ORGLK4000", "단체 관련 에러"), + INVALID_FORMAT(HttpStatus.BAD_REQUEST, "ORGLK4001", "잘못된 형식입니다."), + ORGANIZATION_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "ORGLK4040", "존재하지 않는 조직 링크입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/exception/OrganizationLinkException.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/exception/OrganizationLinkException.java new file mode 100644 index 00000000..52f254ed --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/exception/OrganizationLinkException.java @@ -0,0 +1,11 @@ +package com.sponus.sponusbe.domain.organizationLink.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.sponusbe.global.exception.CustomException; + +public class OrganizationLinkException extends CustomException { + public OrganizationLinkException(BaseErrorCode errorCode) { + super(errorCode); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/service/OrganizationLinkQueryService.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/service/OrganizationLinkQueryService.java new file mode 100644 index 00000000..7ac14d26 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/service/OrganizationLinkQueryService.java @@ -0,0 +1,28 @@ +package com.sponus.sponusbe.domain.organizationLink.service; + +import static com.sponus.sponusbe.domain.organizationLink.exception.OrganizationLinkErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.organization.OrganizationLink; +import com.sponus.coredomain.domain.organization.repository.OrganizationLinkRepository; +import com.sponus.sponusbe.domain.organization.exception.OrganizationException; +import com.sponus.sponusbe.domain.organizationLink.dto.response.OrganizationLinkGetResponse; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class OrganizationLinkQueryService { + private final OrganizationLinkRepository organizationLinkRepository; + + public OrganizationLinkGetResponse getOrganizationLink(Long organizationLinkId) { + OrganizationLink organizationLink = organizationLinkRepository.findById(organizationLinkId) + .orElseThrow(() -> new OrganizationException(ORGANIZATION_LINK_NOT_FOUND)); + + return OrganizationLinkGetResponse.from(organizationLink); + + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/service/OrganizationLinkService.java b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/service/OrganizationLinkService.java new file mode 100644 index 00000000..690b81e5 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/organizationLink/service/OrganizationLinkService.java @@ -0,0 +1,59 @@ +package com.sponus.sponusbe.domain.organizationLink.service; + +import static com.sponus.sponusbe.domain.organization.exception.OrganizationErrorCode.*; +import static com.sponus.sponusbe.domain.organizationLink.exception.OrganizationLinkErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.OrganizationLink; +import com.sponus.coredomain.domain.organization.repository.OrganizationLinkRepository; +import com.sponus.coredomain.domain.organization.repository.OrganizationRepository; +import com.sponus.sponusbe.domain.organization.exception.OrganizationException; +import com.sponus.sponusbe.domain.organizationLink.dto.request.OrganizationLinkCreateRequest; +import com.sponus.sponusbe.domain.organizationLink.dto.request.OrganizationLinkUpdateRequest; +import com.sponus.sponusbe.domain.organizationLink.dto.response.OrganizationLinkCreateResponse; +import com.sponus.sponusbe.domain.organizationLink.exception.OrganizationLinkException; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Transactional +@Service +public class OrganizationLinkService { + private final OrganizationRepository organizationRepository; + private final OrganizationLinkRepository organizationLinkRepository; + + public OrganizationLinkCreateResponse createOrganizationLink(Long organizationId, + OrganizationLinkCreateRequest request) { + Organization organization = organizationRepository.findById(organizationId) + .orElseThrow(() -> new OrganizationException(ORGANIZATION_NOT_FOUND)); + + OrganizationLink organizationLink = OrganizationLink.builder() + .name(request.name()) + .url(request.url()) + .organization(organization) + .build(); + + organizationLink = organizationLinkRepository.save(organizationLink); + organization.getOrganizationLinks().add(organizationLink); + + return new OrganizationLinkCreateResponse(organizationLink.getId()); + } + + public void updateOrganizationLink(Long organizationLinkId, OrganizationLinkUpdateRequest request) { + OrganizationLink organizationLink = organizationLinkRepository.findById(organizationLinkId) + .orElseThrow(() -> new OrganizationLinkException(ORGANIZATION_LINK_NOT_FOUND)); + + organizationLink.update(request.name(), request.url()); + } + + public void deleteOrganizationLink(Long organizationLinkId) { + if (organizationLinkRepository.existsById(organizationLinkId)) { + organizationLinkRepository.deleteById(organizationLinkId); + } else { + throw new OrganizationLinkException(ORGANIZATION_LINK_NOT_FOUND); + } + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/controller/ProposeController.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/controller/ProposeController.java new file mode 100644 index 00000000..fbbddeb9 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/controller/ProposeController.java @@ -0,0 +1,118 @@ +package com.sponus.sponusbe.domain.propose.controller; + +import java.io.IOException; +import java.util.List; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.propose.dto.request.ProposeCreateRequest; +import com.sponus.sponusbe.domain.propose.dto.request.ProposeStatusUpdateRequest; +import com.sponus.sponusbe.domain.propose.dto.request.ProposeUpdateRequest; +import com.sponus.sponusbe.domain.propose.dto.response.DateGroupedProposeResponse; +import com.sponus.sponusbe.domain.propose.dto.response.ProposeCreateResponse; +import com.sponus.sponusbe.domain.propose.dto.response.ProposeDetailGetResponse; +import com.sponus.sponusbe.domain.propose.service.ProposeQueryService; +import com.sponus.sponusbe.domain.propose.service.ProposeService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/v1/proposes") +@RestController +public class ProposeController { + + private final ProposeService proposeService; + private final ProposeQueryService proposeQueryService; + + @PostMapping(consumes = "multipart/form-data") + public ApiResponse createPropose( + @AuthOrganization Organization authOrganization, + @RequestPart("request") @Valid ProposeCreateRequest request, + @RequestPart(value = "attachments", required = false) List attachments + ) throws IOException { + return ApiResponse.onSuccess( + proposeService.createPropose( + authOrganization, + request, + attachments == null ? List.of() : attachments + ) + ); + } + + @GetMapping("/sent") + public ApiResponse> getSentProposes( + @AuthOrganization Organization authOrganization + ) { + return ApiResponse.onSuccess(proposeQueryService.getSentProposes(authOrganization)); + } + + @GetMapping("/received") + public ApiResponse> getReceivedProposes( + @AuthOrganization Organization authOrganization, + @RequestParam(required = false) Long announcementId + ) { + return ApiResponse.onSuccess(proposeQueryService.getReceivedProposes(authOrganization, announcementId)); + } + + @GetMapping("/{proposeId}") + public ApiResponse getProposeDetail( + @AuthOrganization Organization authOrganization, + @PathVariable Long proposeId + ) { + return ApiResponse.onSuccess(proposeService.getProposeDetail(authOrganization, proposeId)); + } + + @PatchMapping(value = "/{proposeId}", consumes = "multipart/form-data") + public ApiResponse updatePropose( + @AuthOrganization Organization authOrganization, + @PathVariable Long proposeId, + @RequestPart @Valid ProposeUpdateRequest request, + @RequestPart(value = "attachments", required = false) List attachments + ) { + proposeService.updatePropose( + authOrganization, + proposeId, + request, + attachments == null ? List.of() : attachments); + return ApiResponse.onSuccess(null); + } + + @PatchMapping(value = "/{proposeId}/status") + public ApiResponse acceptPropose( + @AuthOrganization Organization authOrganization, + @PathVariable Long proposeId, + @RequestBody @Valid ProposeStatusUpdateRequest request + ) { + proposeService.updateProposeStatus( + authOrganization, + proposeId, + request); + return ApiResponse.onSuccess(null); + } + + @DeleteMapping("/{proposeId}") + public ApiResponse deletePropose( + @AuthOrganization Organization authOrganization, + @PathVariable Long proposeId + ) { + proposeService.deletePropose(authOrganization, proposeId); + return ApiResponse.onSuccess(null); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeCreateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeCreateRequest.java new file mode 100644 index 00000000..f2e880e6 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeCreateRequest.java @@ -0,0 +1,36 @@ +package com.sponus.sponusbe.domain.propose.dto.request; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.propose.ProposeStatus; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ProposeCreateRequest( + + @NotBlank(message = "[ERROR] 제안 제목 입력은 필수 입니다.") + String title, + + @NotBlank(message = "[ERROR] 제안 내용 입력은 필수 입니다.") + String content, + + @NotNull(message = "[ERROR] 제안 대상 공고 입력은 필수 입니다.") + Long announcementId +) { + public Propose toEntity( + Announcement announcement, + Organization proposedOrganization, + Organization proposingOrganization + ) { + return Propose.builder() + .title(title) + .content(content) + .status(ProposeStatus.PENDING) + .announcement(announcement) + .proposedOrganization(proposedOrganization) + .proposingOrganization(proposingOrganization) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeStatusUpdateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeStatusUpdateRequest.java new file mode 100644 index 00000000..64b27075 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeStatusUpdateRequest.java @@ -0,0 +1,9 @@ +package com.sponus.sponusbe.domain.propose.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record ProposeStatusUpdateRequest( + @NotBlank(message = "[ERROR] 제안 상태 입력은 필수 입니다.") + String status +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeUpdateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeUpdateRequest.java new file mode 100644 index 00000000..0aaf37d3 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/request/ProposeUpdateRequest.java @@ -0,0 +1,7 @@ +package com.sponus.sponusbe.domain.propose.dto.request; + +public record ProposeUpdateRequest( + String title, + String content +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/DateGroupedProposeResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/DateGroupedProposeResponse.java new file mode 100644 index 00000000..a26c63f4 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/DateGroupedProposeResponse.java @@ -0,0 +1,21 @@ +package com.sponus.sponusbe.domain.propose.dto.response; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record DateGroupedProposeResponse( + String createdDate, + List proposes +) { + public static List from(List proposes) { + final Map> groupedByDate = proposes.stream() + .collect(Collectors.groupingBy(ProposeSummaryGetResponse::createdDate)); + + // createdDate 내림차순으로 정렬된 결과를 List로 변환 + return groupedByDate.entrySet().stream() + .sorted(Map.Entry.>comparingByKey().reversed()) + .map(entry -> new DateGroupedProposeResponse(entry.getKey(), entry.getValue())) + .toList(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeAttachmentResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeAttachmentResponse.java new file mode 100644 index 00000000..b5bd9d57 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeAttachmentResponse.java @@ -0,0 +1,20 @@ +package com.sponus.sponusbe.domain.propose.dto.response; + +import com.sponus.coredomain.domain.propose.ProposeAttachment; + +import lombok.Builder; + +@Builder +public record ProposeAttachmentResponse( + Long id, + String name, + String url +) { + public static ProposeAttachmentResponse from(ProposeAttachment attachment) { + return ProposeAttachmentResponse.builder() + .id(attachment.getId()) + .name(attachment.getName()) + .url(attachment.getUrl()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeCreateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeCreateResponse.java new file mode 100644 index 00000000..88a17c14 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeCreateResponse.java @@ -0,0 +1,6 @@ +package com.sponus.sponusbe.domain.propose.dto.response; + +public record ProposeCreateResponse( + Long proposeId +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeDetailGetResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeDetailGetResponse.java new file mode 100644 index 00000000..a3b93b88 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeDetailGetResponse.java @@ -0,0 +1,52 @@ +package com.sponus.sponusbe.domain.propose.dto.response; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementDetailResponse; + +public record ProposeDetailGetResponse( + Long proposeId, + String title, + String content, + String status, + Long proposedOrganizationId, + String proposedOrganizationName, + String proposedOrganizationImage, + Long proposingOrganizationId, + String proposingOrganizationName, + String proposingOrganizationImage, + List proposeAttachmentUrl, + AnnouncementDetailResponse announcementDetails, + String createdDate, + String createdDay +) { + public static ProposeDetailGetResponse from(Propose propose) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MM.dd"); + DateTimeFormatter dayFormatter = DateTimeFormatter.ofPattern("EEE", Locale.ENGLISH); + List attachmentUrls = propose.getProposeAttachments() + .stream() + .map(ProposeAttachmentResponse::from) + .toList(); + AnnouncementDetailResponse announcementDetails = AnnouncementDetailResponse.from(propose.getAnnouncement(), + false); + return new ProposeDetailGetResponse( + propose.getId(), + propose.getTitle(), + propose.getContent(), + propose.getStatus().name(), + propose.getProposedOrganization().getId(), + propose.getProposedOrganization().getName(), + propose.getProposedOrganization().getImageUrl(), + propose.getProposingOrganization().getId(), + propose.getProposingOrganization().getName(), + propose.getProposingOrganization().getImageUrl(), + attachmentUrls, + announcementDetails, + propose.getCreatedAt().format(dateFormatter), + propose.getUpdatedAt().format(dayFormatter) + ); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeSummaryGetResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeSummaryGetResponse.java new file mode 100644 index 00000000..a31f43a8 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/dto/response/ProposeSummaryGetResponse.java @@ -0,0 +1,46 @@ +package com.sponus.sponusbe.domain.propose.dto.response; + +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.report.Report; +import com.sponus.sponusbe.domain.announcement.dto.response.AnnouncementSummaryResponse; + +public record ProposeSummaryGetResponse( + Long proposeId, + String title, + String status, + Long proposedOrganizationId, + String proposedOrganizationName, + Long proposingOrganizationId, + String proposingOrganizationName, + String proposingOrganizationImageUrl, + boolean isReported, + Long reportId, + AnnouncementSummaryResponse announcementSummary, + String createdDate, + String createdDay +) { + public static ProposeSummaryGetResponse from(Propose propose) { + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MM.dd"); + DateTimeFormatter dayFormatter = DateTimeFormatter.ofPattern("EEE", Locale.ENGLISH); + AnnouncementSummaryResponse announcementSummary = AnnouncementSummaryResponse.from(propose.getAnnouncement()); + Report report = propose.getReport(); + return new ProposeSummaryGetResponse( + propose.getId(), + propose.getTitle(), + propose.getStatus().name(), + propose.getProposedOrganization().getId(), + propose.getProposedOrganization().getName(), + propose.getProposingOrganization().getId(), + propose.getProposingOrganization().getName(), + propose.getProposingOrganization().getImageUrl(), + report != null, + report != null ? report.getId() : null, + announcementSummary, + propose.getCreatedAt().format(dateFormatter), + propose.getUpdatedAt().format(dayFormatter) + ); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/exception/ProposeErrorCode.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/exception/ProposeErrorCode.java new file mode 100644 index 00000000..50aa9b25 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/exception/ProposeErrorCode.java @@ -0,0 +1,30 @@ +package com.sponus.sponusbe.domain.propose.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ProposeErrorCode implements BaseErrorCode { + PROPOSE_ERROR(HttpStatus.BAD_REQUEST, "PROP4000", "제안 관련 에러"), + ANNOUNCEMENT_ID_IS_REQUIRED(HttpStatus.BAD_REQUEST, "PROP4001", "공고 ID가 필요합니다."), + INVALID_PROPOSING_ORGANIZATION(HttpStatus.BAD_REQUEST, "PROP4002", "해당 단체가 작성한 제안이 아닙니다."), + INVALID_PROPOSED_ORGANIZATION(HttpStatus.BAD_REQUEST, "PROP4003", "제안을 받은 단체만 접근이 가능합니다."), + PROPOSE_STATUS_NOT_PENDING(HttpStatus.BAD_REQUEST, "PROP4004", "수락 대기 중인 제안만 수정이 가능합니다."), + INVALID_PROPOSE_STATUS(HttpStatus.BAD_REQUEST, "PROP4005", "유효하지 않은 제안 상태입니다."), + PROPOSE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROP4040", "해당 제안이 존재하지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/exception/ProposeException.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/exception/ProposeException.java new file mode 100644 index 00000000..0b826393 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/exception/ProposeException.java @@ -0,0 +1,11 @@ +package com.sponus.sponusbe.domain.propose.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.sponusbe.global.exception.CustomException; + +public class ProposeException extends CustomException { + + public ProposeException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/service/ProposeQueryService.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/service/ProposeQueryService.java new file mode 100644 index 00000000..fc7086c6 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/service/ProposeQueryService.java @@ -0,0 +1,35 @@ +package com.sponus.sponusbe.domain.propose.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfradb.propose.ProposeCustomRepository; +import com.sponus.sponusbe.domain.propose.dto.response.DateGroupedProposeResponse; +import com.sponus.sponusbe.domain.propose.dto.response.ProposeSummaryGetResponse; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class ProposeQueryService { + + private final ProposeCustomRepository proposeCustomRepository; + + public List getSentProposes(Organization organization) { + return DateGroupedProposeResponse.from( + proposeCustomRepository.findSentPropose(organization.getId()).stream() + .map(ProposeSummaryGetResponse::from) + .toList()); + } + + public List getReceivedProposes(Organization organization, Long announcementId) { + return DateGroupedProposeResponse.from( + proposeCustomRepository.findReceivedProposeWithAnnouncementId(organization.getId(), announcementId).stream() + .map(ProposeSummaryGetResponse::from) + .toList()); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/propose/service/ProposeService.java b/api/src/main/java/com/sponus/sponusbe/domain/propose/service/ProposeService.java new file mode 100644 index 00000000..1b876446 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/propose/service/ProposeService.java @@ -0,0 +1,160 @@ +package com.sponus.sponusbe.domain.propose.service; + +import java.io.IOException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.repository.AnnouncementRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.propose.ProposeAttachment; +import com.sponus.coredomain.domain.propose.ProposeStatus; +import com.sponus.coredomain.domain.propose.repository.ProposeRepository; +import com.sponus.coreinfrafirebase.FirebaseService; +import com.sponus.coreinfras3.S3Service; +import com.sponus.sponusbe.domain.announcement.exception.AnnouncementErrorCode; +import com.sponus.sponusbe.domain.announcement.exception.AnnouncementException; +import com.sponus.sponusbe.domain.propose.dto.request.ProposeCreateRequest; +import com.sponus.sponusbe.domain.propose.dto.request.ProposeStatusUpdateRequest; +import com.sponus.sponusbe.domain.propose.dto.request.ProposeUpdateRequest; +import com.sponus.sponusbe.domain.propose.dto.response.ProposeCreateResponse; +import com.sponus.sponusbe.domain.propose.dto.response.ProposeDetailGetResponse; +import com.sponus.sponusbe.domain.propose.exception.ProposeErrorCode; +import com.sponus.sponusbe.domain.propose.exception.ProposeException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Transactional +@Service +public class ProposeService { + + private final ProposeRepository proposeRepository; + private final AnnouncementRepository announcementRepository; + private final S3Service s3Service; + private final FirebaseService firebaseService; + + public ProposeCreateResponse createPropose( + Organization authOrganization, + ProposeCreateRequest request, + List attachments + ) throws IOException { + // 활성화된 공고만 제안 추가 가능 + Announcement announcement = getAvailableAnnouncement(request.announcementId()); + + // 제안 생성 + final Propose propose = request.toEntity( + announcement, + announcement.getWriter(), + authOrganization + ); + + // 제안의 첨부파일 업로드 + attachments.forEach(file -> { + final String url = s3Service.uploadFile(file); + ProposeAttachment proposeAttachment = ProposeAttachment.builder() + .name(file.getOriginalFilename()) + .url(url) + .build(); + proposeAttachment.setPropose(propose); + }); + + firebaseService.sendMessageTo(announcement.getWriter(), "제안서 도착", + authOrganization.getName() + " 담당자님이 제안서를 보냈습니다.", announcement, propose, null); + + return new ProposeCreateResponse( + proposeRepository.save(propose).getId() + ); + } + + public ProposeDetailGetResponse getProposeDetail( + Organization authOrganization, + Long proposeId) { + final Propose propose = proposeRepository.findById(proposeId) + .orElseThrow(() -> new ProposeException(ProposeErrorCode.PROPOSE_NOT_FOUND)); + // 제안을 받은 단체가 조회할 경우 상태를 "VIEWED"으로 변경 + if (isProposedOrganization(authOrganization.getId(), propose)) + propose.updateToViewed(); + else if (!isProposingOrganization(authOrganization.getId(), propose)) + // 제안한 단체도 아닐 경우 조회 불가 + throw new ProposeException(ProposeErrorCode.INVALID_PROPOSING_ORGANIZATION); + + return ProposeDetailGetResponse.from(propose); + } + + public void updatePropose( + Organization authOrganization, + Long proposeId, + ProposeUpdateRequest request, + List attachments) { + final Propose propose = getUpdatablePropose(authOrganization, proposeId); + propose.updateInfo(request.title(), request.content()); + updateProposeAttachments(propose, attachments); + } + + public void deletePropose(Organization authOrganization, Long proposeId) { + final Propose propose = getUpdatablePropose(authOrganization, proposeId); + proposeRepository.delete(propose); + } + + public void updateProposeStatus(Organization authOrganization, Long proposeId, ProposeStatusUpdateRequest status) { + final Propose propose = proposeRepository.findById(proposeId) + .orElseThrow(() -> new ProposeException(ProposeErrorCode.PROPOSE_NOT_FOUND)); + // 제안을 "받은" 단체만 가능 + if (!isProposedOrganization(authOrganization.getId(), propose)) + throw new ProposeException(ProposeErrorCode.INVALID_PROPOSED_ORGANIZATION); + + try { + propose.updateStatus(ProposeStatus.of(status.status())); + } catch (Exception e) { + throw new ProposeException(ProposeErrorCode.INVALID_PROPOSE_STATUS); + } + } + + private Announcement getAvailableAnnouncement(Long announcementId) { + final Announcement announcement = announcementRepository.findById(announcementId) + .orElseThrow(() -> new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_FOUND)); + if (!announcement.isAvailable()) + throw new AnnouncementException(AnnouncementErrorCode.ANNOUNCEMENT_NOT_IN_PROGRESS); + return announcement; + } + + private Propose getUpdatablePropose(Organization organization, Long proposeId) { + final Propose propose = proposeRepository.findById(proposeId) + .orElseThrow(() -> new ProposeException(ProposeErrorCode.PROPOSE_NOT_FOUND)); + + if (!isProposingOrganization(organization.getId(), propose)) + throw new ProposeException(ProposeErrorCode.INVALID_PROPOSING_ORGANIZATION); + + if (propose.getStatus() != ProposeStatus.PENDING) + throw new ProposeException(ProposeErrorCode.PROPOSE_STATUS_NOT_PENDING); + + return propose; + } + + private boolean isProposingOrganization(Long organizationId, Propose propose) { + return propose.getProposingOrganization().getId().equals(organizationId); + } + + private boolean isProposedOrganization(Long organizationId, Propose propose) { + return propose.getProposedOrganization().getId().equals(organizationId); + } + + private void updateProposeAttachments(Propose propose, List attachments) { + propose.getProposeAttachments().clear(); + attachments.forEach(attachment -> { + final String url = s3Service.uploadFile(attachment); + ProposeAttachment proposeAttachment = ProposeAttachment.builder() + .name(attachment.getOriginalFilename()) + .url(url) + .build(); + proposeAttachment.setPropose(propose); + }); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/controller/ReportController.java b/api/src/main/java/com/sponus/sponusbe/domain/report/controller/ReportController.java new file mode 100644 index 00000000..137f4589 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/controller/ReportController.java @@ -0,0 +1,74 @@ +package com.sponus.sponusbe.domain.report.controller; + +import java.io.IOException; +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.report.dto.request.ReportCreateRequest; +import com.sponus.sponusbe.domain.report.dto.request.ReportUpdateRequest; +import com.sponus.sponusbe.domain.report.dto.response.ReportCreateResponse; +import com.sponus.sponusbe.domain.report.dto.response.ReportGetResponse; +import com.sponus.sponusbe.domain.report.dto.response.ReportUpdateResponse; +import com.sponus.sponusbe.domain.report.service.ReportQueryService; +import com.sponus.sponusbe.domain.report.service.ReportService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/reports") +public class ReportController { + + private final ReportService reportService; + private final ReportQueryService reportQueryService; + + @PostMapping(consumes = "multipart/form-data") + public ApiResponse createReport( + @AuthOrganization Organization authOrganization, + @RequestPart("request") @Valid ReportCreateRequest request, + @RequestPart(value = "images") List images, + @RequestPart(value = "attachments") List attachments) throws IOException { + return ApiResponse.onSuccess( + reportService.createReport( + authOrganization, + request, + images, + attachments)); + } + + @PatchMapping(value = "/{reportId}", consumes = "multipart/form-data") + public ApiResponse updateReport( + @AuthOrganization Organization authOrganization, + @PathVariable Long reportId, + @RequestPart("request") @Valid ReportUpdateRequest request, + @RequestPart(value = "images") List images, + @RequestPart(value = "attachments") List attachments) { + return ApiResponse.onSuccess( + reportService.updateReport( + authOrganization, + reportId, + request, + images, + attachments)); + } + + @GetMapping("/{reportId}") + public ApiResponse readReport( + @PathVariable Long reportId + ) { + return ApiResponse.onSuccess(reportQueryService.readReport(reportId)); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/dto/request/ReportCreateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/request/ReportCreateRequest.java new file mode 100644 index 00000000..91d50b7a --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/request/ReportCreateRequest.java @@ -0,0 +1,27 @@ +package com.sponus.sponusbe.domain.report.dto.request; + +import java.util.ArrayList; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.report.Report; + +import jakarta.validation.constraints.NotNull; + +public record ReportCreateRequest( + @NotNull(message = "[ERROR] 보고서 제목 입력은 필수 입니다.") + String title, + @NotNull(message = "[ERROR] 보고서 내용 입력은 필수 입니다.") + String content, + Long proposeId +) { + + public Report toEntity(Organization writer) { + return Report.builder() + .writer(writer) + .title(title) + .content(content) + .propose(null) + .reportAttachments(new ArrayList<>()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/dto/request/ReportUpdateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/request/ReportUpdateRequest.java new file mode 100644 index 00000000..9ce5cc82 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/request/ReportUpdateRequest.java @@ -0,0 +1,7 @@ +package com.sponus.sponusbe.domain.report.dto.request; + +public record ReportUpdateRequest( + String title, + String content +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportAttachmentResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportAttachmentResponse.java new file mode 100644 index 00000000..aa015ba8 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportAttachmentResponse.java @@ -0,0 +1,22 @@ +package com.sponus.sponusbe.domain.report.dto.response; + +import com.sponus.coredomain.domain.report.ReportAttachment; + +import lombok.Builder; + +@Builder +public record ReportAttachmentResponse( + Long id, + String name, + String url +) { + + public static ReportAttachmentResponse from(ReportAttachment attachment) { + return ReportAttachmentResponse.builder() + .id(attachment.getId()) + .name(attachment.getName()) + .url(attachment.getUrl()) + .build(); + } +} + diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportCreateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportCreateResponse.java new file mode 100644 index 00000000..3578e748 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportCreateResponse.java @@ -0,0 +1,23 @@ +package com.sponus.sponusbe.domain.report.dto.response; + +import com.sponus.coredomain.domain.report.Report; + +import lombok.Builder; + +@Builder +public record ReportCreateResponse( + Long id, + Long writerId, + String title, + String content +) { + + public static ReportCreateResponse from(Report report) { + return ReportCreateResponse.builder() + .id(report.getId()) + .writerId(report.getWriter().getId()) + .title(report.getTitle()) + .content(report.getContent()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportGetResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportGetResponse.java new file mode 100644 index 00000000..40cf32bf --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportGetResponse.java @@ -0,0 +1,38 @@ +package com.sponus.sponusbe.domain.report.dto.response; + +import java.util.List; + +import com.sponus.coredomain.domain.report.Report; + +import lombok.Builder; + +@Builder +public record ReportGetResponse( + Long id, + Long writerId, + String title, + String content, + List reportImages, + List reportAttachments +) { + + public static ReportGetResponse from(Report report) { + List reportImages = report.getReportImages() + .stream() + .map(ReportImageResponse::from) + .toList(); + List reportAttachments = report.getReportAttachments() + .stream() + .map(ReportAttachmentResponse::from) + .toList(); + + return ReportGetResponse.builder() + .id(report.getId()) + .writerId(report.getWriter().getId()) + .title(report.getTitle()) + .content(report.getContent()) + .reportImages(reportImages) + .reportAttachments(reportAttachments) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportImageResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportImageResponse.java new file mode 100644 index 00000000..fdfb6cee --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportImageResponse.java @@ -0,0 +1,21 @@ +package com.sponus.sponusbe.domain.report.dto.response; + +import com.sponus.coredomain.domain.report.ReportImage; + +import lombok.Builder; + +@Builder +public record ReportImageResponse( + Long id, + String name, + String url +) { + + public static ReportImageResponse from(ReportImage image) { + return ReportImageResponse.builder() + .id(image.getId()) + .name(image.getName()) + .url(image.getUrl()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportUpdateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportUpdateResponse.java new file mode 100644 index 00000000..a82a7e3e --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/dto/response/ReportUpdateResponse.java @@ -0,0 +1,23 @@ +package com.sponus.sponusbe.domain.report.dto.response; + +import com.sponus.coredomain.domain.report.Report; + +import lombok.Builder; + +@Builder +public record ReportUpdateResponse( + Long id, + Long writerId, + String title, + String content +) { + + public static ReportUpdateResponse from(Report report) { + return ReportUpdateResponse.builder() + .id(report.getId()) + .writerId(report.getWriter().getId()) + .title(report.getTitle()) + .content(report.getContent()) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/exception/ReportErrorCode.java b/api/src/main/java/com/sponus/sponusbe/domain/report/exception/ReportErrorCode.java new file mode 100644 index 00000000..39d22a0b --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/exception/ReportErrorCode.java @@ -0,0 +1,25 @@ +package com.sponus.sponusbe.domain.report.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ReportErrorCode implements BaseErrorCode { + INVALID_ORGANIZATION(HttpStatus.BAD_REQUEST, "ANC4002", "해당 단체의 보고서가 아닙니다."), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "REPORT4040", "보고서가 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/exception/ReportException.java b/api/src/main/java/com/sponus/sponusbe/domain/report/exception/ReportException.java new file mode 100644 index 00000000..75e41aca --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/exception/ReportException.java @@ -0,0 +1,11 @@ +package com.sponus.sponusbe.domain.report.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.sponusbe.global.exception.CustomException; + +public class ReportException extends CustomException { + + public ReportException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/service/ReportQueryService.java b/api/src/main/java/com/sponus/sponusbe/domain/report/service/ReportQueryService.java new file mode 100644 index 00000000..9aaf3eb9 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/service/ReportQueryService.java @@ -0,0 +1,26 @@ +package com.sponus.sponusbe.domain.report.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.report.Report; +import com.sponus.coredomain.domain.report.repository.ReportRepository; +import com.sponus.sponusbe.domain.report.dto.response.ReportGetResponse; +import com.sponus.sponusbe.domain.report.exception.ReportErrorCode; +import com.sponus.sponusbe.domain.report.exception.ReportException; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class ReportQueryService { + + private final ReportRepository reportRepository; + + public ReportGetResponse readReport(Long id) { + Report report = reportRepository.findById(id) + .orElseThrow(() -> new ReportException(ReportErrorCode.REPORT_NOT_FOUND)); + return ReportGetResponse.from(report); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/report/service/ReportService.java b/api/src/main/java/com/sponus/sponusbe/domain/report/service/ReportService.java new file mode 100644 index 00000000..fdfcc8f2 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/report/service/ReportService.java @@ -0,0 +1,111 @@ +package com.sponus.sponusbe.domain.report.service; + +import java.io.IOException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.propose.repository.ProposeRepository; +import com.sponus.coredomain.domain.report.Report; +import com.sponus.coredomain.domain.report.ReportAttachment; +import com.sponus.coredomain.domain.report.ReportImage; +import com.sponus.coredomain.domain.report.repository.ReportRepository; +import com.sponus.coreinfrafirebase.FirebaseService; +import com.sponus.coreinfras3.S3Service; +import com.sponus.sponusbe.domain.propose.exception.ProposeErrorCode; +import com.sponus.sponusbe.domain.report.dto.request.ReportCreateRequest; +import com.sponus.sponusbe.domain.report.dto.request.ReportUpdateRequest; +import com.sponus.sponusbe.domain.report.dto.response.ReportCreateResponse; +import com.sponus.sponusbe.domain.report.dto.response.ReportUpdateResponse; +import com.sponus.sponusbe.domain.report.exception.ReportErrorCode; +import com.sponus.sponusbe.domain.report.exception.ReportException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Transactional +@Service +public class ReportService { + + private final ReportRepository reportRepository; + private final ProposeRepository proposeRepository; + private final S3Service s3Service; + private final FirebaseService firebaseService; + + public ReportCreateResponse createReport( + Organization authOrganization, + ReportCreateRequest request, + List images, + List attachments + ) throws IOException { + final Report report = request.toEntity(authOrganization); + setReportImages(images, report); + setReportAttachments(attachments, report); + + final Propose propose = proposeRepository.findById(request.proposeId()) + .orElseThrow(() -> new ReportException(ProposeErrorCode.PROPOSE_NOT_FOUND)); + + report.setPropose(propose); + + firebaseService.sendMessageTo(propose.getAnnouncement().getWriter(), "보고서 도착", + authOrganization.getName() + " 담당자님이 보고서를 보냈습니다.", report.getPropose().getAnnouncement(), propose, report); + + return ReportCreateResponse.from(reportRepository.save(report)); + } + + public ReportUpdateResponse updateReport( + Organization authOrganization, + Long reportId, + ReportUpdateRequest request, + List images, + List attachments) { + final Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new ReportException(ReportErrorCode.REPORT_NOT_FOUND)); + + if (!isOrganizationsReport(authOrganization.getId(), report)) + throw new ReportException(ReportErrorCode.INVALID_ORGANIZATION); + + report.update(request.title(), request.content()); + setReportImages(images, report); + setReportAttachments(attachments, report); + + reportRepository.save(report); + return ReportUpdateResponse.from(report); + } + + private boolean isOrganizationsReport(Long organizationId, Report report) { + return report.getWriter().getId().equals(organizationId); + } + + private void setReportImages(List images, Report report) { + report.getReportImages().clear(); + images.forEach(image -> { + final String url = s3Service.uploadFile(image); + ReportImage reportImage = ReportImage.builder() + .name(image.getOriginalFilename()) + .url(url) + .build(); + reportImage.setReport(report); + }); + } + + private void setReportAttachments(List attachments, Report report) { + report.getReportAttachments().clear(); + attachments.forEach(attachment -> { + final String url = s3Service.uploadFile(attachment); + ReportAttachment reportAttachment = ReportAttachment.builder() + .name(attachment.getOriginalFilename()) + .url(url) + .build(); + reportAttachment.setReport(report); + }); + } + +} + diff --git a/api/src/main/java/com/sponus/sponusbe/domain/s3/S3TestController.java b/api/src/main/java/com/sponus/sponusbe/domain/s3/S3TestController.java new file mode 100644 index 00000000..f49d464f --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/s3/S3TestController.java @@ -0,0 +1,49 @@ +package com.sponus.sponusbe.domain.s3; + +import java.util.List; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coreinfras3.S3Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/s3") +public class S3TestController { + + private final S3Service s3Service; + + @PostMapping(value = "/uploadFile", consumes = "multipart/form-data") + public ApiResponse uploadFile(@RequestPart(value = "file", required = false) MultipartFile file) { + return ApiResponse.onSuccess(s3Service.uploadFile(file)); + } + + @PostMapping(value = "/uploadImage", consumes = "multipart/form-data") + public ApiResponse uploadImage(@RequestPart(value = "file", required = false) MultipartFile file) { + return ApiResponse.onSuccess(s3Service.uploadImage(file)); + } + + @DeleteMapping(value = "/deleteImage") + public ApiResponse deleteImage(@RequestBody String path) { + String image = path.substring(path.lastIndexOf('/') + 1); + return ApiResponse.onSuccess(s3Service.deleteFile(image)); + } + + @PostMapping(value = "/uploadImages", consumes = "multipart/form-data") + public ApiResponse> uploadImages( + @RequestPart(value = "files", required = false) List files) { + return ApiResponse.onSuccess(s3Service.uploadFiles(files)); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/controller/TagController.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/controller/TagController.java new file mode 100644 index 00000000..c5c6c8d6 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/controller/TagController.java @@ -0,0 +1,56 @@ +package com.sponus.sponusbe.domain.tag.controller; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coreinfrasecurity.annotation.AuthOrganization; +import com.sponus.sponusbe.domain.tag.dto.TagGetResponse; +import com.sponus.sponusbe.domain.tag.dto.request.TagCreateRequest; +import com.sponus.sponusbe.domain.tag.dto.request.TagUpdateRequest; +import com.sponus.sponusbe.domain.tag.dto.resposne.TagCreateResponse; +import com.sponus.sponusbe.domain.tag.service.TagQueryService; +import com.sponus.sponusbe.domain.tag.service.TagService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/v1/tags") +@RestController +public class TagController { + private final TagService tagService; + private final TagQueryService tagQueryService; + + @PostMapping + public ApiResponse createTag(@AuthOrganization Organization organization, + @RequestBody TagCreateRequest request) { + return ApiResponse.onSuccess(tagService.createTag(organization.getId(), request)); + } + + @DeleteMapping("/{tagId}") + public ApiResponse deleteTag(@PathVariable Long tagId) { + tagService.deleteTag(tagId); + return ApiResponse.onSuccess(null); + } + + @PatchMapping("/{tagId}") + public ApiResponse updateTag(@PathVariable Long tagId, @RequestBody TagUpdateRequest request) { + tagService.updateTag(tagId, request); + return ApiResponse.onSuccess(null); + } + + @GetMapping("/{tagId}") + public ApiResponse getTag(@PathVariable Long tagId) { + return ApiResponse.onSuccess(tagQueryService.getTag(tagId)); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/TagGetResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/TagGetResponse.java new file mode 100644 index 00000000..9f5ab097 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/TagGetResponse.java @@ -0,0 +1,23 @@ +package com.sponus.sponusbe.domain.tag.dto; + +import java.util.List; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.tag.Tag; + +public record TagGetResponse( + Long id, + String name +) { + public static TagGetResponse from(Tag tag) { + return new TagGetResponse(tag.getId(), tag.getName()); + } + + public static List getTagResponse(Organization organization) { + return organization.getTags() + .stream() + .map(TagGetResponse::from) + .toList(); + } + +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/request/TagCreateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/request/TagCreateRequest.java new file mode 100644 index 00000000..c434a70c --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/request/TagCreateRequest.java @@ -0,0 +1,15 @@ +package com.sponus.sponusbe.domain.tag.dto.request; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.tag.Tag; + +public record TagCreateRequest( + String name +) { + public Tag toEntity(Organization organization) { + return Tag.builder() + .name(name) + .organization(organization) + .build(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/request/TagUpdateRequest.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/request/TagUpdateRequest.java new file mode 100644 index 00000000..86bacf52 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/request/TagUpdateRequest.java @@ -0,0 +1,6 @@ +package com.sponus.sponusbe.domain.tag.dto.request; + +public record TagUpdateRequest( + String name +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/resposne/TagCreateResponse.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/resposne/TagCreateResponse.java new file mode 100644 index 00000000..687b5436 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/dto/resposne/TagCreateResponse.java @@ -0,0 +1,7 @@ +package com.sponus.sponusbe.domain.tag.dto.resposne; + +public record TagCreateResponse( + Long tagId, + Long organizationId +) { +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/exception/TagErrorCode.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/exception/TagErrorCode.java new file mode 100644 index 00000000..8608b326 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/exception/TagErrorCode.java @@ -0,0 +1,28 @@ +package com.sponus.sponusbe.domain.tag.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum TagErrorCode implements BaseErrorCode { + //TODO: 태그 에러 코드 수정 필요 + TAG_ERROR(HttpStatus.BAD_REQUEST, "ORG4000", "태그 관련 에러"), + INVALID_FORMAT(HttpStatus.BAD_REQUEST, "ORG4001", "잘못된 형식입니다."), + TAG_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "ORG4002", "중복된 태그입니다."), + TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "ORG4040", "존재하지 않는 태그입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/exception/TagException.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/exception/TagException.java new file mode 100644 index 00000000..77296750 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/exception/TagException.java @@ -0,0 +1,10 @@ +package com.sponus.sponusbe.domain.tag.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.sponusbe.global.exception.CustomException; + +public class TagException extends CustomException { + public TagException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/service/TagQueryService.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/service/TagQueryService.java new file mode 100644 index 00000000..3de6bed0 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/service/TagQueryService.java @@ -0,0 +1,28 @@ +package com.sponus.sponusbe.domain.tag.service; + +import static com.sponus.sponusbe.domain.tag.exception.TagErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.tag.Tag; +import com.sponus.coredomain.domain.tag.repository.TagRepository; +import com.sponus.sponusbe.domain.tag.dto.TagGetResponse; +import com.sponus.sponusbe.domain.tag.exception.TagException; + +import lombok.RequiredArgsConstructor; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class TagQueryService { + + private final TagRepository tagRepository; + + public TagGetResponse getTag(Long tagId) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new TagException(TAG_NOT_FOUND)); + + return TagGetResponse.from(tag); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/domain/tag/service/TagService.java b/api/src/main/java/com/sponus/sponusbe/domain/tag/service/TagService.java new file mode 100644 index 00000000..63480893 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/domain/tag/service/TagService.java @@ -0,0 +1,54 @@ +package com.sponus.sponusbe.domain.tag.service; + +import static com.sponus.sponusbe.domain.organization.exception.OrganizationErrorCode.*; +import static com.sponus.sponusbe.domain.tag.exception.TagErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.repository.OrganizationRepository; +import com.sponus.coredomain.domain.tag.Tag; +import com.sponus.coredomain.domain.tag.repository.TagRepository; +import com.sponus.sponusbe.domain.organization.exception.OrganizationException; +import com.sponus.sponusbe.domain.tag.dto.request.TagCreateRequest; +import com.sponus.sponusbe.domain.tag.dto.request.TagUpdateRequest; +import com.sponus.sponusbe.domain.tag.dto.resposne.TagCreateResponse; +import com.sponus.sponusbe.domain.tag.exception.TagException; + +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@Transactional +@RequiredArgsConstructor +@Service +public class TagService { + private final OrganizationRepository organizationRepository; + private final TagRepository tagRepository; + + public TagCreateResponse createTag(Long organizationId, TagCreateRequest request) { + Organization organization = organizationRepository.findById(organizationId) + .orElseThrow(() -> new OrganizationException(ORGANIZATION_NOT_FOUND)); + + Tag tag = request.toEntity(organization); + organization.getTags().add(tag); + tag = tagRepository.save(tag); + + return new TagCreateResponse(tag.getId(), organization.getId()); + } + + public void deleteTag(Long tagId) { + if (tagRepository.existsById(tagId)) { + tagRepository.deleteById(tagId); + } else { + throw new TagException(TAG_NOT_FOUND); + } + } + + public void updateTag(Long tagId, TagUpdateRequest request) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new TagException(TAG_NOT_FOUND)); + tag.update(request.name()); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/global/config/SchedulerConfig.java b/api/src/main/java/com/sponus/sponusbe/global/config/SchedulerConfig.java new file mode 100644 index 00000000..4ebcb405 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/global/config/SchedulerConfig.java @@ -0,0 +1,23 @@ +package com.sponus.sponusbe.global.config; + +import com.sponus.sponusbe.domain.announcement.service.AnnouncementService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.Scheduled; + +@Slf4j +@RequiredArgsConstructor +@Configuration +public class SchedulerConfig { + private final AnnouncementService announcementService; + + @Scheduled(fixedDelay = 1000L * 60) + public void updateAllViewedAnnouncementViewCount() { + announcementService.updateAllViewedAnnouncementViewCount(); + } + @Scheduled(cron = "0 0 0 * * *") + public void resetAllAnnouncementViewCount() { + announcementService.resetAllAnnouncementViewCount(); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/global/config/SwaggerConfig.java b/api/src/main/java/com/sponus/sponusbe/global/config/SwaggerConfig.java new file mode 100644 index 00000000..411eb2ab --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/global/config/SwaggerConfig.java @@ -0,0 +1,70 @@ +package com.sponus.sponusbe.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class SwaggerConfig { + // url : http://localhost:8080/swagger-ui/index.html#/ + private static final String SECURITY_SCHEME_NAME = "bearerAuth"; + + @Bean + public OpenAPI api() { + Server server = new Server().url("/"); + + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) + .components(authSetting()) + .info(getSwaggerInfo()) + .addServersItem(server); + } + + private Info getSwaggerInfo() { + License license = new License(); + license.setName("{Application}"); + + return new Info() + .title("Spon-Us API Document") + .description("Spon-Us의 API 문서 입니다.") + .version("v0.0.1") + .license(license); + } + + private Components authSetting() { + + return new Components() + .addSecuritySchemes(SECURITY_SCHEME_NAME, + new SecurityScheme() + .name(SECURITY_SCHEME_NAME) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + // return new Components() + // .addSecuritySchemes( + // "access-token", + // new SecurityScheme() + // .type(SecurityScheme.Type.HTTP) + // .scheme("Bearer") + // .bearerFormat("JWT") + // .in(SecurityScheme.In.HEADER) + // .name("Authorization")) + // .addSecuritySchemes( + // "refresh-token", + // new SecurityScheme() + // .type(SecurityScheme.Type.HTTP) + // .scheme("Bearer") + // .bearerFormat("JWT") + // .in(SecurityScheme.In.HEADER) + // .name("RefreshToken")); + } +} + + diff --git a/api/src/main/java/com/sponus/sponusbe/global/exception/CustomException.java b/api/src/main/java/com/sponus/sponusbe/global/exception/CustomException.java new file mode 100644 index 00000000..3a4821e4 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/global/exception/CustomException.java @@ -0,0 +1,15 @@ +package com.sponus.sponusbe.global.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final BaseErrorCode errorCode; + + public CustomException(BaseErrorCode errorCode) { + this.errorCode = errorCode; + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/global/exception/GlobalExceptionHandler.java b/api/src/main/java/com/sponus/sponusbe/global/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..8b03e46d --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,69 @@ +package com.sponus.sponusbe.global.exception; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.coredomain.domain.common.GlobalErrorCode; +import com.sponus.sponusbe.domain.organization.exception.OrganizationErrorCode; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler({Exception.class}) + public ResponseEntity> handleAllException(Exception e) { + log.error(">>>>> Internal Server Error : ", e); + BaseErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; + ApiResponse errorResponse = ApiResponse.onFailure( + errorCode.getCode(), + errorCode.getMessage(), + e.getMessage() + ); + return ResponseEntity.internalServerError().body(errorResponse); + } + + @ExceptionHandler({CustomException.class}) + public ResponseEntity> handleCustomException(CustomException e) { + BaseErrorCode errorCode = e.getErrorCode(); + log.warn(">>>>> Custom Exception : {}", errorCode.getMessage()); + return ResponseEntity.status(errorCode.getHttpStatus()).body(errorCode.getErrorResponse()); + } + + @ExceptionHandler({DataIntegrityViolationException.class}) + public ApiResponse handleIntegrityConstraint(DataIntegrityViolationException e) { + log.warn(">>>>> Data Integrity Violation Exception : {}", e.getMessage()); + BaseErrorCode errorStatus = OrganizationErrorCode.ORGANIZATION_ALREADY_EXIST; + return ApiResponse.onFailure( + errorStatus.getCode(), + errorStatus.getMessage() + ); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity>> handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex + ) { + // 실패한 validation 을 담을 Map + Map failedValidations = new HashMap<>(); + List fieldErrors = ex.getBindingResult().getFieldErrors(); + // fieldErrors 를 순회하며 failedValidations 에 담는다. + fieldErrors.forEach(error -> failedValidations.put(error.getField(), error.getDefaultMessage())); + ApiResponse> errorResponse = ApiResponse.onFailure( + GlobalErrorCode.VALIDATION_FAILED.getCode(), + GlobalErrorCode.VALIDATION_FAILED.getMessage(), + failedValidations); + return ResponseEntity.status(ex.getStatusCode()).body(errorResponse); + } +} diff --git a/api/src/main/java/com/sponus/sponusbe/global/util/MultipartJackson2HttpMessageConverter.java b/api/src/main/java/com/sponus/sponusbe/global/util/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 00000000..2b14dff9 --- /dev/null +++ b/api/src/main/java/com/sponus/sponusbe/global/util/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,35 @@ +package com.sponus.sponusbe.global.util; + +import java.lang.reflect.Type; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + + /** + * "Content-Type: multipart/form-data" 헤더를 지원하는 HTTP 요청 변환기 + */ + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml new file mode 100644 index 00000000..a6f4cdad --- /dev/null +++ b/api/src/main/resources/application.yml @@ -0,0 +1,14 @@ +server: + port: 8080 + +spring: + profiles: + group: + "local": "db, secret, s3, redis, email, firebase, security" + "prod": "db, secret, s3, redis, email, firebase, security" + default: local + + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB diff --git a/api/src/test/java/com/sponus/sponusbe/SponusBeApplicationTests.java b/api/src/test/java/com/sponus/sponusbe/SponusBeApplicationTests.java new file mode 100644 index 00000000..847bb0dd --- /dev/null +++ b/api/src/test/java/com/sponus/sponusbe/SponusBeApplicationTests.java @@ -0,0 +1,6 @@ +package com.sponus.sponusbe; + +// @SpringBootTest +class SponusBeApplicationTests { + +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..80915243 --- /dev/null +++ b/build.gradle @@ -0,0 +1,59 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' +} + +// 실행가능한 jar로 생성하는 옵션, main이 없는 라이브러리에서는 false로 비활성화함 +bootJar { enabled = false } + +// 외부에서 의존하기 위한 jar로 생성하는 옵션, main이 없는 라이브러리에서는 true로 비활성화함 +jar { enabled = true } + +allprojects { + + group = 'com.sponus' + version = '0.0.1-SNAPSHOT' + + java { + sourceCompatibility = '17' + targetCompatibility = '17' + } + + repositories { + mavenCentral() + maven { url 'https://jitpack.io' } + } +} + +subprojects { + apply { plugin('java') } + apply { plugin('org.springframework.boot') } + apply { plugin('io.spring.dependency-management') } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + + implementation 'org.slf4j:slf4j-api:2.0.7' + + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.0.4' + } + + tasks.named('test') { + useJUnitPlatform() + } +} diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 00000000..a2a82a8b --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,2 @@ +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-domain/build.gradle b/core/core-domain/build.gradle new file mode 100644 index 00000000..6f912178 --- /dev/null +++ b/core/core-domain/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + //querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" +} + +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-domain/src/main/java/com/sponus/CoreDomainConfig.java b/core/core-domain/src/main/java/com/sponus/CoreDomainConfig.java new file mode 100644 index 00000000..8589e7bd --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/CoreDomainConfig.java @@ -0,0 +1,15 @@ +package com.sponus; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Configuration +@EnableTransactionManagement +@EntityScan("com.sponus.coredomain") +@EnableJpaRepositories("com.sponus.coredomain") +@EnableJpaAuditing +public class CoreDomainConfig { +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/Announcement.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/Announcement.java new file mode 100644 index 00000000..9844844e --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/Announcement.java @@ -0,0 +1,116 @@ +package com.sponus.coredomain.domain.announcement; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import com.sponus.coredomain.domain.announcement.enums.AnnouncementStatus; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; +import com.sponus.coredomain.domain.bookmark.Bookmark; +import com.sponus.coredomain.domain.common.BaseEntity; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.Propose; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "announcement") +public class Announcement extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "announcement_id") + private Long id; + + @Column(name = "announcement_title", nullable = false) + private String title; + + @Column(name = "announcement_type", nullable = false) + @Enumerated(EnumType.STRING) + private AnnouncementType type; + + @Column(name = "announcement_category", nullable = false) + @Enumerated(EnumType.STRING) + private AnnouncementCategory category; + + @Column(name = "announcement_content", nullable = false) + private String content; + + @Column(name = "announcement_status", nullable = false) + @Enumerated(EnumType.STRING) + private AnnouncementStatus status; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization writer; + + @Builder.Default + @OneToMany(mappedBy = "announcement", cascade = CascadeType.ALL, orphanRemoval = true) + private List announcementImages = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "announcement", cascade = CascadeType.ALL, orphanRemoval = true) + private List bookmarks = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "announcement", cascade = CascadeType.ALL, orphanRemoval = true) + private List proposes = new ArrayList<>(); + + public void increaseViewCount() { + this.viewCount++; + } + + public void updateInfo(String title, AnnouncementType type, AnnouncementCategory category, String content) { + this.title = title == null ? this.title : title; + this.type = type == null ? this.type : type; + this.category = category == null ? this.category : category; + this.content = content == null ? this.content : content; + } + + public void updateStatus(AnnouncementStatus status) { + this.status = status; + } + + public void updateViewCount(long viewCount) { + this.viewCount = viewCount; + } + + public boolean isAvailable() { + return this.status == AnnouncementStatus.OPENED; + } + + public long getBookmarkSaveCount() { + return this.bookmarks.size(); + } + + public void setUpdatedAt(LocalDateTime now) { + this.updatedAt = now; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/AnnouncementImage.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/AnnouncementImage.java new file mode 100644 index 00000000..b289d8e6 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/AnnouncementImage.java @@ -0,0 +1,49 @@ +package com.sponus.coredomain.domain.announcement; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "announcement_image") +public class AnnouncementImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long id; + + @Column(name = "image_name", nullable = false) + private String name; + + @Column(name = "image_url", nullable = false) + private String url; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "announcement_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Announcement announcement; + + public void setAnnouncement(Announcement announcement) { + if (this.announcement != null) { + this.announcement.getAnnouncementImages().remove(this); + } + this.announcement = announcement; + announcement.getAnnouncementImages().add(this); + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementCategory.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementCategory.java new file mode 100644 index 00000000..ec9fcb18 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementCategory.java @@ -0,0 +1,5 @@ +package com.sponus.coredomain.domain.announcement.enums; + +public enum AnnouncementCategory { + IDEA, MARKETING, DESIGN, MEDIA, DEVELOPMENT, OTHER +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementStatus.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementStatus.java new file mode 100644 index 00000000..b0a0526d --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementStatus.java @@ -0,0 +1,9 @@ +package com.sponus.coredomain.domain.announcement.enums; + +public enum AnnouncementStatus { + OPENED, CLOSED; + + public static AnnouncementStatus of(String input) { + return AnnouncementStatus.valueOf(input.toUpperCase()); + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementType.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementType.java new file mode 100644 index 00000000..ef7ab010 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/enums/AnnouncementType.java @@ -0,0 +1,5 @@ +package com.sponus.coredomain.domain.announcement.enums; + +public enum AnnouncementType { + SPONSORSHIP, PARTNERSHIP, COLLABORATION +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/repository/AnnouncementRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/repository/AnnouncementRepository.java new file mode 100644 index 00000000..ac8d6ffd --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/announcement/repository/AnnouncementRepository.java @@ -0,0 +1,31 @@ +package com.sponus.coredomain.domain.announcement.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementCategory; +import com.sponus.coredomain.domain.announcement.enums.AnnouncementType; + +public interface AnnouncementRepository extends JpaRepository { + + List findByWriterIdOrderByCreatedAtDesc(Long writerId); + + List findByTitleContains(String title); + + // List findByStatus(AnnouncementStatus status); + + List findByCategoryAndTypeOrderByCreatedAtDesc(AnnouncementCategory category, AnnouncementType type); + + List findByCategoryOrderByCreatedAtDesc(AnnouncementCategory category); + + List findByTypeOrderByCreatedAtDesc(AnnouncementType type); + + @Query("SELECT a FROM Announcement a WHERE a.status='OPENED' ORDER BY a.viewCount DESC LIMIT 10") + List findTop10OrderByViewCountDesc(); + + @Query("SELECT a FROM Announcement a WHERE a.status='OPENED' ORDER BY RANDOM() LIMIT 10") + List findOrderByRandom(); +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/Bookmark.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/Bookmark.java new file mode 100644 index 00000000..e57e5970 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/Bookmark.java @@ -0,0 +1,57 @@ +package com.sponus.coredomain.domain.bookmark; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.common.BaseEntity; +import com.sponus.coredomain.domain.organization.Organization; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "bookmark") +public class Bookmark extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "bookmark_id") + private Long id; + + @Column(name = "save_count", nullable = false) + private long saveCount; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization organization; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "announcement_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Announcement announcement; + + public void increaseSaveCount() { + this.saveCount++; + } + + public void decreaseSaveCount() { + if (this.saveCount > 0) { + this.saveCount--; + } + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/BookmarkStatus.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/BookmarkStatus.java new file mode 100644 index 00000000..be7b912a --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/BookmarkStatus.java @@ -0,0 +1,5 @@ +package com.sponus.coredomain.domain.bookmark; + +public enum BookmarkStatus { + RECENT, VIEWED, SAVED +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/repository/BookmarkRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/repository/BookmarkRepository.java new file mode 100644 index 00000000..0787adea --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,22 @@ +package com.sponus.coredomain.domain.bookmark.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.bookmark.Bookmark; + +public interface BookmarkRepository extends JpaRepository { + + Optional findByOrganizationAndAnnouncement(Organization organization, Announcement announcement); + + List findByOrganizationOrderByCreatedAtDesc(Organization organization); + + List findByOrganizationOrderByAnnouncementViewCountDesc(Organization organization); + + List findByOrganizationOrderBySaveCountDesc(Organization organization); + +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/ApiResponse.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/ApiResponse.java new file mode 100644 index 00000000..7756b3ef --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/ApiResponse.java @@ -0,0 +1,50 @@ +package com.sponus.coredomain.domain.common; + +import org.springframework.http.HttpStatus; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"statusCode", "message", "content"}) +public class ApiResponse { + + @JsonProperty("statusCode") + @NonNull + private final String statusCode; + + @JsonProperty("message") + @NonNull + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty("content") + private T content; + + // 성공한 경우 응답 생성 + public static ApiResponse onSuccess(T content) { + return new ApiResponse<>(HttpStatus.OK.name(), HttpStatus.OK.getReasonPhrase(), content); + } + + // 실패한 경우 응답 생성 + public static ApiResponse onFailure(String statusCode, String message) { + return new ApiResponse<>(statusCode, message, null); + } + + public static ApiResponse onFailure(String statusCode, String message, T content) { + return new ApiResponse<>(statusCode, message, content); + } + + // Json serialize + public String toJsonString() throws JsonProcessingException { + return new ObjectMapper().writeValueAsString(this); + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/BaseEntity.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/BaseEntity.java new file mode 100644 index 00000000..42b34463 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/BaseEntity.java @@ -0,0 +1,23 @@ +package com.sponus.coredomain.domain.common; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + protected LocalDateTime updatedAt; +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/BaseErrorCode.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/BaseErrorCode.java new file mode 100644 index 00000000..0a29b162 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/BaseErrorCode.java @@ -0,0 +1,14 @@ +package com.sponus.coredomain.domain.common; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + + HttpStatus getHttpStatus(); + + String getCode(); + + String getMessage(); + + ApiResponse getErrorResponse(); +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/GlobalErrorCode.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/GlobalErrorCode.java new file mode 100644 index 00000000..80352597 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/common/GlobalErrorCode.java @@ -0,0 +1,23 @@ +package com.sponus.coredomain.domain.common; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum GlobalErrorCode implements BaseErrorCode { + VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "ERROR4000", "입력값에 대한 검증에 실패했습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "ERROR5000", "서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } + +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/notification/Notification.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/notification/Notification.java new file mode 100644 index 00000000..cd64e810 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/notification/Notification.java @@ -0,0 +1,90 @@ +package com.sponus.coredomain.domain.notification; + +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.common.BaseEntity; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.report.Report; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@DynamicInsert +@DynamicUpdate +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "notification") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notification_id") + private Long id; + + @Column(name = "notification_title", nullable = false) + private String title; + + @Column(name = "notification_body", nullable = false) + private String body; + + @Column(name = "notification_is_read", nullable = false) + @ColumnDefault("false") + private boolean isRead; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization organization; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "announcement_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Announcement announcement; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "propose_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Propose propose; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "report_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Report report; + + public void setOrganization(Organization organization) { + this.organization = organization; + } + + public void setAnnouncement(Announcement announcement) { + this.announcement = announcement; + } + + public void setPropose(Propose propose) { + this.propose = propose; + } + + public void setReport(Report report) { + this.report = report; + } + + public void setRead(boolean read) { + isRead = read; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/notification/repository/NotificationRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..ff93580c --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,12 @@ +package com.sponus.coredomain.domain.notification.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.notification.Notification; +import com.sponus.coredomain.domain.organization.Organization; + +public interface NotificationRepository extends JpaRepository { + List findByOrganizationOrderByCreatedAtDesc(Organization organization); +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/Organization.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/Organization.java new file mode 100644 index 00000000..5401c066 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/Organization.java @@ -0,0 +1,146 @@ +package com.sponus.coredomain.domain.organization; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.ColumnDefault; + +import com.sponus.coredomain.domain.common.BaseEntity; +import com.sponus.coredomain.domain.organization.enums.OrganizationStatus; +import com.sponus.coredomain.domain.organization.enums.OrganizationType; +import com.sponus.coredomain.domain.organization.enums.SuborganizationType; +import com.sponus.coredomain.domain.tag.Tag; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "organization") +public class Organization extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "organization_id") + private Long id; + + @Column(name = "organization_name", nullable = false) + private String name; + + @Column(name = "organization_email", nullable = false, unique = true) + private String email; + + @Column(name = "organization_password", nullable = false) + private String password; + + @Column(name = "organization_location") + private String location; + + @Column(name = "organization_description") + private String description; + + @Column(name = "organization_image_url") + private String imageUrl; + + @Column(name = "organization_type", nullable = false) + @Enumerated(EnumType.STRING) + private OrganizationType organizationType; + + @Column(name = "suborganization_type") + @Enumerated(EnumType.STRING) + private SuborganizationType suborganizationType; + + @Column(name = "manager_name") + private String managerName; + + @Column(name = "manager_position") + private String managerPosition; + + @Column(name = "manager_email") + private String managerEmail; + + @Column(name = "manager_phone") + private String managerPhone; + + @Column(name = "manager_available_day") + private String managerAvailableDay; + + @Column(name = "manager_available_hour") + private String managerAvailableHour; + + @Column(name = "manager_contact_preference") + private String managerContactPreference; + + @Column(name = "notifications_enabled") + @ColumnDefault("false") + private boolean notificationsEnabled; + + @Column(name = "organization_status", nullable = false) + @Enumerated(EnumType.STRING) + private OrganizationStatus organizationStatus; + + @Builder.Default + @OneToMany(mappedBy = "organization", fetch = FetchType.EAGER) + private List tags = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "organization", fetch = FetchType.EAGER) + private List organizationLinks = new ArrayList<>(); + + public boolean isStudentOrganization() { + return this.organizationType == OrganizationType.STUDENT; + } + + public void update(String name, String email, String password, String location, String description, + OrganizationType organizationType, SuborganizationType suborganizationType, String managerName, + String managerPosition, String managerEmail, String managerPhone, String managerAvailableDay, + String managerAvailableHour, String managerContactPreference + ) { + this.name = name == null ? this.name : name; + this.email = email == null ? this.email : email; + this.password = password == null ? this.password : password; + this.location = location == null ? this.location : location; + this.description = description == null ? this.description : description; + this.organizationType = organizationType == null ? this.organizationType : organizationType; + this.suborganizationType = + suborganizationType == null ? this.suborganizationType : suborganizationType; + this.managerName = managerName == null ? this.managerName : managerName; + this.managerPosition = managerPosition == null ? this.managerPosition : managerPosition; + this.managerEmail = managerEmail == null ? this.managerEmail : managerEmail; + this.managerPhone = managerPhone == null ? this.managerPhone : managerPhone; + this.managerAvailableDay = + managerAvailableDay == null ? this.managerAvailableDay : managerAvailableDay; + this.managerAvailableHour = + managerAvailableHour == null ? this.managerAvailableHour : managerAvailableHour; + this.managerContactPreference = managerContactPreference == null ? this.managerContactPreference : + managerContactPreference; + } + + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public void deactivate() { + this.organizationStatus = OrganizationStatus.INACTIVE; + } + + public void activate() { + this.organizationStatus = OrganizationStatus.ACTIVE; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/OrganizationLink.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/OrganizationLink.java new file mode 100644 index 00000000..f0537016 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/OrganizationLink.java @@ -0,0 +1,47 @@ +package com.sponus.coredomain.domain.organization; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "organization_link") +public class OrganizationLink { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "link_id") + private Long id; + + @Column(name = "link_name", nullable = false) + private String name; + + @Column(name = "link_url", nullable = false) + private String url; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization organization; + + public void update(String name, String url) { + this.name = name == null ? this.name : name; + this.url = url == null ? this.url : url; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/OrganizationStatus.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/OrganizationStatus.java new file mode 100644 index 00000000..05f5a847 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/OrganizationStatus.java @@ -0,0 +1,7 @@ +package com.sponus.coredomain.domain.organization.enums; + +public enum OrganizationStatus { + ACTIVE, + INACTIVE, + ; +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/OrganizationType.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/OrganizationType.java new file mode 100644 index 00000000..472a30f3 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/OrganizationType.java @@ -0,0 +1,27 @@ +package com.sponus.coredomain.domain.organization.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum OrganizationType { + + STUDENT("STUDENT"), + COMPANY("COMPANY"), + ; + + private final String value; + + @JsonCreator + public static OrganizationType from(String ipt) { + for (OrganizationType groupValue : OrganizationType.values()) { + if (groupValue.getValue().equals(ipt)) { + return groupValue; + } + } + return null; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/SuborganizationType.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/SuborganizationType.java new file mode 100644 index 00000000..e428a414 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/enums/SuborganizationType.java @@ -0,0 +1,28 @@ +package com.sponus.coredomain.domain.organization.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum SuborganizationType { + + STUDENT_COUNCIL("STUDENT_COUNCIL", "Student Council"), + CLUB("CLUB", "Club"), + ; + + private final String value; + private final String name; + + @JsonCreator + public static SuborganizationType from(String ipt) { + for (SuborganizationType subGroupValue : SuborganizationType.values()) { + if (subGroupValue.getValue().equals(ipt)) { + return subGroupValue; + } + } + return null; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/repository/OrganizationLinkRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/repository/OrganizationLinkRepository.java new file mode 100644 index 00000000..74a43033 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/repository/OrganizationLinkRepository.java @@ -0,0 +1,8 @@ +package com.sponus.coredomain.domain.organization.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.organization.OrganizationLink; + +public interface OrganizationLinkRepository extends JpaRepository { +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/repository/OrganizationRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/repository/OrganizationRepository.java new file mode 100644 index 00000000..2c6f701e --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/organization/repository/OrganizationRepository.java @@ -0,0 +1,15 @@ +package com.sponus.coredomain.domain.organization.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.organization.Organization; + +public interface OrganizationRepository extends JpaRepository { + + Optional findOrganizationByEmail(String email); + + List findByNameContains(String name); +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/Propose.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/Propose.java new file mode 100644 index 00000000..117a55e1 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/Propose.java @@ -0,0 +1,100 @@ +package com.sponus.coredomain.domain.propose; + +import java.util.ArrayList; +import java.util.List; + +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.common.BaseEntity; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.report.Report; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "propose") +public class Propose extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "propose_id") + private Long id; + + @Column(name = "propose_title", nullable = false) + private String title; + + @Column(name = "propose_content", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "propose_status", nullable = false) + private ProposeStatus status; + + @Column(name = "imp_uid") + private String impUid; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "announcement_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Announcement announcement; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "proposed_organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization proposedOrganization; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "proposing_organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization proposingOrganization; + + @OneToOne(mappedBy = "propose", fetch = FetchType.EAGER) + private Report report; + + @Builder.Default + @OneToMany(mappedBy = "propose", cascade = CascadeType.ALL, orphanRemoval = true) + private List proposeAttachments = new ArrayList<>(); + + public void updateToViewed() { + if (this.status == ProposeStatus.PENDING) + this.status = ProposeStatus.VIEWED; + } + + public void updateInfo(String title, String content) { + this.title = title == null ? this.title : title; + this.content = content == null ? this.content : content; + } + + public void updateStatus(ProposeStatus status) { + this.status = status == null ? this.status : status; + } + + public void updateToPaid(String impUid) { + this.status = ProposeStatus.PAID; + this.impUid = impUid; + } + + public boolean isPaid() { + return this.status == ProposeStatus.PAID; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeAttachment.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeAttachment.java new file mode 100644 index 00000000..db8216a2 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeAttachment.java @@ -0,0 +1,50 @@ +package com.sponus.coredomain.domain.propose; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "propose_attachment") +public class ProposeAttachment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attachment_id") + private Long id; + + @Column(name = "file_name", nullable = false) + private String name; + + @Column(name = "file_url", nullable = false) + private String url; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "propose_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Propose propose; + + public void setPropose(Propose propose) { + if (this.propose != null) { + this.propose.getProposeAttachments().remove(this); + } + this.propose = propose; + propose.getProposeAttachments().add(this); + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeImage.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeImage.java new file mode 100644 index 00000000..e14f1902 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeImage.java @@ -0,0 +1,49 @@ +package com.sponus.coredomain.domain.propose; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "propose_image") +public class ProposeImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long id; + + @Column(name = "image_name", nullable = false) + private String name; + + @Column(name = "image_url", nullable = false) + private String url; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "propose_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Propose propose; + + // public void setPropose(Propose propose) { + // if (this.propose != null) { + // this.propose.getProposeImages().remove(this); + // } + // this.propose = propose; + // propose.getProposeImages().add(this); + // } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeStatus.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeStatus.java new file mode 100644 index 00000000..35670bad --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/ProposeStatus.java @@ -0,0 +1,11 @@ +package com.sponus.coredomain.domain.propose; + +public enum ProposeStatus { + // 제안을 보낸 측: PENDING + // 제안을 받은 측: VIEWED, ACCEPTED, REJECTED, SUSPENDED, PAID + PENDING, VIEWED, ACCEPTED, REJECTED, SUSPENDED, PAID, COMPLETED; + + public static ProposeStatus of(String input) { + return ProposeStatus.valueOf(input.toUpperCase()); + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeAttachmentRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeAttachmentRepository.java new file mode 100644 index 00000000..3b4c65ff --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeAttachmentRepository.java @@ -0,0 +1,9 @@ +package com.sponus.coredomain.domain.propose.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.propose.ProposeAttachment; + +public interface ProposeAttachmentRepository extends JpaRepository { + +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeImageRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeImageRepository.java new file mode 100644 index 00000000..81e09a64 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeImageRepository.java @@ -0,0 +1,9 @@ +package com.sponus.coredomain.domain.propose.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.propose.ProposeImage; + +public interface ProposeImageRepository extends JpaRepository { + +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeRepository.java new file mode 100644 index 00000000..aa17c105 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/propose/repository/ProposeRepository.java @@ -0,0 +1,14 @@ +package com.sponus.coredomain.domain.propose.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.propose.Propose; + +public interface ProposeRepository extends JpaRepository { + + Optional findByImpUid(String impUid); + + Optional findByProposingOrganizationIdAndAnnouncementId(Long proposingOrganizationId, Long announcementId); +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/Report.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/Report.java new file mode 100644 index 00000000..1177f83e --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/Report.java @@ -0,0 +1,77 @@ +package com.sponus.coredomain.domain.report; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.Propose; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@DynamicUpdate +@DynamicInsert +@Entity +@Table(name = "report") +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; + + @Column(name = "report_title", nullable = false) + private String title; + + @Column(name = "report_content", nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization writer; + + @OneToOne + @JoinColumn(name = "propose_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Propose propose; + + @Builder.Default + @OneToMany(mappedBy = "report", cascade = CascadeType.ALL, orphanRemoval = true) + private List reportImages = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "report", cascade = CascadeType.ALL) + private List reportAttachments = new ArrayList<>(); + + public void setPropose(Propose propose) { + this.propose = propose; + } + + public void update(String title, String content) { + this.title = title == null ? this.title : title; + this.content = content == null ? this.content : content; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/ReportAttachment.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/ReportAttachment.java new file mode 100644 index 00000000..ff9f601a --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/ReportAttachment.java @@ -0,0 +1,50 @@ +package com.sponus.coredomain.domain.report; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "report_attachment") +public class ReportAttachment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attachment_id") + private Long id; + + @Column(name = "file_name", nullable = false) + private String name; + + @Column(name = "file_url", nullable = false) + private String url; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "report_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Report report; + + public void setReport(Report report) { + if (this.report != null) { + this.report.getReportAttachments().remove(this); + } + this.report = report; + report.getReportAttachments().add(this); + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/ReportImage.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/ReportImage.java new file mode 100644 index 00000000..1552d038 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/ReportImage.java @@ -0,0 +1,49 @@ +package com.sponus.coredomain.domain.report; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Entity +@Table(name = "report_image") +public class ReportImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long id; + + @Column(name = "image_name", nullable = false) + private String name; + + @Column(name = "image_url", nullable = false) + private String url; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "report_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Report report; + + public void setReport(Report report) { + if (this.report != null) { + this.report.getReportImages().remove(this); + } + this.report = report; + report.getReportImages().add(this); + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/repository/ReportRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/repository/ReportRepository.java new file mode 100644 index 00000000..567e5d2e --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/report/repository/ReportRepository.java @@ -0,0 +1,8 @@ +package com.sponus.coredomain.domain.report.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.report.Report; + +public interface ReportRepository extends JpaRepository { +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/tag/Tag.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/tag/Tag.java new file mode 100644 index 00000000..462ec735 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/tag/Tag.java @@ -0,0 +1,45 @@ +package com.sponus.coredomain.domain.tag; + +import com.sponus.coredomain.domain.organization.Organization; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Entity +@Table(name = "tag") +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "tag_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Organization organization; + + @Column(name = "tag_name", nullable = false) + private String name; + + public void update(String name) { + this.name = name; + } +} diff --git a/core/core-domain/src/main/java/com/sponus/coredomain/domain/tag/repository/TagRepository.java b/core/core-domain/src/main/java/com/sponus/coredomain/domain/tag/repository/TagRepository.java new file mode 100644 index 00000000..967e2953 --- /dev/null +++ b/core/core-domain/src/main/java/com/sponus/coredomain/domain/tag/repository/TagRepository.java @@ -0,0 +1,8 @@ +package com.sponus.coredomain.domain.tag.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sponus.coredomain.domain.tag.Tag; + +public interface TagRepository extends JpaRepository { +} diff --git a/core/core-infra-db/build.gradle b/core/core-infra-db/build.gradle new file mode 100644 index 00000000..b35f3d38 --- /dev/null +++ b/core/core-infra-db/build.gradle @@ -0,0 +1,16 @@ +dependencies { + + implementation project(':core:core-domain'); + + // postgresql + runtimeOnly 'org.postgresql:postgresql' + + //querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" +} + +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-infra-db/out/production/resources/application-db.yml b/core/core-infra-db/out/production/resources/application-db.yml new file mode 100644 index 00000000..e126dc4f --- /dev/null +++ b/core/core-infra-db/out/production/resources/application-db.yml @@ -0,0 +1,41 @@ +spring: + config: + activate: + on-profile: local + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/postgres + username: postgres + password: postgres! + jpa: + database: postgresql + hibernate: + ddl-auto: create-drop + open-in-view: false + show-sql: true + generate-ddl: true + defer-datasource-initialization: true + + sql: + init: + mode: always + +--- + +spring: + config: + activate: + on-profile: prod + datasource: + driver-class-name: org.postgresql.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PW} + + jpa: + database: postgresql + hibernate: + ddl-auto: update + open-in-view: false + show-sql: true + generate-ddl: true diff --git a/core/core-infra-db/out/production/resources/data.sql b/core/core-infra-db/out/production/resources/data.sql new file mode 100644 index 00000000..ccb5a27b --- /dev/null +++ b/core/core-infra-db/out/production/resources/data.sql @@ -0,0 +1,62 @@ +INSERT INTO organization (organization_name, + organization_email, + organization_password, + organization_location, + organization_type, + suborganization_type, + manager_name, + manager_position, + manager_email, + manager_phone, + manager_available_day, + manager_available_hour, + manager_contact_preference, + organization_status, + organization_image_url) +VALUES ('팀 스포너스', + 'sponus@gmail.com', + '$2a$10$tzrzG/BSFrrye7Kbm4qPYuP6jaQcj5TX5ER1.X/THqkudiSjtEmWW', + '서울특별시 강남구 테헤란로 427', + 'STUDENT', + 'STUDENT_COUNCIL', + '이가은', + 'Project Manager', + 'test@gmail.com', + '01012345678', + '월-금', + '09:00-18:00', + 'EMAIL', + 'ACTIVE', + 'https://sponus.s3.ap-northeast-2.amazonaws.com/images/sponus.png'); + +INSERT INTO announcement (announcement_title, + announcement_type, + announcement_category, + announcement_content, + announcement_status, + view_count, + organization_id, + created_at, + updated_at) +VALUES ('무신사 스폰서십', + 'SPONSORSHIP', + 'MARKETING', + '무신사 스폰서십을 진행할 대학교 학생회를 모집합니다.', + 'OPENED', + 0, + 1, + '2024-02-12 15:29:19.000000', + '2024-02-12 15:29:19.000000'); + +INSERT INTO tag (organization_id, tag_name) +VALUES (1, '#무신사'), + (1, '#스폰서쉽'); + + + +INSERT INTO announcement_image (image_name, + image_url, + announcement_id) +VALUES ('무신사 스폰서십', + 'https://sponus.s3.ap-northeast-2.amazonaws.com/images/sponus.png', + 1); diff --git a/core/core-infra-db/src/main/java/com/sponus/coreinfradb/propose/ProposeCustomRepository.java b/core/core-infra-db/src/main/java/com/sponus/coreinfradb/propose/ProposeCustomRepository.java new file mode 100644 index 00000000..68262a2e --- /dev/null +++ b/core/core-infra-db/src/main/java/com/sponus/coreinfradb/propose/ProposeCustomRepository.java @@ -0,0 +1,13 @@ +package com.sponus.coreinfradb.propose; + +import java.util.List; + +import com.sponus.coredomain.domain.propose.Propose; + +public interface ProposeCustomRepository { + + List findSentPropose(Long id); + + List findReceivedProposeWithAnnouncementId(Long organizationId, Long announcementId); + +} diff --git a/core/core-infra-db/src/main/java/com/sponus/coreinfradb/propose/ProposeCustomRepositoryImpl.java b/core/core-infra-db/src/main/java/com/sponus/coreinfradb/propose/ProposeCustomRepositoryImpl.java new file mode 100644 index 00000000..b8079de9 --- /dev/null +++ b/core/core-infra-db/src/main/java/com/sponus/coreinfradb/propose/ProposeCustomRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.sponus.coreinfradb.propose; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.propose.QPropose; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Repository +public class ProposeCustomRepositoryImpl implements ProposeCustomRepository { + + private final EntityManager entityManager; + + @Override + + public List findSentPropose(Long id) { + QPropose p = QPropose.propose; + + JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); + + return queryFactory.selectFrom(p) + .where(p.proposingOrganization.id.eq(id)) + .leftJoin(p.proposingOrganization).fetchJoin() + .fetch(); + } + + @Override + public List findReceivedProposeWithAnnouncementId(Long organizationId, Long announcementId) { + QPropose p = QPropose.propose; + + JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); + + BooleanBuilder whereClause = new BooleanBuilder(); + whereClause.and(p.proposedOrganization.id.eq(organizationId)); + + if (announcementId != null) { + whereClause.and(p.announcement.id.eq(announcementId)); + } + + return queryFactory.selectFrom(p) + .where(whereClause) + .orderBy(p.createdAt.desc()) + .leftJoin(p.proposedOrganization).fetchJoin() + .leftJoin(p.announcement) + .fetch(); + } +} diff --git a/core/core-infra-db/src/main/resources/application-db.yml b/core/core-infra-db/src/main/resources/application-db.yml new file mode 100644 index 00000000..562732ea --- /dev/null +++ b/core/core-infra-db/src/main/resources/application-db.yml @@ -0,0 +1,39 @@ +spring.config.activate.on-profile: local + +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/postgres + username: postgres + password: postgres! + jpa: + database: postgresql + hibernate: + ddl-auto: create-drop + open-in-view: false + show-sql: true + generate-ddl: true + defer-datasource-initialization: true + + sql: + init: + mode: always + data-locations: classpath:db/data.sql + # schema-locations: classpath:db/schema.sql + +--- +spring.config.activate.on-profile: prod + +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + database: postgresql + hibernate: + ddl-auto: validate + open-in-view: false + show-sql: true diff --git a/core/core-infra-db/src/main/resources/db/data.sql b/core/core-infra-db/src/main/resources/db/data.sql new file mode 100644 index 00000000..ccb5a27b --- /dev/null +++ b/core/core-infra-db/src/main/resources/db/data.sql @@ -0,0 +1,62 @@ +INSERT INTO organization (organization_name, + organization_email, + organization_password, + organization_location, + organization_type, + suborganization_type, + manager_name, + manager_position, + manager_email, + manager_phone, + manager_available_day, + manager_available_hour, + manager_contact_preference, + organization_status, + organization_image_url) +VALUES ('팀 스포너스', + 'sponus@gmail.com', + '$2a$10$tzrzG/BSFrrye7Kbm4qPYuP6jaQcj5TX5ER1.X/THqkudiSjtEmWW', + '서울특별시 강남구 테헤란로 427', + 'STUDENT', + 'STUDENT_COUNCIL', + '이가은', + 'Project Manager', + 'test@gmail.com', + '01012345678', + '월-금', + '09:00-18:00', + 'EMAIL', + 'ACTIVE', + 'https://sponus.s3.ap-northeast-2.amazonaws.com/images/sponus.png'); + +INSERT INTO announcement (announcement_title, + announcement_type, + announcement_category, + announcement_content, + announcement_status, + view_count, + organization_id, + created_at, + updated_at) +VALUES ('무신사 스폰서십', + 'SPONSORSHIP', + 'MARKETING', + '무신사 스폰서십을 진행할 대학교 학생회를 모집합니다.', + 'OPENED', + 0, + 1, + '2024-02-12 15:29:19.000000', + '2024-02-12 15:29:19.000000'); + +INSERT INTO tag (organization_id, tag_name) +VALUES (1, '#무신사'), + (1, '#스폰서쉽'); + + + +INSERT INTO announcement_image (image_name, + image_url, + announcement_id) +VALUES ('무신사 스폰서십', + 'https://sponus.s3.ap-northeast-2.amazonaws.com/images/sponus.png', + 1); diff --git a/core/core-infra-db/src/main/resources/db/schema.sql b/core/core-infra-db/src/main/resources/db/schema.sql new file mode 100644 index 00000000..c0ca5e5a --- /dev/null +++ b/core/core-infra-db/src/main/resources/db/schema.sql @@ -0,0 +1,156 @@ +CREATE TABLE organization +( + organization_id BIGSERIAL NOT NULL, + created_at TIMESTAMP(6), + updated_at TIMESTAMP(6), + organization_description VARCHAR(255), + organization_email VARCHAR(255) NOT NULL, + organization_image_url VARCHAR(255), + organization_location VARCHAR(255), + manager_available_day VARCHAR(255), + manager_available_hour VARCHAR(255), + manager_contact_preference VARCHAR(255), + manager_email VARCHAR(255), + manager_name VARCHAR(255), + manager_phone VARCHAR(255), + manager_position VARCHAR(255), + organization_name VARCHAR(255) NOT NULL, + notifications_enabled BOOLEAN DEFAULT FALSE, + organization_status VARCHAR(255) NOT NULL CHECK (organization_status IN ('ACTIVE', 'INACTIVE')), + organization_type VARCHAR(255) NOT NULL CHECK (organization_type IN ('STUDENT', 'COMPANY')), + organization_password VARCHAR(255) NOT NULL, + suborganization_type VARCHAR(255) CHECK (suborganization_type IN ('STUDENT_COUNCIL', 'CLUB')), + PRIMARY KEY (organization_id) +); + +CREATE TABLE organization_link +( + link_id BIGSERIAL NOT NULL, + link_name VARCHAR(255) NOT NULL, + link_url VARCHAR(255) NOT NULL, + organization_id BIGINT NOT NULL, + PRIMARY KEY (link_id) +); + +CREATE TABLE announcement +( + announcement_id BIGSERIAL NOT NULL, + created_at TIMESTAMP(6), + updated_at TIMESTAMP(6), + announcement_category VARCHAR(255) NOT NULL CHECK (announcement_category IN + ('IDEA', 'MARKETING', 'DESIGN', 'MEDIA', 'DEVELOPMENT', + 'OTHER')), + announcement_content VARCHAR(255) NOT NULL, + announcement_status VARCHAR(255) NOT NULL CHECK (announcement_status IN ('OPENED', 'CLOSED')), + announcement_title VARCHAR(255) NOT NULL, + announcement_type VARCHAR(255) NOT NULL CHECK (announcement_type IN ('SPONSORSHIP', 'PARTNERSHIP', 'COLLABORATION')), + view_count BIGINT NOT NULL, + organization_id BIGINT, + PRIMARY KEY (announcement_id) +); + +CREATE TABLE announcement_image +( + image_id BIGSERIAL NOT NULL, + image_name VARCHAR(255) NOT NULL, + image_url VARCHAR(255) NOT NULL, + announcement_id BIGINT NOT NULL, + PRIMARY KEY (image_id) +); + +CREATE TABLE bookmark +( + bookmark_id BIGSERIAL NOT NULL, + created_at TIMESTAMP(6), + updated_at TIMESTAMP(6), + save_count BIGINT NOT NULL, + announcement_id BIGINT NOT NULL, + organization_id BIGINT, + PRIMARY KEY (bookmark_id) +); + +CREATE TABLE notification +( + notification_id BIGSERIAL NOT NULL, + created_at TIMESTAMP(6), + updated_at TIMESTAMP(6), + notification_body VARCHAR(255) NOT NULL, + notification_is_read BOOLEAN DEFAULT FALSE NOT NULL, + notification_title VARCHAR(255) NOT NULL, + announcement_id BIGINT, + organization_id BIGINT, + propose_id BIGINT, + report_id BIGINT, + PRIMARY KEY (notification_id) +); + +CREATE TABLE propose +( + propose_id BIGSERIAL NOT NULL, + created_at TIMESTAMP(6), + updated_at TIMESTAMP(6), + propose_content VARCHAR(255) NOT NULL, + imp_uid VARCHAR(255), + propose_status VARCHAR(255) NOT NULL CHECK (propose_status IN + ('PENDING', 'VIEWED', 'ACCEPTED', 'REJECTED', 'SUSPENDED', + 'PAID', 'COMPLETED')), + propose_title VARCHAR(255) NOT NULL, + announcement_id BIGINT, + proposed_organization_id BIGINT, + proposing_organization_id BIGINT, + PRIMARY KEY (propose_id) +); + +CREATE TABLE propose_attachment +( + attachment_id BIGSERIAL NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_url VARCHAR(255) NOT NULL, + propose_id BIGINT NOT NULL, + PRIMARY KEY (attachment_id) +); + +CREATE TABLE propose_image +( + image_id BIGSERIAL NOT NULL, + image_name VARCHAR(255) NOT NULL, + image_url VARCHAR(255) NOT NULL, + propose_id BIGINT NOT NULL, + PRIMARY KEY (image_id) +); + +CREATE TABLE report +( + report_id BIGSERIAL NOT NULL, + report_content VARCHAR(255) NOT NULL, + report_title VARCHAR(255) NOT NULL, + propose_id BIGINT, + organization_id BIGINT, + PRIMARY KEY (report_id) +); + +CREATE TABLE report_attachment +( + attachment_id BIGSERIAL NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_url VARCHAR(255) NOT NULL, + report_id BIGINT NOT NULL, + PRIMARY KEY (attachment_id) +); + +CREATE TABLE report_image +( + image_id BIGSERIAL NOT NULL, + image_name VARCHAR(255) NOT NULL, + image_url VARCHAR(255) NOT NULL, + report_id BIGINT NOT NULL, + PRIMARY KEY (image_id) +); + +CREATE TABLE tag +( + tag_id BIGSERIAL NOT NULL, + tag_name VARCHAR(255) NOT NULL, + organization_id BIGINT, + PRIMARY KEY (tag_id) +); diff --git a/core/core-infra-email/build.gradle b/core/core-infra-email/build.gradle new file mode 100644 index 00000000..a87592dd --- /dev/null +++ b/core/core-infra-email/build.gradle @@ -0,0 +1,7 @@ +dependencies { + // email + implementation 'org.springframework.boot:spring-boot-starter-mail' +} + +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-infra-email/src/main/java/com/sponus/coreinfraemail/EmailConfig.java b/core/core-infra-email/src/main/java/com/sponus/coreinfraemail/EmailConfig.java new file mode 100644 index 00000000..a00a329a --- /dev/null +++ b/core/core-infra-email/src/main/java/com/sponus/coreinfraemail/EmailConfig.java @@ -0,0 +1,54 @@ +package com.sponus.coreinfraemail; + +import java.util.Properties; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +public class EmailConfig { + + @Value("${mail.smtp.port}") + private int port; + @Value("${mail.smtp.auth}") + private boolean auth; + @Value("${mail.smtp.starttls.enable}") + private boolean starttls; + @Value("${mail.smtp.starttls.required}") + private boolean startllsRequired; + + @Value("${mail.smtp.socketFactory.class}") + private String socketFactory; + @Value("${mail.smtp.socketFactory.fallback}") + private boolean fallback; + @Value("${AdminMail.id}") + private String id; + @Value("${AdminMail.password}") + private String password; + + @Bean + public JavaMailSender javaMailService() { + JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); + javaMailSender.setHost("smtp.gmail.com"); + javaMailSender.setUsername(id); + javaMailSender.setPassword(password); + javaMailSender.setPort(port); + javaMailSender.setJavaMailProperties(getMailProperties()); + javaMailSender.setDefaultEncoding("UTF-8"); + return javaMailSender; + } + + private Properties getMailProperties() { + Properties pt = new Properties(); + pt.put("mail.smtp.socketFactory.port", port); + pt.put("mail.smtp.auth", auth); + pt.put("mail.smtp.starttls.enable", starttls); + pt.put("mail.smtp.starttls.required", startllsRequired); + pt.put("mail.smtp.socketFactory.fallback", fallback); + pt.put("mail.smtp.socketFactory.class", socketFactory); + return pt; + } +} diff --git a/core/core-infra-email/src/main/java/com/sponus/coreinfraemail/EmailUtil.java b/core/core-infra-email/src/main/java/com/sponus/coreinfraemail/EmailUtil.java new file mode 100644 index 00000000..8f66d70a --- /dev/null +++ b/core/core-infra-email/src/main/java/com/sponus/coreinfraemail/EmailUtil.java @@ -0,0 +1,76 @@ +package com.sponus.coreinfraemail; + +import java.util.Random; + +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +import jakarta.mail.Message; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class EmailUtil { + + private final JavaMailSender emailSender; + + private static final Random RANDOM = new Random(); + + public String sendEmail(String to) throws Exception { + String code = createEmailCode(); + MimeMessage message = createEmail(to, code); + emailSender.send(message); + return code; + } + + private MimeMessage createEmail(String to, String code) throws Exception { + log.info("보내는 대상 : " + to); + log.info("인증 코드 : " + code); + MimeMessage message = emailSender.createMimeMessage(); + InternetAddress[] recipients = {new InternetAddress(to)}; + message.setRecipients(Message.RecipientType.TO, recipients); // 보내는 대상 + message.setSubject("Spon-us 인증 코드 발급입니다."); // 제목 + + String msgg = ""; + msgg += "
"; + msgg += "

Spon-us 인증 코드입니다.

"; // 동적으로 메시지 내용 변경 + msgg += "
"; + msgg += "
"; + msgg += "

인증 코드

"; + msgg += "
"; + msgg += "인증 코드 : "; + msgg += code + "

"; + msgg += "
"; + message.setText(msgg, "utf-8", "html"); // 내용 + message.setFrom(new InternetAddress("Spon-us_team", "Spon-us")); // 보내는 사람 + + return message; + } + + public static String createEmailCode() { + StringBuilder code = new StringBuilder(); + + for (int i = 0; i < 6; i++) { // 인증 코드 6자리 + int index = RANDOM.nextInt(3); // 0~2 까지 랜덤 + switch (index) { + case 0: + code.append((char)((RANDOM.nextInt(26)) + 97)); + // a~z (ex. 1+97=98 => (char)98 = 'b') + break; + case 1: + code.append((char)((RANDOM.nextInt(26)) + 65)); + // A~Z + break; + default: + code.append((RANDOM.nextInt(10))); + // 0~9 + break; + } + } + return code.toString(); + } +} diff --git a/core/core-infra-email/src/main/resources/application-email.yml b/core/core-infra-email/src/main/resources/application-email.yml new file mode 100644 index 00000000..996e7cbc --- /dev/null +++ b/core/core-infra-email/src/main/resources/application-email.yml @@ -0,0 +1,14 @@ +mail: + smtp: + port: ${EMAIL_PORT} + auth: true + starttls: + required: true + enable: true + socketFactory: + class: javax.net.ssl.SSLSocketFactory + fallback: false + +AdminMail: + id: ${ADMIN_EMAIL_ID} + password: ${ADMIN_EMAIL_PASSWORD} diff --git a/core/core-infra-firebase/build.gradle b/core/core-infra-firebase/build.gradle new file mode 100644 index 00000000..b0b5337b --- /dev/null +++ b/core/core-infra-firebase/build.gradle @@ -0,0 +1,13 @@ +dependencies { + implementation project(':core:core-domain'); + implementation project(':core:core-infra-redis'); + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // FCM + implementation 'com.google.firebase:firebase-admin:9.2.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' +} + +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FcmMessage.java b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FcmMessage.java new file mode 100644 index 00000000..7643e093 --- /dev/null +++ b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FcmMessage.java @@ -0,0 +1,46 @@ +package com.sponus.coreinfrafirebase; + +import com.sponus.coredomain.domain.notification.Notification; + +import lombok.Builder; + +@Builder +public record FcmMessage( + boolean validateOnly, + Message message +) { + public static FcmMessage of(boolean validateOnly, Message message) { + return FcmMessage.builder() + .validateOnly(validateOnly) + .message(message) + .build(); + } + + @Builder + public record Message( + String token, + NotificationSummary notification + ) { + public static Message of(String token, NotificationSummary notificationSummary) { + return Message.builder() + .token(token) + .notification(notificationSummary) + .build(); + } + } + + @Builder + public record NotificationSummary( + String title, + String body, + String image + ) { + public static NotificationSummary from(Notification notification) { + return NotificationSummary.builder() + .title(notification.getTitle()) + .body(notification.getBody()) + .image(null) + .build(); + } + } +} diff --git a/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FirebaseService.java b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FirebaseService.java new file mode 100644 index 00000000..4b4b6a58 --- /dev/null +++ b/core/core-infra-firebase/src/main/java/com/sponus/coreinfrafirebase/FirebaseService.java @@ -0,0 +1,126 @@ +package com.sponus.coreinfrafirebase; + +import java.io.IOException; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import com.sponus.coredomain.domain.announcement.Announcement; +import com.sponus.coredomain.domain.notification.Notification; +import com.sponus.coredomain.domain.notification.repository.NotificationRepository; +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.propose.Propose; +import com.sponus.coredomain.domain.report.Report; +import com.sponus.coreinfraredis.util.RedisUtil; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FirebaseService { + + @Value("${firebase.fcmUrl}") + private String fcmUrl; + + @Value("${firebase.firebaseConfigPath}") + private String firebaseConfigPath; + + @Value("${firebase.scope}") + private String scope; + + private final ObjectMapper objectMapper; + + private final NotificationRepository notificationRepository; + + private final RedisUtil redisUtil; + + public void sendMessageTo(Organization targetOrganization, String title, String body, Announcement announcement, + Propose propose, Report report) throws IOException { + + String token = getFcmToken(targetOrganization.getEmail()); + + Notification notification = Notification.builder() + .title(title) + .body(body) + .build(); + + notification.setOrganization(targetOrganization); + notification.setAnnouncement(announcement); + notification.setPropose(propose); + notification.setReport(report); + + String message = makeFcmMessage(token, notificationRepository.save(notification)); + + OkHttpClient client = new OkHttpClient(); + RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8")); + + Request request = new Request.Builder() + .url(fcmUrl) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json;charset=UTF-8") + .build(); + + log.info("Sending FCM request. URL: {}, Headers: {}, Body: {}", fcmUrl, request.headers(), message); + + Response response = client.newCall(request) + .execute(); + + // Response error + String responseBodyString = response.body().string(); + log.info("Notification ResponseBody : {} ", responseBodyString); + int codeIndex = responseBodyString.indexOf("\"code\":"); + int messageIndex = responseBodyString.indexOf("\"message\":"); + + if (codeIndex != -1 && messageIndex != -1) { + + String responseErrorCode = responseBodyString.substring(codeIndex + "\"code\":".length(), + responseBodyString.indexOf(',', codeIndex)); + responseErrorCode = responseErrorCode.trim(); + + String responseErrorMessage = responseBodyString.substring(messageIndex + "\"message\":".length(), + responseBodyString.indexOf(',', messageIndex)); + responseErrorMessage = responseErrorMessage.trim(); + + log.info("[*]Error Code: " + responseErrorCode); + log.info("[*]Error Message: " + responseErrorMessage); + } else { + // Response 정상 + log.info("[*]Error Code or Message not found"); + } + } + + private String makeFcmMessage(String token, Notification notification) throws JsonProcessingException { + FcmMessage fcmMessage = FcmMessage.of(false, + FcmMessage.Message.of(token, + FcmMessage.NotificationSummary.from(notification))); + + log.info("Notification : {}", fcmMessage.message().notification().toString()); + + return objectMapper.writeValueAsString(fcmMessage); + } + + private String getAccessToken() throws IOException { + GoogleCredentials googleCredentials = GoogleCredentials.fromStream(new ClassPathResource(firebaseConfigPath) + .getInputStream()).createScoped(List.of(scope)); + googleCredentials.refreshIfExpired(); + return googleCredentials.getAccessToken().getTokenValue(); + } + + public String getFcmToken(String email) { + return (String)redisUtil.get(email + "_fcm_token"); + } +} diff --git a/core/core-infra-firebase/src/main/resources/application-firebase.yml b/core/core-infra-firebase/src/main/resources/application-firebase.yml new file mode 100644 index 00000000..b26e2b75 --- /dev/null +++ b/core/core-infra-firebase/src/main/resources/application-firebase.yml @@ -0,0 +1,4 @@ +firebase: + fcmUrl: ${FIREBASE_URL} + firebaseConfigPath: ${FIREBASE_CONFIG_PATH} + scope: https://www.googleapis.com/auth/cloud-platform diff --git a/core/core-infra-redis/build.gradle b/core/core-infra-redis/build.gradle new file mode 100644 index 00000000..0a1f49b4 --- /dev/null +++ b/core/core-infra-redis/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-redis' +} + +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/CoreRedisConfig.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/CoreRedisConfig.java new file mode 100644 index 00000000..9c0286b8 --- /dev/null +++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/CoreRedisConfig.java @@ -0,0 +1,9 @@ +package com.sponus.coreinfraredis; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +@Configuration +@EnableRedisRepositories("com.sponus.coreinfraredis") +public class CoreRedisConfig { +} diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/config/RedisConfig.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/config/RedisConfig.java new file mode 100644 index 00000000..59931911 --- /dev/null +++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/config/RedisConfig.java @@ -0,0 +1,33 @@ +package com.sponus.coreinfraredis.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/entity/AnnouncementView.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/entity/AnnouncementView.java new file mode 100644 index 00000000..4329966a --- /dev/null +++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/entity/AnnouncementView.java @@ -0,0 +1,35 @@ +package com.sponus.coreinfraredis.entity; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@RedisHash("announcementView") +public class AnnouncementView { + @Id + private String announcementId; + @Builder.Default + private Set organizationIds = new HashSet<>(); + + @TimeToLive + @Builder.Default + private Long expiration = Duration.between(LocalDateTime.now(), LocalDate.now().plusDays(1).atStartOfDay()) + .getSeconds(); +} diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/repository/AnnouncementViewRepository.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/repository/AnnouncementViewRepository.java new file mode 100644 index 00000000..3538334d --- /dev/null +++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/repository/AnnouncementViewRepository.java @@ -0,0 +1,9 @@ +package com.sponus.coreinfraredis.repository; + +import org.springframework.data.repository.CrudRepository; + +import com.sponus.coreinfraredis.entity.AnnouncementView; + +public interface AnnouncementViewRepository extends CrudRepository { + +} diff --git a/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/util/RedisUtil.java b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/util/RedisUtil.java new file mode 100644 index 00000000..f22c3d65 --- /dev/null +++ b/core/core-infra-redis/src/main/java/com/sponus/coreinfraredis/util/RedisUtil.java @@ -0,0 +1,56 @@ +package com.sponus.coreinfraredis.util; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RedisUtil { + + private final RedisTemplate redisTemplate; + + public void saveAsValue(String key, Object val, Long time, TimeUnit timeUnit) { + redisTemplate.opsForValue().set(key, val, time, timeUnit); + } + + public void appendToRecentlyViewedAnnouncement(String key, String newValue) { + long RECENT_VIEWED_ANNOUNCEMENT_LIMIT = 20; + + log.info("[*] Newly Viewed Announcement: " + newValue); + Object mostRecentlyViewedValue = redisTemplate.opsForList().index(key, 0); + if (Objects.equals(mostRecentlyViewedValue, newValue)) { + log.info("[*] Skip saving viewed history..."); + return; + } + if (Objects.equals(redisTemplate.opsForList().size(key), RECENT_VIEWED_ANNOUNCEMENT_LIMIT)) { + log.info("[*] Recent Announcement Deque Full Capacity.."); + log.info("[*] Del Top()"); + redisTemplate.opsForList().rightPop(key); + } + redisTemplate.opsForList().leftPush(key, newValue); + } + + public boolean hasKey(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + public List getList(String key) { + return redisTemplate.opsForList().range(key, 0, -1); + } + + public boolean delete(String key) { + return Boolean.TRUE.equals(redisTemplate.delete(key)); + } +} diff --git a/core/core-infra-redis/src/main/resources/application-redis.yml b/core/core-infra-redis/src/main/resources/application-redis.yml new file mode 100644 index 00000000..15079fdb --- /dev/null +++ b/core/core-infra-redis/src/main/resources/application-redis.yml @@ -0,0 +1,5 @@ +spring: + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} diff --git a/core/core-infra-s3/build.gradle b/core/core-infra-s3/build.gradle new file mode 100644 index 00000000..6835552e --- /dev/null +++ b/core/core-infra-s3/build.gradle @@ -0,0 +1,8 @@ +dependencies { + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'com.sksamuel.scrimage:scrimage-core:4.1.1' + implementation 'com.sksamuel.scrimage:scrimage-webp:4.1.1' +} + +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-infra-s3/out/production/resources/application-s3.yml b/core/core-infra-s3/out/production/resources/application-s3.yml new file mode 100644 index 00000000..4266c83d --- /dev/null +++ b/core/core-infra-s3/out/production/resources/application-s3.yml @@ -0,0 +1,13 @@ +# S3 +cloud: + aws: + s3: + bucket: ${BUCKET} + folder: images/ + stack: + auto: false + region: + static: ${REGION} + credentials: + accessKey: ${S3_ACCESS_KEY} + secretKey: ${S3_SECRET_KEY} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/S3Config.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/S3Config.java new file mode 100644 index 00000000..48abe1cc --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/S3Config.java @@ -0,0 +1,55 @@ +package com.sponus.coreinfras3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; + +@Configuration +@Getter +public class S3Config { + + private AWSCredentials awsCredentials; + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.s3.folder}") + private String folder; + + @PostConstruct + public void init() { + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + } + + @Bean + public AmazonS3 amazonS3() { + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider() { + return new AWSStaticCredentialsProvider(awsCredentials); + } +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/S3Service.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/S3Service.java new file mode 100644 index 00000000..b9a01f9c --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/S3Service.java @@ -0,0 +1,77 @@ +package com.sponus.coreinfras3; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.sponus.coreinfras3.convert.webp.WebpConvertService; +import com.sponus.coreinfras3.util.FileUtils; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + + private final AmazonS3 amazonS3; + + private final S3Config s3Config; + + private final WebpConvertService webpConvertService; + + public String uploadImage(MultipartFile multipartFile) { + File file = FileUtils.convertToFile(multipartFile); + return uploadImage(file); + } + + public String uploadImage(File file) { + File webpFile = webpConvertService.convertToWebP(file); + return uploadFile(webpFile); + } + + public String uploadFile(MultipartFile multipartFile) { + File file = FileUtils.convertToFile(multipartFile); + return uploadFile(file); + } + + public String uploadFile(File file) { + String fileName = getFileNamePrefix() + file.getName(); + + amazonS3.putObject(new PutObjectRequest(s3Config.getBucket(), s3Config.getFolder() + fileName, file)); + try { + Files.delete(file.toPath()); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to delete file", e); + } + + return amazonS3.getUrl(s3Config.getBucket(), s3Config.getFolder() + fileName).toString(); + } + + public String deleteFile(String image) { + try { + amazonS3.deleteObject(s3Config.getBucket(), s3Config.getFolder() + image); + } catch (Exception e) { + log.error("error at AmazonS3Manager deleteFile : {}", (Object)e.getStackTrace()); + } + return "삭제 성공"; + } + + public List uploadFiles(List files) { + return files.stream() + .map(this::uploadFile) + .toList(); + } + + private String getFileNamePrefix() { + return UUID.randomUUID().toString().substring(0, 5) + "-"; + } +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/ImageFormat.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/ImageFormat.java new file mode 100644 index 00000000..f823cd07 --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/ImageFormat.java @@ -0,0 +1,13 @@ +package com.sponus.coreinfras3.convert; + +public enum ImageFormat { + JPEG, JPG, PNG, GIF, WEBP; + + public static ImageFormat of(String format) { + try { + return ImageFormat.valueOf(format.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unsupported image format: " + format); + } + } +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/Jpg2WebpStrategy.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/Jpg2WebpStrategy.java new file mode 100644 index 00000000..f20a5a9f --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/Jpg2WebpStrategy.java @@ -0,0 +1,37 @@ +package com.sponus.coreinfras3.convert.webp; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import org.springframework.stereotype.Service; + +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; +import com.sponus.coreinfras3.convert.ImageFormat; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class Jpg2WebpStrategy implements WebpConvertStrategy { + + @Override + public boolean identify(ImageFormat imageFormat) { + return imageFormat == ImageFormat.JPG || imageFormat == ImageFormat.JPEG; + } + + @Override + public File convert(String fileName, File file, WebpCompressionParam param) { + try { + File webpFile = ImmutableImage.loader() + .fromFile(file) + .output(WebpWriter.DEFAULT, new File(fileName + ".webp")); + Files.delete(file.toPath()); + return webpFile; + } catch (IOException e) { + log.error("JPG -> WebP conversion failed, cancelling conversion. {}", e.getMessage()); + return file; + } + } +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/Png2WebpStrategy.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/Png2WebpStrategy.java new file mode 100644 index 00000000..57cb633e --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/Png2WebpStrategy.java @@ -0,0 +1,37 @@ +package com.sponus.coreinfras3.convert.webp; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import org.springframework.stereotype.Service; + +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; +import com.sponus.coreinfras3.convert.ImageFormat; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class Png2WebpStrategy implements WebpConvertStrategy { + + @Override + public boolean identify(ImageFormat imageFormat) { + return ImageFormat.PNG.equals(imageFormat); + } + + @Override + public File convert(String fileName, File file, WebpCompressionParam param) { + try { + File webpFile = ImmutableImage.loader() + .fromFile(file) + .output(WebpWriter.DEFAULT, new File(fileName + ".webp")); + Files.delete(file.toPath()); + return webpFile; + } catch (IOException e) { + log.error("PNG -> WebP conversion failed, cancelling conversion. {}", e.getMessage()); + return file; + } + } +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpCompressionParam.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpCompressionParam.java new file mode 100644 index 00000000..54d4d8e3 --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpCompressionParam.java @@ -0,0 +1,7 @@ +package com.sponus.coreinfras3.convert.webp; + +public class WebpCompressionParam { + Integer q = -1; // RGB 채널 압축 여부 ( 0 ~ 6 ) + Integer m = -1; // 압축 방식 지정 ( 0 ~ 6 ), 높을 수록 고효율 압축 + Boolean lossless = false; // 손실 or 무손실 압축 여부 +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertService.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertService.java new file mode 100644 index 00000000..2c7d8ddb --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertService.java @@ -0,0 +1,31 @@ +package com.sponus.coreinfras3.convert.webp; + +import java.io.File; + +import org.springframework.stereotype.Service; + +import com.sponus.coreinfras3.convert.ImageFormat; +import com.sponus.coreinfras3.util.FileUtils; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class WebpConvertService { + + private final WebpConvertStrategyLocator strategyLocator; + + // @Async(AsyncConfig.WEBP_CONVERSION) // 비동기 처리. 이 부분은 AsyncConfig에 정의된 상수를 사용 + public File convertToWebP(File file) { + String fileName = FileUtils.removeExtension(file.getName()); + String extension = file.getName().substring(file.getName().lastIndexOf(".") + 1); + + ImageFormat format = ImageFormat.of(extension); + if (format == null) { + throw new IllegalArgumentException("Unsupported image format: " + extension); + } + + WebpConvertStrategy convertStrategy = strategyLocator.getStrategy(format); + return convertStrategy.convert(fileName, file, new WebpCompressionParam()); + } +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertStrategy.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertStrategy.java new file mode 100644 index 00000000..91e31350 --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertStrategy.java @@ -0,0 +1,11 @@ +package com.sponus.coreinfras3.convert.webp; + +import java.io.File; + +import com.sponus.coreinfras3.convert.ImageFormat; + +public interface WebpConvertStrategy { + boolean identify(ImageFormat imageFormat); + + File convert(String fileName, File file, WebpCompressionParam param); +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertStrategyLocator.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertStrategyLocator.java new file mode 100644 index 00000000..254d41c2 --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/convert/webp/WebpConvertStrategyLocator.java @@ -0,0 +1,24 @@ +package com.sponus.coreinfras3.convert.webp; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.sponus.coreinfras3.convert.ImageFormat; + +@Service +public class WebpConvertStrategyLocator { + + private final List webpConvertStrategy; + + public WebpConvertStrategyLocator(List webpConvertStrategy) { + this.webpConvertStrategy = webpConvertStrategy; + } + + public WebpConvertStrategy getStrategy(ImageFormat imageFormat) { + return webpConvertStrategy.stream() + .filter(service -> service.identify(imageFormat)) + .findFirst() + .orElseThrow(IllegalArgumentException::new); + } +} diff --git a/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/util/FileUtils.java b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/util/FileUtils.java new file mode 100644 index 00000000..75745fdf --- /dev/null +++ b/core/core-infra-s3/src/main/java/com/sponus/coreinfras3/util/FileUtils.java @@ -0,0 +1,49 @@ +package com.sponus.coreinfras3.util; + +import java.io.File; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class FileUtils { + + private static final String TEMP_DIR_PATH = "tmp/"; + + public static String getExtension(String fileName) { + if (fileName == null) { + throw new IllegalArgumentException("fileName cannot be null"); + } + int index = fileName.lastIndexOf("."); + if (index == -1) { + throw new IllegalArgumentException("fileName does not have an extension"); + } + return fileName.substring(index + 1); + } + + public static String removeExtension(String fileName) { + if (fileName == null) { + throw new IllegalArgumentException("fileName cannot be null"); + } + int index = fileName.lastIndexOf("."); + if (index == -1) { + throw new IllegalArgumentException("fileName does not have an extension"); + } + return fileName.substring(0, index); + } + + public static File convertToFile(MultipartFile multipartFile) { + String tempDir = System.getProperty("java.io.tmpdir"); + File file = new File(tempDir + multipartFile.getOriginalFilename()); + log.info("File path: {}", file.getAbsolutePath()); + try { + multipartFile.transferTo(file); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to convert multipart file to file", e); + } + return file; + } +} diff --git a/core/core-infra-s3/src/main/resources/application-s3.yml b/core/core-infra-s3/src/main/resources/application-s3.yml new file mode 100644 index 00000000..3ef14f6d --- /dev/null +++ b/core/core-infra-s3/src/main/resources/application-s3.yml @@ -0,0 +1,14 @@ +# S3 +cloud: + aws: + s3: + bucket: ${S3_BUCKET} + folder: images/ + stack: + auto: false + region: + static: ${AWS_REGION} + credentials: + accessKey: ${S3_ACCESS_KEY} + secretKey: ${S3_SECRET_KEY} + diff --git a/core/core-infra-security/build.gradle b/core/core-infra-security/build.gradle new file mode 100644 index 00000000..8667cd53 --- /dev/null +++ b/core/core-infra-security/build.gradle @@ -0,0 +1,15 @@ +dependencies { + implementation project(':core:core-domain'); + implementation project(':core:core-infra-redis'); + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' +} + +bootJar { enabled = false } +jar { enabled = true } diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/annotation/AuthOrganization.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/annotation/AuthOrganization.java new file mode 100644 index 00000000..5a9a3ab2 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/annotation/AuthOrganization.java @@ -0,0 +1,14 @@ +package com.sponus.coreinfrasecurity.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.Parameter; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Parameter(hidden = true) +public @interface AuthOrganization { +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/annotation/AuthOrganizationArgumentResolver.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/annotation/AuthOrganizationArgumentResolver.java new file mode 100644 index 00000000..97f18561 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/annotation/AuthOrganizationArgumentResolver.java @@ -0,0 +1,51 @@ +package com.sponus.coreinfrasecurity.annotation; + +import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.repository.OrganizationRepository; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityCustomException; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityErrorCode; +import com.sponus.coreinfrasecurity.user.CustomUserDetails; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@Transactional +public class AuthOrganizationArgumentResolver implements HandlerMethodArgumentResolver { + + private final OrganizationRepository organizationRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasParameterAnnotation = parameter.hasParameterAnnotation(AuthOrganization.class); + boolean isOrganizationParameterType = parameter.getParameterType().isAssignableFrom(Organization.class); + return hasParameterAnnotation && isOrganizationParameterType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Object userDetails = SecurityContextHolder.getContext() + .getAuthentication() + .getPrincipal(); + + if (userDetails instanceof String) { + log.error("userDetails is String"); + throw new SecurityCustomException(SecurityErrorCode.TOKEN_NOT_FOUND); + } + + return organizationRepository.findById(((CustomUserDetails)userDetails).getId()) + .orElseThrow(() -> new SecurityCustomException(SecurityErrorCode.TOKEN_ORGANIZATION_NOT_FOND)); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/CorsConfig.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/CorsConfig.java new file mode 100644 index 00000000..0e426f34 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/CorsConfig.java @@ -0,0 +1,36 @@ +package com.sponus.coreinfrasecurity.config; + +import java.util.ArrayList; + +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CorsConfig implements WebMvcConfigurer { + + public static CorsConfigurationSource apiConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + ArrayList allowedOriginPatterns = new ArrayList<>(); + allowedOriginPatterns.add("http://localhost:8080"); + allowedOriginPatterns.add("http://localhost:3000"); + // allowedOriginPatterns.add("http://13.209.60.211:8080"); + + ArrayList allowedHttpMethods = new ArrayList<>(); + allowedHttpMethods.add("GET"); + allowedHttpMethods.add("POST"); + + configuration.setAllowedOrigins(allowedOriginPatterns); + configuration.setAllowedMethods(allowedHttpMethods); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/SecurityConfig.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/SecurityConfig.java new file mode 100644 index 00000000..5883ac4f --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/SecurityConfig.java @@ -0,0 +1,138 @@ +package com.sponus.coreinfrasecurity.config; + +import java.util.Arrays; +import java.util.stream.Stream; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.sponus.coreinfraredis.util.RedisUtil; +import com.sponus.coreinfrasecurity.jwt.exception.JwtAccessDeniedHandler; +import com.sponus.coreinfrasecurity.jwt.exception.JwtAuthenticationEntryPoint; +import com.sponus.coreinfrasecurity.jwt.filter.CustomLoginFilter; +import com.sponus.coreinfrasecurity.jwt.filter.CustomLogoutHandler; +import com.sponus.coreinfrasecurity.jwt.filter.JwtAuthenticationFilter; +import com.sponus.coreinfrasecurity.jwt.filter.JwtExceptionFilter; +import com.sponus.coreinfrasecurity.jwt.util.HttpResponseUtil; +import com.sponus.coreinfrasecurity.jwt.util.JwtUtil; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + private final String[] swaggerUrls = {"/swagger-ui/**", "/v3/**"}; + private final String[] authUrls = { + "/", + "/payment", + "/api/v1/organizations/join/**", + "/api/v1/organizations/login/**", + "/api/v1/organizations/email/**", + "/api/v1/report/**", + "/api/v1/s3/**", + "/api/v1/payments/**", + "/api/v1/auth/reissue" + }; + private final String[] allowedUrls = Stream.concat(Arrays.stream(swaggerUrls), Arrays.stream(authUrls)) + .toArray(String[]::new); + + private final AuthenticationConfiguration authenticationConfiguration; + private final JwtUtil jwtUtil; + private final RedisUtil redisUtil; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .cors(cors -> cors + .configurationSource(CorsConfig.apiConfigurationSource())); + + // csrf disable + http + .csrf(AbstractHttpConfigurer::disable); + + // form 로그인 방식 disable + http + .formLogin(AbstractHttpConfigurer::disable); + + // http basic 인증 방식 disable + http + .httpBasic(AbstractHttpConfigurer::disable); + + // 경로별 인가 작업 + http + .authorizeHttpRequests(auth -> auth + .requestMatchers(allowedUrls).permitAll() + .requestMatchers("/**").authenticated() + .anyRequest().permitAll() + ); + + // Jwt Filter (with login) + CustomLoginFilter loginFilter = new CustomLoginFilter( + authenticationManager(authenticationConfiguration), jwtUtil, redisUtil + ); + loginFilter.setFilterProcessesUrl("/api/v1/organizations/login"); + + http + .addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class); + + http + .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, redisUtil), CustomLoginFilter.class); + + http + .addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class); + + http + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ); + + // 세션 사용 안함 + http + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + + // Logout Filter + http + .logout(logout -> logout + .logoutUrl("/api/v1/organizations/logout") + .addLogoutHandler(new CustomLogoutHandler(redisUtil, jwtUtil)) + .logoutSuccessHandler((request, response, authentication) -> + HttpResponseUtil.setSuccessResponse( + response, + HttpStatus.OK, + "로그아웃 성공" + ) + ) + ); + + return http.build(); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/WebMvcConfig.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/WebMvcConfig.java new file mode 100644 index 00000000..98e11cfd --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/config/WebMvcConfig.java @@ -0,0 +1,23 @@ +package com.sponus.coreinfrasecurity.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.sponus.coreinfrasecurity.annotation.AuthOrganizationArgumentResolver; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthOrganizationArgumentResolver authOrganizationArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authOrganizationArgumentResolver); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/dto/JwtPair.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/dto/JwtPair.java new file mode 100644 index 00000000..03ea1348 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/dto/JwtPair.java @@ -0,0 +1,7 @@ +package com.sponus.coreinfrasecurity.jwt.dto; + +public record JwtPair( + String accessToken, + String refreshToken +) { +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/JwtAccessDeniedHandler.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..b9b33087 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/JwtAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package com.sponus.coreinfrasecurity.jwt.exception; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.sponus.coreinfrasecurity.jwt.util.HttpResponseUtil; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 인증된 사용자가 필요한 권한없이 접근하려고 할 때 발생하는 예외 처리 + */ +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + log.warn("Access Denied: ", accessDeniedException); + + HttpResponseUtil.setErrorResponse(response, HttpStatus.FORBIDDEN, + SecurityErrorCode.FORBIDDEN.getErrorResponse()); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/JwtAuthenticationEntryPoint.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..1ff2347c --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/JwtAuthenticationEntryPoint.java @@ -0,0 +1,40 @@ +package com.sponus.coreinfrasecurity.jwt.exception; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coreinfrasecurity.jwt.util.HttpResponseUtil; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 사용자가 인증되지 않은 상태에서 접근하려고 할 때 발생하는 예외 처리 + */ +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) + throws IOException { + HttpStatus httpStatus; + ApiResponse errorResponse; + + log.error(">>>>>> AuthenticationException: ", authException); + httpStatus = HttpStatus.UNAUTHORIZED; + errorResponse = ApiResponse.onFailure( + SecurityErrorCode.UNAUTHORIZED.getCode(), + SecurityErrorCode.UNAUTHORIZED.getMessage(), + authException.getMessage()); + + HttpResponseUtil.setErrorResponse(response, httpStatus, errorResponse); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/SecurityCustomException.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/SecurityCustomException.java new file mode 100644 index 00000000..b2621813 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/SecurityCustomException.java @@ -0,0 +1,23 @@ +package com.sponus.coreinfrasecurity.jwt.exception; + +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.Getter; + +@Getter +public class SecurityCustomException extends RuntimeException { + + private final BaseErrorCode errorCode; + + private final Throwable cause; + + public SecurityCustomException(BaseErrorCode errorCode) { + this.errorCode = errorCode; + this.cause = null; + } + + public SecurityCustomException(BaseErrorCode errorCode, Throwable cause) { + this.errorCode = errorCode; + this.cause = cause; + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/SecurityErrorCode.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/SecurityErrorCode.java new file mode 100644 index 00000000..cbf584a0 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/exception/SecurityErrorCode.java @@ -0,0 +1,33 @@ +package com.sponus.coreinfrasecurity.jwt.exception; + +import org.springframework.http.HttpStatus; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SecurityErrorCode implements BaseErrorCode { + + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "SEC4001", "잘못된 형식의 토큰입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "SEC4010", "인증이 필요합니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "SEC4011", "토큰이 만료되었습니다."), + TOKEN_SIGNATURE_ERROR(HttpStatus.UNAUTHORIZED, "SEC4012", "토큰이 위조되었거나 손상되었습니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "SEC4030", "권한이 없습니다."), + TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "SEC4041", "토큰이 존재하지 않습니다."), + TOKEN_ORGANIZATION_NOT_FOND(HttpStatus.UNAUTHORIZED, "SEC4042", "존재하지 않는 단체입니다."), + INTERNAL_SECURITY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SEC5000", "인증 처리 중 서버 에러가 발생했습니다."), + INTERNAL_TOKEN_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SEC5001", "토큰 처리 중 서버 에러가 발생했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ApiResponse getErrorResponse() { + return ApiResponse.onFailure(code, message); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/CustomLoginFilter.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/CustomLoginFilter.java new file mode 100644 index 00000000..f54cbe6e --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/CustomLoginFilter.java @@ -0,0 +1,138 @@ +package com.sponus.coreinfrasecurity.jwt.filter; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coreinfraredis.util.RedisUtil; +import com.sponus.coreinfrasecurity.jwt.dto.JwtPair; +import com.sponus.coreinfrasecurity.jwt.util.HttpResponseUtil; +import com.sponus.coreinfrasecurity.jwt.util.JwtUtil; +import com.sponus.coreinfrasecurity.user.CustomUserDetails; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomLoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + private final RedisUtil redisUtil; + + @Override + public Authentication attemptAuthentication( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response + ) throws AuthenticationException { + logger.info("[*] Login Filter"); + + Map requestBody; + try { + requestBody = getBody(request); + } catch (IOException e) { + throw new AuthenticationServiceException("Error occurred while parsing request body"); + } + + logger.info("[*] Request Body : " + requestBody); + + String email = (String)requestBody.get("email"); + String password = (String)requestBody.get("password"); + String fcmToken = (String)requestBody.get("fcmToken"); + + redisUtil.saveAsValue( + email + "_fcm_token", + fcmToken, + 999999999L, + TimeUnit.MILLISECONDS + ); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null); + + return authenticationManager.authenticate(authToken); + } + + @Override + protected void successfulAuthentication( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain chain, + @NonNull Authentication authentication) throws IOException { + logger.info("[*] Login Success"); + + CustomUserDetails customUserDetails = (CustomUserDetails)authentication.getPrincipal(); + + logger.info("[*] Login with " + customUserDetails.getUsername()); + + JwtPair jwtPair = new JwtPair( + jwtUtil.createJwtAccessToken(customUserDetails), + jwtUtil.createJwtRefreshToken(customUserDetails) + ); + + HttpResponseUtil.setSuccessResponse(response, HttpStatus.CREATED, jwtPair); + } + + @Override + protected void unsuccessfulAuthentication( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull AuthenticationException failed) throws IOException { + logger.info("[*] Login Fail"); + // TODO : 예외처리 정리 + String errorMessage; + if (failed instanceof BadCredentialsException) { + errorMessage = "Bad credentials"; + } else if (failed instanceof LockedException) { + errorMessage = "Account is locked"; + } else if (failed instanceof DisabledException) { + errorMessage = "Account is disabled"; + } else if (failed instanceof UsernameNotFoundException) { + errorMessage = "Account not found"; + } else if (failed instanceof AuthenticationServiceException) { + errorMessage = "Error occurred while parsing request body"; + } else { + errorMessage = "Authentication failed"; + } + HttpResponseUtil.setErrorResponse( + response, HttpStatus.UNAUTHORIZED, + ApiResponse.onFailure( + HttpStatus.BAD_REQUEST.name(), + errorMessage + ) + ); + } + + private Map getBody(HttpServletRequest request) throws IOException { + StringBuilder stringBuilder = new StringBuilder(); + String line; + + try (BufferedReader bufferedReader = request.getReader()) { + while ((line = bufferedReader.readLine()) != null) { + stringBuilder.append(line); + } + } + + String requestBody = stringBuilder.toString(); + ObjectMapper objectMapper = new ObjectMapper(); + + return objectMapper.readValue(requestBody, Map.class); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/CustomLogoutHandler.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/CustomLogoutHandler.java new file mode 100644 index 00000000..2b7ea23d --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/CustomLogoutHandler.java @@ -0,0 +1,55 @@ +package com.sponus.coreinfrasecurity.jwt.filter; + +import java.util.concurrent.TimeUnit; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import com.sponus.coreinfraredis.util.RedisUtil; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityCustomException; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityErrorCode; +import com.sponus.coreinfrasecurity.jwt.util.JwtUtil; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class CustomLogoutHandler implements LogoutHandler { + + private final RedisUtil redisUtil; + private final JwtUtil jwtUtil; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + try { + log.info("[*] Logout Filter"); + + String accessToken = jwtUtil.resolveAccessToken(request); + + redisUtil.saveAsValue( + accessToken, + "logout", + jwtUtil.getExpTime(accessToken), + TimeUnit.MILLISECONDS + ); + + String email = jwtUtil.getEmail(accessToken); + + redisUtil.delete( + email + "_refresh_token" + ); + + redisUtil.delete( + email + "_fcm_token" + ); + + } catch (ExpiredJwtException e) { + log.warn("[*] case : accessToken expired"); + throw new SecurityCustomException(SecurityErrorCode.TOKEN_EXPIRED); + } + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/JwtAuthenticationFilter.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..c903bb1e --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,82 @@ +package com.sponus.coreinfrasecurity.jwt.filter; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.sponus.coreinfraredis.util.RedisUtil; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityCustomException; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityErrorCode; +import com.sponus.coreinfrasecurity.jwt.util.JwtUtil; +import com.sponus.coreinfrasecurity.user.CustomUserDetails; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final RedisUtil redisUtil; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + logger.info("[*] Jwt Filter"); + + try { + String accessToken = jwtUtil.resolveAccessToken(request); + + // accessToken 없이 접근할 경우 + if (accessToken == null) { + filterChain.doFilter(request, response); + return; + } + + // logout 처리된 accessToken + if (redisUtil.get(accessToken) != null && redisUtil.get(accessToken).equals("logout")) { + logger.info("[*] Logout accessToken"); + filterChain.doFilter(request, response); + return; + } + + logger.info("[*] Authorization with Token"); + authenticateAccessToken(accessToken); + filterChain.doFilter(request, response); + } catch (ExpiredJwtException e) { + logger.warn("[*] case : accessToken Expired"); + throw new SecurityCustomException(SecurityErrorCode.TOKEN_EXPIRED); + } + } + + private void authenticateAccessToken(String accessToken) { + CustomUserDetails userDetails = new CustomUserDetails( + jwtUtil.getId(accessToken), + jwtUtil.getEmail(accessToken), + null, + jwtUtil.getAuthority(accessToken) + ); + + logger.info("[*] Authority Registration"); + + // 스프링 시큐리티 인증 토큰 생성 + Authentication authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); + + // 컨텍스트 홀더에 저장 + SecurityContextHolder.getContext().setAuthentication(authToken); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/JwtExceptionFilter.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/JwtExceptionFilter.java new file mode 100644 index 00000000..fe0185cf --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/filter/JwtExceptionFilter.java @@ -0,0 +1,57 @@ +package com.sponus.coreinfrasecurity.jwt.filter; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.sponus.coredomain.domain.common.ApiResponse; +import com.sponus.coredomain.domain.common.BaseErrorCode; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityCustomException; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityErrorCode; +import com.sponus.coreinfrasecurity.jwt.util.HttpResponseUtil; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class JwtExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws IOException { + try { + filterChain.doFilter(request, response); + } catch (SecurityCustomException e) { + log.warn(">>>>> SecurityCustomException : ", e); + BaseErrorCode errorCode = e.getErrorCode(); + ApiResponse errorResponse = ApiResponse.onFailure( + errorCode.getCode(), + errorCode.getMessage(), + e.getMessage() + ); + HttpResponseUtil.setErrorResponse( + response, + errorCode.getHttpStatus(), + errorResponse + ); + } catch (Exception e) { + log.error(">>>>> Exception : ", e); + ApiResponse errorResponse = ApiResponse.onFailure( + SecurityErrorCode.INTERNAL_SECURITY_ERROR.getCode(), + SecurityErrorCode.INTERNAL_SECURITY_ERROR.getMessage(), + e.getMessage() + ); + HttpResponseUtil.setErrorResponse( + response, + HttpStatus.INTERNAL_SERVER_ERROR, + errorResponse + ); + } + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/util/HttpResponseUtil.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/util/HttpResponseUtil.java new file mode 100644 index 00000000..f4086e42 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/util/HttpResponseUtil.java @@ -0,0 +1,35 @@ +package com.sponus.coreinfrasecurity.jwt.util; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sponus.coredomain.domain.common.ApiResponse; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class HttpResponseUtil { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static void setSuccessResponse(HttpServletResponse response, HttpStatus httpStatus, Object body) + throws IOException { + String responseBody = objectMapper.writeValueAsString(ApiResponse.onSuccess(body)); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(httpStatus.value()); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(responseBody); + } + + public static void setErrorResponse(HttpServletResponse response, HttpStatus httpStatus, Object body) + throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(httpStatus.value()); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getOutputStream(), body); + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/util/JwtUtil.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/util/JwtUtil.java new file mode 100644 index 00000000..6f0a0786 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/jwt/util/JwtUtil.java @@ -0,0 +1,176 @@ +package com.sponus.coreinfrasecurity.jwt.util; + +import static com.sponus.coreinfrasecurity.jwt.exception.SecurityErrorCode.*; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.sponus.coreinfraredis.util.RedisUtil; +import com.sponus.coreinfrasecurity.jwt.dto.JwtPair; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityCustomException; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityErrorCode; +import com.sponus.coreinfrasecurity.user.CustomUserDetails; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class JwtUtil { + + private static final String AUTHORITIES_CLAIM_NAME = "auth"; + + private final SecretKey secretKey; + private final Long accessExpMs; + private final Long refreshExpMs; + private final RedisUtil redisUtil; + + public JwtUtil( + @Value("${spring.jwt.secret}") String secret, + @Value("${spring.jwt.token.access-expiration-time}") Long access, + @Value("${spring.jwt.token.refresh-expiration-time}") Long refresh, + RedisUtil redis) { + + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm()); + accessExpMs = access; + refreshExpMs = refresh; + redisUtil = redis; + } + + public String createJwtAccessToken(CustomUserDetails customUserDetails) { + Instant issuedAt = Instant.now(); + Instant expiration = issuedAt.plusMillis(accessExpMs); + + return Jwts.builder() + .header() + .add("alg", "HS256") + .add("typ", "JWT") + .and() + .subject(customUserDetails.getId().toString()) + .claim("email", customUserDetails.getUsername()) + .claim(AUTHORITIES_CLAIM_NAME, customUserDetails.getAuthority()) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } + + public String createJwtRefreshToken(CustomUserDetails customUserDetails) { + Instant issuedAt = Instant.now(); + Instant expiration = issuedAt.plusMillis(refreshExpMs); + + String refreshToken = Jwts.builder() + .header() + .add("alg", "HS256") + .add("typ", "JWT") + .and() + .subject(customUserDetails.getId().toString()) + .claim("email", customUserDetails.getUsername()) + .claim(AUTHORITIES_CLAIM_NAME, customUserDetails.getAuthority()) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + + redisUtil.saveAsValue( + customUserDetails.getEmail() + "_refresh_token", + refreshToken, + refreshExpMs, + TimeUnit.MILLISECONDS + ); + + return refreshToken; + } + + public JwtPair reissueToken(String refreshToken) { + try { + validateRefreshToken(refreshToken); + log.info("[*] Valid RefreshToken"); + + CustomUserDetails tempCustomUserDetails = new CustomUserDetails( + getId(refreshToken), + getEmail(refreshToken), + null, + getAuthority(refreshToken) + ); + + return new JwtPair( + createJwtAccessToken(tempCustomUserDetails), + createJwtRefreshToken(tempCustomUserDetails) + ); + } catch (IllegalArgumentException iae) { + throw new SecurityCustomException(INVALID_TOKEN, iae); + } catch (ExpiredJwtException eje) { + throw new SecurityCustomException(TOKEN_EXPIRED, eje); + } + } + + public String resolveAccessToken(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + + if (authorization == null || !authorization.startsWith("Bearer ")) { + log.warn("[*] No token in req"); + return null; + } + + log.info("[*] Token exists"); + return authorization.split(" ")[1]; + } + + public void validateRefreshToken(String refreshToken) { + // refreshToken 유효성 검증 + String email = getEmail(refreshToken); + + //redis에 refreshToken 있는지 검증 + if (!redisUtil.hasKey(email + "_refresh_token")) { + log.warn("[*] case : Invalid refreshToken"); + throw new SecurityCustomException(INVALID_TOKEN); + } + } + + public Long getId(String token) { + return Long.parseLong(getClaims(token).getSubject()); + } + + public String getEmail(String token) { + return getClaims(token).get("email", String.class); + } + + public String getAuthority(String token) { + return getClaims(token).get(AUTHORITIES_CLAIM_NAME, String.class); + } + + public Boolean isExpired(String token) { + // 여기서 토큰 형식 이상한 것도 걸러짐 + return getClaims(token).getExpiration().before(Date.from(Instant.now())); + } + + public Long getExpTime(String token) { + return getClaims(token).getExpiration().getTime(); + } + + private Claims getClaims(String token) { + try { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload(); + } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) { + throw new SecurityCustomException(INVALID_TOKEN, e); + } catch (SignatureException e) { + throw new SecurityCustomException(SecurityErrorCode.TOKEN_SIGNATURE_ERROR, e); + } + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/user/CustomUserDetails.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/user/CustomUserDetails.java new file mode 100644 index 00000000..af3041d9 --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/user/CustomUserDetails.java @@ -0,0 +1,73 @@ +package com.sponus.coreinfrasecurity.user; + +import java.util.ArrayList; +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.sponus.coredomain.domain.organization.Organization; + +import lombok.Getter; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long id; + private final String email; + private final String password; + private final String authority; + + public CustomUserDetails(Long id, String email, String password, String authority) { + this.id = id; + this.email = email; + this.password = password; + this.authority = authority; + } + + public CustomUserDetails(Organization organization) { + this.id = organization.getId(); + this.email = organization.getEmail(); + this.password = organization.getPassword(); + this.authority = organization.getOrganizationType().toString(); + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + + collection.add((GrantedAuthority)() -> authority); + + return collection; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return this.email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/user/CustomUserDetailsService.java b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/user/CustomUserDetailsService.java new file mode 100644 index 00000000..f8d5484d --- /dev/null +++ b/core/core-infra-security/src/main/java/com/sponus/coreinfrasecurity/user/CustomUserDetailsService.java @@ -0,0 +1,32 @@ +package com.sponus.coreinfrasecurity.user; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.sponus.coredomain.domain.organization.Organization; +import com.sponus.coredomain.domain.organization.repository.OrganizationRepository; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityCustomException; +import com.sponus.coreinfrasecurity.jwt.exception.SecurityErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomUserDetailsService implements UserDetailsService { + + private final OrganizationRepository organizationRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Organization findOrganization = organizationRepository.findOrganizationByEmail(email) + .orElseThrow(() -> new SecurityCustomException(SecurityErrorCode.TOKEN_ORGANIZATION_NOT_FOND)); + + log.info("[*] Organization found : " + findOrganization.getEmail()); + + return new CustomUserDetails(findOrganization); + } +} diff --git a/core/core-infra-security/src/main/resources/application-security.yml b/core/core-infra-security/src/main/resources/application-security.yml new file mode 100644 index 00000000..4e801992 --- /dev/null +++ b/core/core-infra-security/src/main/resources/application-security.yml @@ -0,0 +1,6 @@ +spring: + jwt: + secret: ${JWT_SECRET} + token: + access-expiration-time: ${JWT_ACCESS_EXPIRATION_TIME} + refresh-expiration-time: ${JWT_REFRESH_EXPIRATION_TIME} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..04a71afe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + postgres: + image: postgres:latest + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres! + ports: + - "5432:5432" + volumes: + - db_data:/var/lib/postgresql/data + + redis: + image: redis:latest + ports: + - "6379:6379" + +volumes: + db_data: diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d64cd491 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..1af9e093 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..1aa94a42 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..93e3f59f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/http/test.http b/http/test.http new file mode 100644 index 00000000..6017a3df --- /dev/null +++ b/http/test.http @@ -0,0 +1,237 @@ +@masterToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJzcG9udXNAZ21haWwuY29tIiwiYXV0aCI6IlNUVURFTlQiLCJpYXQiOjE3MDU4MzQ2MTksImV4cCI6MTcwNjgzNDYxOX0.XO8ydia0RB1eVGXrA7nBeP5AND2Ui4ivK26DEldZLnE + +### 회원가입 +POST http://localhost:8080/api/v1/organizations/join +Content-Type: application/json + +{ + "name": "팀 스포너스 학생회", + "email": "sponus_student@gmail.com", + "password": "password1234", + "location": "none", + "organizationType": "STUDENT", + "suborganizationType": "STUDENT_COUNCIL" +} + +### 기업단체 로그인 +POST http://localhost:8080/api/v1/organizations/login +Content-Type: application/json + +{ + "email": "sponus@gmail.com", + "password": "password1234", + "fcmToken": "fUhUFOE6uEM8rUJ48kiQM2:APA91bGIqRztEpmgv34yFag6sjBRGXgkza4Gh-CqMjLHdp3jSFT25EFeUgBMNt6UWrcwJzaZ1daO0a2H1iSUNReS0A524XKVb_eulcgV4SXRL9lxe1Zc6fDLQQJd4egnjdzDDzJKQ27f" +} + +### 학생단체 로그인 +POST http://localhost:8080/api/v1/organizations/login +Content-Type: application/json + +{ + "email": "sponus_student@gmail.com", + "password": "password1234", + "fcmToken": "fUhUFOE6uEM8rUJ48kiQM2:APA91bGIqRztEpmgv34yFag6sjBRGXgkza4Gh-CqMjLHdp3jSFT25EFeUgBMNt6UWrcwJzaZ1daO0a2H1iSUNReS0A524XKVb_eulcgV4SXRL9lxe1Zc6fDLQQJd4egnjdzDDzJKQ27f" +} + +### 로그아웃 +GET http://localhost:8080/api/v1/organizations/logout +Authorization: Bearer {{masterToken}} + +### 토큰 재발급 +GET http://localhost:8080/api/v1/auth/reissue +Authorization: Bearer {{masterToken}} +RefreshToken: {{refreshToken}} + +### 공고 생성 +POST http://localhost:8080/api/v1/announcements +Authorization: Bearer {{masterToken}} +Content-Type: application/json + +{ + "title": "무신사 스폰서십", + "type": "SPONSORSHIP", + "category": "MARKETING", + "content": "무신사 스폰서십을 진행할 대학교 학생회를 모집합니다.", + "images": [ + ] +} + +### 공고 상세 조회 +GET http://localhost:8080/api/v1/announcements/1 +Authorization: Bearer {{masterToken}} + +### 공고 상태별 목록 조회 +GET http://localhost:8080/api/v1/announcements/status?status=POSTED +Authorization: Bearer {{masterToken}} + +### 공고 수정 +PATCH http://localhost:8080/api/v1/announcements/1 +Authorization: Bearer {{masterToken}} +Content-Type: application/json + +{ + "title": "스포너스 스폰서십", + "type": "SPONSORSHIP", + "category": "MARKETING", + "content": "스포너스 스폰서십을 진행할 대학교 학생회를 모집합니다." +} + +### 공고 삭제 (생성한 단체만 삭제 가능) +DELETE http://localhost:8080/api/v1/announcements/1 +Authorization: Bearer {{masterToken}} + +### 공고 카테고리별 조회 (전체, 전체) +GET http://localhost:8080/api/v1/announcements/category +Authorization: Bearer {{masterToken}} + +### 공고 카테고리별 조회 2 +GET http://localhost:8080/api/v1/announcements/category?category=MARKETING&type=SPONSORSHIP +Authorization: Bearer {{masterToken}} + +### 북마크 등록 및 취소 +POST http://localhost:8080/api/v1/me/announcements/0/bookmarked +Authorization: Bearer {{masterToken}} +Content-Type: application/json + +{ + "announcementId": 1 +} + +### 북마크 최근 저장순 조회 +GET http://localhost:8080/api/v1/me/announcements/bookmarked?sort=RECENT +Authorization: Bearer {{masterToken}} + +### 북마크 저장 많은순 조회 +GET http://localhost:8080/api/v1/me/announcements/bookmarked?sort=SAVED +Authorization: Bearer {{masterToken}} + +### 보고서 작성 +POST http://localhost:8080/api/v1/reports +Content-Type: application/json + +{ + "title": "무신사 스폰서십", + "content": "무신사 스폰서십을 진행했습니다.", + "files": [] +} + +### 보고서 조회 +GET http://localhost:8080/api/v1/reports/0 + + +### 공고 검색 +GET http://localhost:8080/api/v1/announcements?search=무신사 + +### 내 조직 조회 +GET http://localhost:8080/api/v1/organizations/me +Authorization: Bearer {{masterToken}} + +### 내 조직 수정 +PATCH http://localhost:8080/api/v1/organizations/me +Authorization: Bearer {{masterToken}} +Content-Type: application/json + +{ + "name": "팀 스포너스 업데이트!!!!", + "email": "sponus@gmail.com 업데이트!!!!", + "password": "password1234 업데이트!!!!", + "location": "none 업데이트!!!!", + "organizationType": "COMPANY", + "suborganizationType": "CLUB", + "managerName": "이가은", + "managerPosition": "Project Manager", + "managerEmail": "test@gmail.com", + "managerPhone": "01012345678", + "managerAvailableDay": "월-금", + "managerAvailableHour": "15:00-18:00", + "managerContactPreference": "EMAIL 업데이트!!!!" +} + +### 내 조직 수정 2 +PATCH http://localhost:8080/api/v1/organizations/me +Authorization: Bearer {{masterToken}} +Content-Type: application/json + +{ + "name": "팀 스포너스 업데이트!!!!2" +} + +### 내 조직 삭제 [soft delete] (OrganizationStatus = INACTIVE) +DELETE http://localhost:8080/api/v1/organizations/me +Authorization: Bearer {{masterToken}} + +### 조직 조회 +GET http://localhost:8080/api/v1/organizations/1 +Authorization: Bearer {{matsterToken}} + +### 조직 검색 +GET http://localhost:8080/api/v1/organizations?search=스포너스 +Authorization: Bearer {{matsterToken}} + +### 이메일 인증 +POST http://localhost:8080/api/v1/organizations/email?email=이메일 + +### 조직 링크 생성 (TODO: 테스트 필요) +POST http://localhost:8080/api/v1/organization-links +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJzcG9udXNAZ21haWwuY29tIiwiYXV0aCI6IlNUVURFTlQiLCJpYXQiOjE3MDcwMjI2MzIsImV4cCI6MTcwODAyMjYzMn0.olnLsJGmP9hRifXYN-H85V6LBivGhRX8HcPJV1rPSoo +Content-Type: application/json + +{ + "name": "페이스북 공식 계정", + "url": "https://www.facebook.com/?locale=ko_KR" +} + +### 조직 링크 조회 +GET http://localhost:8080/api/v1/organization-links/3 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJzcG9udXNAZ21haWwuY29tIiwiYXV0aCI6IlNUVURFTlQiLCJpYXQiOjE3MDcwMjYyNTAsImV4cCI6MTcwODAyNjI1MH0.kCsg2CClbDzBbiX4k2EmxihyToIdr5stZ1ADxoMdSSI + + +### 조직 링크 수정 +PATCH http://localhost:8080/api/v1/organization-links/1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJzcG9udXNAZ21haWwuY29tIiwiYXV0aCI6IlNUVURFTlQiLCJpYXQiOjE3MDcwMjYyNTAsImV4cCI6MTcwODAyNjI1MH0.kCsg2CClbDzBbiX4k2EmxihyToIdr5stZ1ADxoMdSSI +Content-Type: application/json + +{ + "name": "페이스북 공식 계정 업데이트", + "url": "https://www.facebook.com/?locale=ko_KR 업데이트" +} + +### 조직 링크 삭제 +DELETE http://localhost:8080/api/v1/organization-links/1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJzcG9udXNAZ21haWwuY29tIiwiYXV0aCI6IlNUVURFTlQiLCJpYXQiOjE3MDcwMjYyNTAsImV4cCI6MTcwODAyNjI1MH0.kCsg2CClbDzBbiX4k2EmxihyToIdr5stZ1ADxoMdSSI + +### 태그 생성 +POST http://localhost:8080/api/v1/tags +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJzcG9udXNAZ21haWwuY29tIiwiYXV0aCI6IlNUVURFTlQiLCJpYXQiOjE3MDcwMjI2MzIsImV4cCI6MTcwODAyMjYzMn0.olnLsJGmP9hRifXYN-H85V6LBivGhRX8HcPJV1rPSoo +Content-Type: application/json + +{ + "name": "태그 name test2" +} + +### 태그 조회 (todo 테스트 필요) +GET http://localhost:8080/api/v1/tags/1 +Authorization: Bearer {{masterToken}} + +### 태그 수정 기능(todo: 테스트 필요) +PATCH http://localhost:8080/api/v1/tags/1 +Authorization: Bearer {{masterToken}} +Content-Type: application/json + +{ + "name": "태그 수정" +} + +### 태그 삭제 (TODO: 테스트 필요) +DELETE http://localhost:8080/api/v1/tags/1 +Authorization: Bearer {{masterToken}} + +### 알림 테스트 +POST http://localhost:8080/api/v1/notification/fcm +Authorization: Bearer {{masterToken}} +Content-Type: application/json + +{ + "title": "test 제목", + "body": "test 내용" +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..4b64e837 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,9 @@ +rootProject.name = 'sponus-be' +include 'api' +include 'core:core-domain' +include 'core:core-infra-db' +include 'core:core-infra-s3' +include 'core:core-infra-redis' +include 'core:core-infra-email' +include 'core:core-infra-firebase' +include 'core:core-infra-security'