diff --git a/database_schema.sql b/database_schema.sql index 6aac284..dfdc1a6 100644 --- a/database_schema.sql +++ b/database_schema.sql @@ -131,7 +131,7 @@ CREATE TABLE IF NOT EXISTS attendance_records ( id BIGSERIAL PRIMARY KEY, meeting_id BIGINT NOT NULL REFERENCES meetings(id), user_id BIGINT NOT NULL REFERENCES users(id), - present BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(32) NOT NULL DEFAULT 'ABSENT', updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by BIGINT REFERENCES users(id), UNIQUE (meeting_id, user_id) diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java index aee3fe5..d3d17c9 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java @@ -29,18 +29,16 @@ @RestController @RequestMapping("/api/v1/core-attendance/meetings") @RequiredArgsConstructor -@PreAuthorize(CoreAttendanceController.LEAD_OR_HIGHER_RULE) public class CoreAttendanceController { - public static final String LEAD_OR_HIGHER_RULE = + public static final String CORE_OR_HIGHER_RULE = "@accessGuard.check(authentication," + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" - + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; - public static final String ORGANIZER_OR_HIGHER_RULE = + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE))"; + public static final String LEAD_OR_HIGHER_RULE = "@accessGuard.check(authentication," + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" - + "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER))"; - + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; private final CoreAttendanceService service; private static ResponseEntity, Void>> okUpdated(long updated, List ignored) { @@ -48,19 +46,20 @@ private static ResponseEntity, Void>> okUpdated( } /* ===== Meetings(날짜) 목록 ===== */ + @PreAuthorize(CORE_OR_HIGHER_RULE) @GetMapping public ResponseEntity> listDates() { return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_LIST_RETRIEVED_SUCCESS, new DateListResponse(service.getDates()))); } - @PreAuthorize(ORGANIZER_OR_HIGHER_RULE) + @PreAuthorize(LEAD_OR_HIGHER_RULE) @PostMapping public ResponseEntity> createDate(@Valid @RequestBody CreateDateRequest request) { service.addDate(request.getDate()); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_CREATED_SUCCESS, new DateListResponse(service.getDates()))); } - @PreAuthorize(ORGANIZER_OR_HIGHER_RULE) + @PreAuthorize(LEAD_OR_HIGHER_RULE) @DeleteMapping("/{date}") public ResponseEntity> deleteDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { service.deleteDate(date.toString()); @@ -68,9 +67,10 @@ public ResponseEntity> deleteDate(@PathVaria } /* ===== 팀 목록 (리드=본인 팀만 / 관리자=전체) ===== */ + @PreAuthorize(CORE_OR_HIGHER_RULE) @GetMapping("/teams") public ResponseEntity, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) { - List list = service.isLeadScoped(me.getRole(), me.getTeam()) + List list = service.isTeamScoped(me.getRole(), me.getTeam()) ? service.getTeamsForLead(service.resolveEffectiveTeam(me.getRole(), me.getTeam(), null)) : service.getTeamsForOrganizerOrAdmin(); @@ -80,24 +80,26 @@ public ResponseEntity, PageMeta>> getTeams(@Authe /* ===== 특정 날짜의 팀원+현재 출석 상태 조회 (리드=본인 팀만) ===== */ // 프론트가 체크박스 채우기 전에 필요한 목록/상태 + @PreAuthorize(CORE_OR_HIGHER_RULE) @GetMapping("/{date}/members") public ResponseEntity>, Void>> membersOfMeeting(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team // 관리자만 사용, 리드는 무시 ) { TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); var list = service.getMembersWithPresence(date.toString(), effectiveTeam); - // list 원소 예시: { "userId": "123", "name": "홍길동", "present": true, "lastModifiedAt": "..." } + // list 원소 예시: { "userId": "123", "name": "홍길동", "status": "PRESENT", "lastModifiedAt": "..." } return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list)); } /* ===== 특정 날짜 출석 일괄 저장 (멱등 스냅샷) ===== */ - // Body: { "userIds": ["1","2",...], "present": true } → presentUserIds만 보내는 구조로도 쉽게 변환 가능 + // Body: { "userIds": ["1","2",...], "status": "PRESENT" } + @PreAuthorize(LEAD_OR_HIGHER_RULE) @PutMapping("/{date}/attendance") public ResponseEntity, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestBody @Valid SetAttendanceRequest req) { var userIds = req.safeUserIds(); CoreAttendanceService.AttendanceUpdateResult result = service.saveAttendanceSnapshot( date.toString(), userIds, - req.presentValue(), + req.statusValue(), me.getRole(), me.getTeam() ); @@ -105,6 +107,7 @@ public ResponseEntity, Void>> saveAttendanceSnap } /* ===== 날짜 요약(JSON) ===== */ + @PreAuthorize(CORE_OR_HIGHER_RULE) @GetMapping("/{date}/summary") public ResponseEntity> summary(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); @@ -113,6 +116,7 @@ public ResponseEntity> summary(@Authentica } /* ===== 날짜 요약(CSV) ===== */ + @PreAuthorize(CORE_OR_HIGHER_RULE) @GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8") public ResponseEntity summaryCsv(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); @@ -122,6 +126,7 @@ public ResponseEntity summaryCsv(@AuthenticationPrincipal CustomUserDeta .body(csv); } + @PreAuthorize(CORE_OR_HIGHER_RULE) @GetMapping(value = "/summary.csv", produces = "text/csv; charset=UTF-8") public ResponseEntity summaryCsvAll( @AuthenticationPrincipal CustomUserDetails me, diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/dto/request/SetAttendanceRequest.java b/src/main/java/inha/gdgoc/domain/core/attendance/dto/request/SetAttendanceRequest.java index 148dbd9..fdb5832 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/dto/request/SetAttendanceRequest.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/dto/request/SetAttendanceRequest.java @@ -1,17 +1,19 @@ package inha.gdgoc.domain.core.attendance.dto.request; +import inha.gdgoc.domain.core.attendance.enums.AttendanceStatus; import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Objects; public record SetAttendanceRequest( @NotNull List userIds, - @NotNull Boolean present + @NotNull AttendanceStatus status ) { public List safeUserIds() { return userIds == null ? List.of() : userIds.stream().filter(Objects::nonNull).toList(); } - public boolean presentValue() { - return Boolean.TRUE.equals(present); + + public AttendanceStatus statusValue() { + return status; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/dto/response/DaySummaryResponse.java b/src/main/java/inha/gdgoc/domain/core/attendance/dto/response/DaySummaryResponse.java index 823d5c7..9569c16 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/dto/response/DaySummaryResponse.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/dto/response/DaySummaryResponse.java @@ -14,6 +14,9 @@ public class DaySummaryResponse { private String date; private List perTeam; private long present; + private long late; + private long preArranged; + private long absent; private long total; @Getter @@ -23,6 +26,9 @@ public static class TeamSummary { private String teamId; private String teamName; private long present; + private long late; + private long preArranged; + private long absent; private long total; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/entity/AttendanceRecord.java b/src/main/java/inha/gdgoc/domain/core/attendance/entity/AttendanceRecord.java index 39b9809..045a1c5 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/entity/AttendanceRecord.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/entity/AttendanceRecord.java @@ -1,5 +1,6 @@ package inha.gdgoc.domain.core.attendance.entity; +import inha.gdgoc.domain.core.attendance.enums.AttendanceStatus; import inha.gdgoc.domain.user.entity.User; import jakarta.persistence.*; import lombok.*; @@ -33,8 +34,9 @@ public class AttendanceRecord { @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_attendance_user")) private User user; - @Column(name = "present", nullable = false) - private boolean present; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private AttendanceStatus status; @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; @@ -51,4 +53,4 @@ public class AttendanceRecord { void onUpdate() { updatedAt = OffsetDateTime.now(); } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/enums/AttendanceStatus.java b/src/main/java/inha/gdgoc/domain/core/attendance/enums/AttendanceStatus.java new file mode 100644 index 0000000..4875a42 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/core/attendance/enums/AttendanceStatus.java @@ -0,0 +1,42 @@ +package inha.gdgoc.domain.core.attendance.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import lombok.Getter; + +@Getter +public enum AttendanceStatus { + PRESENT("출석"), + LATE("지각"), + PRE_ARRANGED("사전 승인"), + ABSENT("결석"); + + private final String label; + + AttendanceStatus(String label) { + this.label = label; + } + + @JsonCreator + public static AttendanceStatus from(String raw) { + if (raw == null) { + return null; + } + + String normalized = raw.trim() + .replace('-', '_') + .replace(' ', '_') + .toUpperCase(); + + if (normalized.isBlank()) { + return null; + } + + return switch (normalized) { + case "PRESENT" -> PRESENT; + case "LATE" -> LATE; + case "PRE_ARRANGED", "PREARRANGED", "AGREED", "EXCUSED" -> PRE_ARRANGED; + case "ABSENT" -> ABSENT; + default -> throw new IllegalArgumentException("Unknown attendance status: " + raw); + }; + } +} diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java b/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java index 383ca50..066be28 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java @@ -12,9 +12,9 @@ @Repository public interface AttendanceRecordRepository extends JpaRepository { - /* 조회: 특정 meetingId의 (userId, present) 목록 */ + /* 조회: 특정 meetingId의 (userId, status) 목록 */ @Query(""" - select ar.user.id, ar.present + select ar.user.id, ar.status from AttendanceRecord ar where ar.meeting.id = :meetingId """) @@ -26,12 +26,12 @@ public interface AttendanceRecordRepository extends JpaRepository getTeamsForOrganizerOrAdmin() { return toTeamResponsesGrouped(users); } - public boolean isLeadScoped(UserRole role, TeamType team) { + public boolean isTeamScoped(UserRole role, TeamType team) { + if (role == UserRole.CORE) { + return team != null; + } return role == UserRole.LEAD && team != TeamType.HR; } public TeamType resolveEffectiveTeam(UserRole role, TeamType principalTeam, TeamType requestedTeam) { - return isLeadScoped(role, principalTeam) ? requireTeam(principalTeam) : requestedTeam; + return isTeamScoped(role, principalTeam) ? requireTeam(principalTeam) : requestedTeam; } /* ===================== Attendance ===================== */ @@ -123,17 +127,17 @@ public UserIdValidationResult filterUserIdsNotInTeam(TeamType team, List u } /** - * 배치로 출석 true/false 반영 (고성능 UPSERT) + * 배치로 출석 상태 반영 (고성능 UPSERT) */ @Transactional - public long setAttendance(String date, List userIds, boolean present) { + public long setAttendance(String date, List userIds, AttendanceStatus status) { if (userIds == null || userIds.isEmpty()) return 0L; LocalDate d = LocalDate.parse(date); Long meetingId = ensureMeetingAndGetId(d); Long[] arr = userIds.toArray(Long[]::new); // ✅ List -> Array - int affected = attendanceRecordRepository.upsertBatchByMeetingId(meetingId, arr, present); + int affected = attendanceRecordRepository.upsertBatchByMeetingId(meetingId, arr, status.name()); return Math.max(affected, 0); } @@ -141,21 +145,21 @@ public long setAttendance(String date, List userIds, boolean present) { public AttendanceUpdateResult saveAttendanceSnapshot( String date, List userIds, - boolean present, + AttendanceStatus status, UserRole role, TeamType principalTeam ) { - if (isLeadScoped(role, principalTeam)) { + if (isTeamScoped(role, principalTeam)) { TeamType myTeam = requireTeam(principalTeam); UserIdValidationResult validation = filterUserIdsNotInTeam(myTeam, userIds); if (validation.validIds().isEmpty()) { return new AttendanceUpdateResult(0L, validation.invalidIds()); } - long updated = setAttendance(date, validation.validIds(), present); + long updated = setAttendance(date, validation.validIds(), status); return new AttendanceUpdateResult(updated, validation.invalidIds()); } - long updated = setAttendance(date, userIds, present); + long updated = setAttendance(date, userIds, status); return new AttendanceUpdateResult(updated, List.of()); } @@ -165,17 +169,19 @@ public AttendanceUpdateResult saveAttendanceSnapshot( @Transactional(readOnly = true) public List> getMembersWithPresence(String date, TeamType teamOrNull) { LocalDate d = LocalDate.parse(date); - Map day = getPresenceMap(d); + Map day = getStatusMap(d); var roles = List.of(UserRole.CORE, UserRole.LEAD, UserRole.ORGANIZER); List users = (teamOrNull == null) ? userRepository.findByUserRoleIn(roles) : userRepository.findByTeamAndUserRoleIn(teamOrNull, roles); return users.stream().filter(u -> u.getTeam() != null).sorted(Comparator.comparing(User::getName)).map(u -> { + AttendanceStatus status = day.getOrDefault(u.getId(), AttendanceStatus.ABSENT); Map row = new LinkedHashMap<>(); row.put("userId", String.valueOf(u.getId())); row.put("name", u.getName()); row.put("team", u.getTeam().getLabel()); - row.put("present", day.getOrDefault(u.getId(), false)); + row.put("status", status.name()); + row.put("statusLabel", status.getLabel()); row.put("lastModifiedAt", null); // 추후 updatedAt/updatedBy 확장 시 채우기 return row; }).toList(); @@ -200,7 +206,7 @@ public Optional inferTeamFromUserIds(List userIds) { @Transactional(readOnly = true) public DaySummaryResponse summary(String date, TeamType teamForLeadOrNull) { LocalDate d = LocalDate.parse(date); - Map day = getPresenceMap(d); + Map day = getStatusMap(d); var roles = List.of(UserRole.CORE, UserRole.LEAD, UserRole.ORGANIZER); List baseUsers = (teamForLeadOrNull == null) ? userRepository.findByUserRoleIn(roles) : userRepository.findByTeamAndUserRoleIn(teamForLeadOrNull, roles); @@ -212,14 +218,20 @@ public DaySummaryResponse summary(String date, TeamType teamForLeadOrNull) { var perTeam = byTeam.entrySet().stream().map(e -> { TeamType team = e.getKey(); List us = e.getValue(); - long p = us.stream().filter(u -> day.getOrDefault(u.getId(), false)).count(); - return new DaySummaryResponse.TeamSummary(team.name(), team.getLabel(), p, us.size()); + long present = countStatus(us, day, AttendanceStatus.PRESENT); + long late = countStatus(us, day, AttendanceStatus.LATE); + long preArranged = countStatus(us, day, AttendanceStatus.PRE_ARRANGED); + long absent = countStatus(us, day, AttendanceStatus.ABSENT); + return new DaySummaryResponse.TeamSummary(team.name(), team.getLabel(), present, late, preArranged, absent, us.size()); }).sorted(Comparator.comparing(DaySummaryResponse.TeamSummary::getTeamName)).toList(); long present = perTeam.stream().mapToLong(DaySummaryResponse.TeamSummary::getPresent).sum(); + long late = perTeam.stream().mapToLong(DaySummaryResponse.TeamSummary::getLate).sum(); + long preArranged = perTeam.stream().mapToLong(DaySummaryResponse.TeamSummary::getPreArranged).sum(); + long absent = perTeam.stream().mapToLong(DaySummaryResponse.TeamSummary::getAbsent).sum(); long total = perTeam.stream().mapToLong(DaySummaryResponse.TeamSummary::getTotal).sum(); - return new DaySummaryResponse(date, perTeam, present, total); + return new DaySummaryResponse(date, perTeam, present, late, preArranged, absent, total); } /** @@ -229,7 +241,7 @@ public DaySummaryResponse summary(String date, TeamType teamForLeadOrNull) { public String buildSummaryCsv(String date, TeamType teamOrNull) { DaySummaryResponse s = summary(date, teamOrNull); StringBuilder sb = new StringBuilder(); - sb.append("date,team_id,team_name,present,total\n"); + sb.append("date,team_id,team_name,present,late,pre_arranged,absent,total\n"); for (var t : s.getPerTeam()) { sb.append(escape(date)) .append(',') @@ -239,6 +251,12 @@ public String buildSummaryCsv(String date, TeamType teamOrNull) { .append(',') .append(t.getPresent()) .append(',') + .append(t.getLate()) + .append(',') + .append(t.getPreArranged()) + .append(',') + .append(t.getAbsent()) + .append(',') .append(t.getTotal()) .append('\n'); } @@ -250,6 +268,12 @@ public String buildSummaryCsv(String date, TeamType teamOrNull) { .append(',') .append(s.getPresent()) .append(',') + .append(s.getLate()) + .append(',') + .append(s.getPreArranged()) + .append(',') + .append(s.getAbsent()) + .append(',') .append(s.getTotal()) .append('\n'); @@ -266,7 +290,7 @@ public String buildFullMatrixCsv(TeamType teamOrNull) { // 날짜가 없으면 헤더만 if (dates.isEmpty()) { - return "이름,출석률\n"; + return "이름,출석,지각,사전 승인,결석\n"; } // 2) 대상 사용자: 기존 정책과 동일 (CORE/LEAD/ORGANIZER), 팀 필터 적용 @@ -280,18 +304,16 @@ public String buildFullMatrixCsv(TeamType teamOrNull) { if (users.isEmpty()) { StringBuilder onlyHeader = new StringBuilder("이름"); for (LocalDate d : dates) onlyHeader.append(',').append(d); - onlyHeader.append(",출석률\n"); + onlyHeader.append(",출석,지각,사전 승인,결석\n"); return new String(onlyHeader.toString().getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); } - // 3) 날짜별 출석 맵 수집 (userId -> present) - List> presenceByDate = new ArrayList<>(dates.size()); + // 3) 날짜별 출석 상태 맵 수집 (userId -> status) + List> presenceByDate = new ArrayList<>(dates.size()); for (LocalDate d : dates) { - presenceByDate.add(getPresenceMap(d)); + presenceByDate.add(getStatusMap(d)); } - int totalSessions = dates.size(); - // 4) CSV 빌드 StringBuilder sb = new StringBuilder(); // Header @@ -299,32 +321,37 @@ public String buildFullMatrixCsv(TeamType teamOrNull) { for (LocalDate d : dates) { sb.append(',').append(d); } - sb.append(",출석률\n"); + sb.append(",출석,지각,사전 승인,결석\n"); // Rows for (User u : users) { sb.append(escape(u.getName())); Long uid = u.getId(); - int attended = 0; - - // 날짜별 O/X 채우면서 출석 횟수 카운트 - for (Map day : presenceByDate) { - boolean present = Boolean.TRUE.equals(day.getOrDefault(uid, false)); - if (present) attended++; - sb.append(',').append(present ? 'O' : 'X'); + int present = 0; + int late = 0; + int preArranged = 0; + int absent = 0; + + for (Map day : presenceByDate) { + AttendanceStatus status = day.getOrDefault(uid, AttendanceStatus.ABSENT); + switch (status) { + case PRESENT -> present++; + case LATE -> late++; + case PRE_ARRANGED -> preArranged++; + case ABSENT -> absent++; + } + sb.append(',').append(escape(status.getLabel())); } - // 출석률: 전체 회차 기준 (중간 합류 없음 가정) - double rate = attended * 100.0 / totalSessions; - sb.append(',') - .append(attended) - .append('/') - .append(totalSessions) - .append(" (") - .append(String.format(java.util.Locale.US, "%.1f", rate)) - .append("%)") + .append(present) + .append(',') + .append(late) + .append(',') + .append(preArranged) + .append(',') + .append(absent) .append('\n'); } @@ -344,23 +371,41 @@ protected Long ensureMeetingAndGetId(LocalDate date) { } /** - * 특정 날짜의 출석 맵(userId → present) + * 특정 날짜의 출석 맵(userId → status) */ @Transactional(readOnly = true) - protected Map getPresenceMap(LocalDate date) { - Map map = new HashMap<>(); + protected Map getStatusMap(LocalDate date) { + Map map = new HashMap<>(); var meetingOpt = meetingRepository.findByMeetingDate(date); if (meetingOpt.isEmpty()) return map; Long meetingId = meetingOpt.get().getId(); attendanceRecordRepository.findPresencePairsByMeetingId(meetingId).forEach(row -> { Long uid = ((Number) row[0]).longValue(); - Boolean present = (Boolean) row[1]; - map.put(uid, present != null && present); + map.put(uid, toAttendanceStatus(row[1])); }); return map; } + private long countStatus(List users, Map day, AttendanceStatus target) { + return users.stream() + .filter(u -> day.getOrDefault(u.getId(), AttendanceStatus.ABSENT) == target) + .count(); + } + + private AttendanceStatus toAttendanceStatus(Object raw) { + if (raw instanceof AttendanceStatus status) { + return status; + } + if (raw instanceof Enum enumValue) { + return AttendanceStatus.from(enumValue.name()); + } + if (raw instanceof String value) { + return AttendanceStatus.from(value); + } + return AttendanceStatus.ABSENT; + } + public record UserIdValidationResult(List validIds, List invalidIds) { } diff --git a/src/main/resources/db/migration/V20260317__alter_attendance_records_status.sql b/src/main/resources/db/migration/V20260317__alter_attendance_records_status.sql new file mode 100644 index 0000000..be12aad --- /dev/null +++ b/src/main/resources/db/migration/V20260317__alter_attendance_records_status.sql @@ -0,0 +1,18 @@ +ALTER TABLE attendance_records + ADD COLUMN IF NOT EXISTS status VARCHAR(32); + +UPDATE attendance_records +SET status = CASE + WHEN status IS NOT NULL THEN status + WHEN present THEN 'PRESENT' + ELSE 'ABSENT' +END; + +ALTER TABLE attendance_records + ALTER COLUMN status SET NOT NULL; + +ALTER TABLE attendance_records + ALTER COLUMN status SET DEFAULT 'ABSENT'; + +ALTER TABLE attendance_records + DROP COLUMN IF EXISTS present;