diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f51066a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,99 @@ +name: Deploy Frontend & Backend + +on: + push: + branches: [deploy] + +jobs: + frontend: + name: Deploy Frontend to S3 + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '22' + + - name: Install dependencies + run: | + cd frontend + npm ci + + - name: Build frontend + run: | + cd frontend + npm run build + + - name: Deploy to S3 + uses: jakejarvis/s3-sync-action@master + with: + args: --delete + env: + AWS_S3_BUCKET: www.pirocheck.org + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + SOURCE_DIR: frontend/dist + + backend: + name: Deploy Backend to EC2 + needs: frontend + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Build backend + run: | + cd backend + ./gradlew build --no-daemon + + - name: Restore PEM file + run: | + echo "${{ secrets.EC2_SSH_KEY }}" | base64 -d > pirocheck.pem + chmod 400 pirocheck.pem + + - name: Copy JAR to EC2 + run: | + scp -o StrictHostKeyChecking=no -i pirocheck.pem backend/build/libs/*.jar ubuntu@${{ secrets.EC2_HOST }}:/home/ubuntu/app.jar + + - name: Restart Spring Boot on EC2 + run: | + ssh -o StrictHostKeyChecking=no -i pirocheck.pem ubuntu@${{ secrets.EC2_HOST }} 'bash ~/restart.sh' + + + - name: Send Discord notification (Success) + if: success() + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d '{ + "embeds": [{ + "title": "πŸš€ Deploy 성곡!", + "description": "**Branch**: `${{ github.ref }}`\n**Commit**: `${{ github.sha }}`\n🟒 μ„œλΉ„μŠ€κ°€ μ •μƒμ μœΌλ‘œ λ°°ν¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€!", + "color": 65353 + }] + }' ${{ secrets.DISCORD_WEBHOOK }} + + - name: Send Discord notification (Failure) + if: failure() + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d '{ + "embeds": [{ + "title": "❌ Deploy μ‹€νŒ¨!", + "description": "**Branch**: `${{ github.ref }}`\n**Commit**: `${{ github.sha }}`\nπŸ”΄ 배포 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. 둜그λ₯Ό ν™•μΈν•˜μ„Έμš”.", + "color": 16711680 + }] + }' ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..234cd69 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,31 @@ +name: Notify Discord on PR events + +on: + pull_request: + types: [opened, closed, reopened] + +jobs: + notify-discord: + runs-on: ubuntu-latest + steps: + - name: Determine PR event + id: msg + run: | + if [[ "${{ github.event.action }}" == "opened" ]]; then + echo "message= PR Opened: **${{ github.event.pull_request.title }}**" >> $GITHUB_OUTPUT + elif [[ "${{ github.event.action }}" == "reopened" ]]; then + echo "message= PR Reopened: **${{ github.event.pull_request.title }}**" >> $GITHUB_OUTPUT + elif [[ "${{ github.event.action }}" == "closed" && "${{ github.event.pull_request.merged }}" == "true" ]]; then + echo "message= PR Merged: **${{ github.event.pull_request.title }}**" >> $GITHUB_OUTPUT + else + echo "message= PR Closed without merge: **${{ github.event.pull_request.title }}**" >> $GITHUB_OUTPUT + fi + + - name: Send Discord notification + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d "{\"content\": \"${{ steps.msg.outputs.message }}\\n[PR #${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }}) by @${{ github.actor }}\"}" \ + "$DISCORD_WEBHOOK_URL" diff --git a/.gitignore b/.gitignore index 01b6b1f..4d570a7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ # Common .DS_Store *.log -.env \ No newline at end of file +.env + +.idea/ \ No newline at end of file diff --git a/backend/pirocheck/.gitignore b/backend/pirocheck/.gitignore index 77aa6f7..8f30de9 100644 --- a/backend/pirocheck/.gitignore +++ b/backend/pirocheck/.gitignore @@ -5,6 +5,9 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ +.idea +.idea/ + ### STS ### .apt_generated .classpath @@ -18,7 +21,6 @@ bin/ !**/src/test/**/bin/ ### IntelliJ IDEA ### -.idea *.iws *.iml *.ipr diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/User/repository/UserRepository.java b/backend/pirocheck/src/main/java/backend/pirocheck/User/repository/UserRepository.java index 675aca1..9f80d25 100644 --- a/backend/pirocheck/src/main/java/backend/pirocheck/User/repository/UserRepository.java +++ b/backend/pirocheck/src/main/java/backend/pirocheck/User/repository/UserRepository.java @@ -1,11 +1,15 @@ package backend.pirocheck.User.repository; +import backend.pirocheck.User.entity.Role; import backend.pirocheck.User.entity.User; import io.swagger.v3.oas.annotations.Operation; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByName(String name); + + List findByRole(Role role); } diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/controller/AttendanceController.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/controller/AttendanceController.java new file mode 100644 index 0000000..ac5847a --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/controller/AttendanceController.java @@ -0,0 +1,55 @@ +package backend.pirocheck.attendence.controller; + +import backend.pirocheck.attendence.dto.request.GetAttendanceByDateReq; +import backend.pirocheck.attendence.dto.request.MarkAttendanceReq; +import backend.pirocheck.attendence.dto.response.AttendanceSlotRes; +import backend.pirocheck.attendence.dto.response.AttendanceStatusRes; +import backend.pirocheck.attendence.entity.AttendanceCode; +import backend.pirocheck.attendence.service.AttendanceService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/attendance") +public class AttendanceController { + + private final AttendanceService attendanceService; + + // νŠΉμ • μœ μ €μ˜ μΆœμ„ 정보 + @GetMapping("/user") + public List getAttendanceByUserId(@RequestParam Long userId) { + return attendanceService.findByUserId(userId); + } + + // νŠΉμ • μœ μ €μ˜ νŠΉμ • 일자 μΆœμ„ 정보 + @GetMapping("/user/date") + public List getAttendanceByUserIdAndDate( + @RequestParam Long userId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + ) { + return attendanceService.findByUserIdAndDate(userId, LocalDate.now()); + } + + // μΆœμ„μ²΄ν¬ μ‹œμž‘ + @PostMapping("/start") + public AttendanceCode postAttendance() { + return attendanceService.generateCodeAndCreateAttendances(); + } + + // μΆœμ„μ½”λ“œ 비ꡐ + @PostMapping("/mark") + public boolean markAttendance(@RequestBody MarkAttendanceReq req) { + return attendanceService.markAttendance(req.getUserId(), req.getCode()); + } + + // μΆœμ„μ²΄ν¬ μ’…λ£Œ + @PutMapping("/expire") + public boolean expireAttendance(@RequestParam String code) { + return attendanceService.exprireAttendanceCode(code); + } +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/request/GetAttendanceByDateReq.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/request/GetAttendanceByDateReq.java new file mode 100644 index 0000000..d731fb4 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/request/GetAttendanceByDateReq.java @@ -0,0 +1,11 @@ +package backend.pirocheck.attendence.dto.request; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class GetAttendanceByDateReq { + private Long userId; + private LocalDate date; +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/request/MarkAttendanceReq.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/request/MarkAttendanceReq.java new file mode 100644 index 0000000..a6aad92 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/request/MarkAttendanceReq.java @@ -0,0 +1,9 @@ +package backend.pirocheck.attendence.dto.request; + +import lombok.Getter; + +@Getter +public class MarkAttendanceReq { + private Long userId; + private String code; +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/AttendanceSlotRes.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/AttendanceSlotRes.java new file mode 100644 index 0000000..81f0bbf --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/AttendanceSlotRes.java @@ -0,0 +1,14 @@ +package backend.pirocheck.attendence.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class AttendanceSlotRes { + private int order; + private boolean status; + +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/AttendanceStatusRes.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/AttendanceStatusRes.java new file mode 100644 index 0000000..2a965ac --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/AttendanceStatusRes.java @@ -0,0 +1,15 @@ +package backend.pirocheck.attendence.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +public class AttendanceStatusRes { + private LocalDate date; + private List slots; +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/GetAttendanceByUserIdRes.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/GetAttendanceByUserIdRes.java new file mode 100644 index 0000000..50fd6a4 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/dto/response/GetAttendanceByUserIdRes.java @@ -0,0 +1,13 @@ +package backend.pirocheck.attendence.dto.response; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class GetAttendanceByUserIdRes { + private Long userId; + private LocalDate date; + private int order; + private boolean status; +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/entity/Attendance.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/entity/Attendance.java new file mode 100644 index 0000000..af9ce56 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/entity/Attendance.java @@ -0,0 +1,31 @@ +package backend.pirocheck.attendence.entity; + +import backend.pirocheck.User.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Entity +@Table( + name = "attendance", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "date", "order_number"}) +) +@Getter @Setter +public class Attendance { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private LocalDate date; + + @Column(name = "order_number") + private int order; + + private boolean status; +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/entity/AttendanceCode.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/entity/AttendanceCode.java new file mode 100644 index 0000000..358bf29 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/entity/AttendanceCode.java @@ -0,0 +1,24 @@ +package backend.pirocheck.attendence.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Entity +@Table(name = "attendance_code") +@Getter @Setter +public class AttendanceCode { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String code; + + private LocalDate date; + + @Column(name = "order_number") + private int order; + + private boolean isExpired = false; +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/repository/AttendanceCodeRepository.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/repository/AttendanceCodeRepository.java new file mode 100644 index 0000000..2c30b55 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/repository/AttendanceCodeRepository.java @@ -0,0 +1,14 @@ +package backend.pirocheck.attendence.repository; + +import backend.pirocheck.attendence.entity.AttendanceCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; + +@Repository +public interface AttendanceCodeRepository extends JpaRepository { + int countByDate(LocalDate date); + Optional findByCodeAndDate(String code, LocalDate date); +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/repository/AttendanceRepository.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/repository/AttendanceRepository.java new file mode 100644 index 0000000..445f157 --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/repository/AttendanceRepository.java @@ -0,0 +1,17 @@ +package backend.pirocheck.attendence.repository; + +import backend.pirocheck.attendence.entity.Attendance; +import backend.pirocheck.attendence.entity.AttendanceCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface AttendanceRepository extends JpaRepository { + List findByUserId(Long userId); + List findByUserIdAndDate(Long userId, LocalDate date); + Optional findByUserIdAndDateAndOrder(Long userId, LocalDate date, int order); +} diff --git a/backend/pirocheck/src/main/java/backend/pirocheck/attendence/service/AttendanceService.java b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/service/AttendanceService.java new file mode 100644 index 0000000..42ffe8f --- /dev/null +++ b/backend/pirocheck/src/main/java/backend/pirocheck/attendence/service/AttendanceService.java @@ -0,0 +1,143 @@ +package backend.pirocheck.attendence.service; + +import backend.pirocheck.User.entity.Role; +import backend.pirocheck.User.entity.User; +import backend.pirocheck.User.repository.UserRepository; +import backend.pirocheck.attendence.dto.response.AttendanceSlotRes; +import backend.pirocheck.attendence.dto.response.AttendanceStatusRes; +import backend.pirocheck.attendence.entity.Attendance; +import backend.pirocheck.attendence.entity.AttendanceCode; +import backend.pirocheck.attendence.repository.AttendanceCodeRepository; +import backend.pirocheck.attendence.repository.AttendanceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AttendanceService { + + private final AttendanceRepository attendanceRepository; + private final AttendanceCodeRepository attendanceCodeRepository; + private final UserRepository userRepository; + + // μΆœμ„μ½”λ“œ 생성 ν•¨μˆ˜ + @Transactional + public AttendanceCode generateCodeAndCreateAttendances() { + LocalDate today = LocalDate.now(); + + // 였늘 μƒμ„±λœ μΆœμ„μ½”λ“œ 개수 = ν˜„μž¬κΉŒμ§€ μƒμ„±λœ μ°¨μ‹œ 수 + 1 (MAX=3) + int currentOrder = attendanceCodeRepository.countByDate(today) + 1; + + // 1. μΆœμ„ μ½”λ“œ 생성 + String code = String.valueOf(ThreadLocalRandom.current().nextInt(1000, 10000)); + + AttendanceCode attendanceCode = new AttendanceCode(); + attendanceCode.setCode(code); + attendanceCode.setDate(today); + attendanceCode.setOrder(currentOrder); + attendanceCodeRepository.save(attendanceCode); + + // 2. user κΆŒν•œμ„ κ°€μ§„ 학생 리슀트 쑰회 + List users = userRepository.findByRole(Role.MEMBER); + + // 3. 각 학생에 λŒ€ν•΄ μΆœμ„ 데이터 미리 생성 + for (User user : users) { + Attendance attendance = new Attendance(); + attendance.setUser(user); + attendance.setDate(LocalDate.now()); + attendance.setOrder(currentOrder); + attendance.setStatus(false); // 기본은 false + attendanceRepository.save(attendance); + } + return attendanceCode; + } + + // μΆœμ„μ½”λ“œ 만료처리 ν•¨μˆ˜ + @Transactional + public boolean exprireAttendanceCode(String code) { + Optional codeOpt = attendanceCodeRepository.findByCodeAndDate(code, LocalDate.now()); + + if (codeOpt.isEmpty()) { + return false; + } + + AttendanceCode attendanceCode = codeOpt.get(); + + if (attendanceCode.isExpired()) { + return false; + } + + attendanceCode.setExpired(true); + attendanceCodeRepository.save(attendanceCode); + + return true; + } + + // μΆœμ„μ²˜λ¦¬ ν•¨μˆ˜ + @Transactional + public boolean markAttendance(Long userId, String inputCode) { + // 1. μΆœμ„μ½”λ“œ 일치 비ꡐ + Optional validCodeOpt = attendanceCodeRepository.findByCodeAndDate(inputCode, LocalDate.now()); + + if (validCodeOpt.isEmpty()) return false; + + AttendanceCode code = validCodeOpt.get(); + + // 2. ν•΄λ‹Ή μœ μ €μ˜ μΆœμ„ λ ˆμ½”λ“œ 쑰회 + Optional attendanceOpt = attendanceRepository.findByUserIdAndDateAndOrder(userId, code.getDate(), code.getOrder()); + + if (attendanceOpt.isEmpty()) return false; + + // 3. μΆœμ„ 처리 + Attendance attendance = attendanceOpt.get(); + attendance.setStatus(true); + attendanceRepository.save(attendance); + + return true; + } + + // μœ μ €μ˜ 전체 μΆœμ„ ν˜„ν™©μ„ μ‘°νšŒν•˜λŠ” ν•¨μˆ˜ + public List findByUserId(Long userId) { + List attendances = attendanceRepository.findByUserId(userId); + + // λ‚ μ§œλ³„λ‘œ κ·Έλ£Ήν™” + Map> grouped = attendances.stream() + .collect(Collectors.groupingBy(Attendance::getDate)); + + // λ‚ μ§œλ³„λ‘œ DTO λ³€ν™˜ + return grouped.entrySet().stream() + .map(entry -> { + LocalDate date = entry.getKey(); + List slots = entry.getValue().stream() + .map(a -> new AttendanceSlotRes(a.getOrder(), a.isStatus())) + .sorted(Comparator.comparingInt(AttendanceSlotRes::getOrder)) + .toList(); + + AttendanceStatusRes dto = new AttendanceStatusRes(); + dto.setDate(date); + dto.setSlots(slots); + return dto; + }) + .sorted(Comparator.comparing(AttendanceStatusRes::getDate).reversed()) + .toList(); + } + + // μœ μ €μ˜ νŠΉμ • λ‚ μ§œμ˜ μΆœμ„ ν˜„ν™©μ„ μ‘°νšŒν•˜λŠ” ν•¨μˆ˜ + public List findByUserIdAndDate(Long userId, LocalDate date) { + List attendances = attendanceRepository.findByUserIdAndDate(userId, date); + + return attendances.stream() + .map(a -> new AttendanceSlotRes(a.getOrder(), a.isStatus())) + .sorted(Comparator.comparingInt(AttendanceSlotRes::getOrder)) + .toList(); + } +} diff --git a/frontend/src/root.module.css b/frontend/src/root.module.css new file mode 100644 index 0000000..14354e4 --- /dev/null +++ b/frontend/src/root.module.css @@ -0,0 +1,27 @@ +:root { + .noto-sans-kr-context { + font-family: "Noto Sans KR", sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + } + @font-face { + font-family: "Cafe24Moyamoya-Regular-v1.0"; + src: url("https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_231029@1.1/Cafe24Moyamoya-Regular-v1.0.woff2") + format("woff2"); + font-weight: normal; + font-style: normal; + } + --main-green: #49ff24; + --card-toggle-green: #2aff00; + --card-detail-green: #2d791d; + --icon-top-green: #14ae5c; + --icon-detail-green: #14ae5c; + --icon-detail-yellow: #ffcd29; + --icon-detail-red: #ff2c2c; + --background-black: #000000; + --fill-gray: #d9d9d9; + --warn-red: #ff5858; + --text-white: #ffffff; + --border-gray: #c7c7c7; +}