Skip to content

Commit 6787fc0

Browse files
authored
Merge pull request #324 from GDGoCINHA/develop
Merge Dev
2 parents 5934e6b + bcca779 commit 6787fc0

9 files changed

Lines changed: 193 additions & 73 deletions

File tree

database_schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ CREATE TABLE IF NOT EXISTS attendance_records (
131131
id BIGSERIAL PRIMARY KEY,
132132
meeting_id BIGINT NOT NULL REFERENCES meetings(id),
133133
user_id BIGINT NOT NULL REFERENCES users(id),
134-
present BOOLEAN NOT NULL DEFAULT FALSE,
134+
status VARCHAR(32) NOT NULL DEFAULT 'ABSENT',
135135
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
136136
updated_by BIGINT REFERENCES users(id),
137137
UNIQUE (meeting_id, user_id)

src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,48 +29,48 @@
2929
@RestController
3030
@RequestMapping("/api/v1/core-attendance/meetings")
3131
@RequiredArgsConstructor
32-
@PreAuthorize(CoreAttendanceController.LEAD_OR_HIGHER_RULE)
3332
public class CoreAttendanceController {
3433

35-
public static final String LEAD_OR_HIGHER_RULE =
34+
public static final String CORE_OR_HIGHER_RULE =
3635
"@accessGuard.check(authentication,"
3736
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
38-
+ "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))";
39-
public static final String ORGANIZER_OR_HIGHER_RULE =
37+
+ "T(inha.gdgoc.domain.user.enums.UserRole).CORE))";
38+
public static final String LEAD_OR_HIGHER_RULE =
4039
"@accessGuard.check(authentication,"
4140
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
42-
+ "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER))";
43-
41+
+ "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))";
4442
private final CoreAttendanceService service;
4543

4644
private static ResponseEntity<ApiResponse<Map<String, Object>, Void>> okUpdated(long updated, List<Long> ignored) {
4745
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.ATTENDANCE_ALL_SET_SUCCESS, Map.of("updated", updated, "ignoredUserIds", ignored)));
4846
}
4947

5048
/* ===== Meetings(날짜) 목록 ===== */
49+
@PreAuthorize(CORE_OR_HIGHER_RULE)
5150
@GetMapping
5251
public ResponseEntity<ApiResponse<DateListResponse, Void>> listDates() {
5352
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_LIST_RETRIEVED_SUCCESS, new DateListResponse(service.getDates())));
5453
}
5554

56-
@PreAuthorize(ORGANIZER_OR_HIGHER_RULE)
55+
@PreAuthorize(LEAD_OR_HIGHER_RULE)
5756
@PostMapping
5857
public ResponseEntity<ApiResponse<DateListResponse, Void>> createDate(@Valid @RequestBody CreateDateRequest request) {
5958
service.addDate(request.getDate());
6059
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_CREATED_SUCCESS, new DateListResponse(service.getDates())));
6160
}
6261

63-
@PreAuthorize(ORGANIZER_OR_HIGHER_RULE)
62+
@PreAuthorize(LEAD_OR_HIGHER_RULE)
6463
@DeleteMapping("/{date}")
6564
public ResponseEntity<ApiResponse<DateListResponse, Void>> deleteDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
6665
service.deleteDate(date.toString());
6766
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_DELETED_SUCCESS, new DateListResponse(service.getDates())));
6867
}
6968

7069
/* ===== 팀 목록 (리드=본인 팀만 / 관리자=전체) ===== */
70+
@PreAuthorize(CORE_OR_HIGHER_RULE)
7171
@GetMapping("/teams")
7272
public ResponseEntity<ApiResponse<List<TeamResponse>, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) {
73-
List<TeamResponse> list = service.isLeadScoped(me.getRole(), me.getTeam())
73+
List<TeamResponse> list = service.isTeamScoped(me.getRole(), me.getTeam())
7474
? service.getTeamsForLead(service.resolveEffectiveTeam(me.getRole(), me.getTeam(), null))
7575
: service.getTeamsForOrganizerOrAdmin();
7676

@@ -80,31 +80,34 @@ public ResponseEntity<ApiResponse<List<TeamResponse>, PageMeta>> getTeams(@Authe
8080

8181
/* ===== 특정 날짜의 팀원+현재 출석 상태 조회 (리드=본인 팀만) ===== */
8282
// 프론트가 체크박스 채우기 전에 필요한 목록/상태
83+
@PreAuthorize(CORE_OR_HIGHER_RULE)
8384
@GetMapping("/{date}/members")
8485
public ResponseEntity<ApiResponse<List<Map<String, Object>>, Void>> membersOfMeeting(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team // 관리자만 사용, 리드는 무시
8586
) {
8687
TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team);
8788
var list = service.getMembersWithPresence(date.toString(), effectiveTeam);
88-
// list 원소 예시: { "userId": "123", "name": "홍길동", "present": true, "lastModifiedAt": "..." }
89+
// list 원소 예시: { "userId": "123", "name": "홍길동", "status": "PRESENT", "lastModifiedAt": "..." }
8990
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list));
9091
}
9192

9293
/* ===== 특정 날짜 출석 일괄 저장 (멱등 스냅샷) ===== */
93-
// Body: { "userIds": ["1","2",...], "present": true } → presentUserIds만 보내는 구조로도 쉽게 변환 가능
94+
// Body: { "userIds": ["1","2",...], "status": "PRESENT" }
95+
@PreAuthorize(LEAD_OR_HIGHER_RULE)
9496
@PutMapping("/{date}/attendance")
9597
public ResponseEntity<ApiResponse<Map<String, Object>, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestBody @Valid SetAttendanceRequest req) {
9698
var userIds = req.safeUserIds();
9799
CoreAttendanceService.AttendanceUpdateResult result = service.saveAttendanceSnapshot(
98100
date.toString(),
99101
userIds,
100-
req.presentValue(),
102+
req.statusValue(),
101103
me.getRole(),
102104
me.getTeam()
103105
);
104106
return okUpdated(result.updatedCount(), result.ignoredUserIds());
105107
}
106108

107109
/* ===== 날짜 요약(JSON) ===== */
110+
@PreAuthorize(CORE_OR_HIGHER_RULE)
108111
@GetMapping("/{date}/summary")
109112
public ResponseEntity<ApiResponse<DaySummaryResponse, Void>> summary(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) {
110113
TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team);
@@ -113,6 +116,7 @@ public ResponseEntity<ApiResponse<DaySummaryResponse, Void>> summary(@Authentica
113116
}
114117

115118
/* ===== 날짜 요약(CSV) ===== */
119+
@PreAuthorize(CORE_OR_HIGHER_RULE)
116120
@GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8")
117121
public ResponseEntity<String> summaryCsv(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) {
118122
TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team);
@@ -122,6 +126,7 @@ public ResponseEntity<String> summaryCsv(@AuthenticationPrincipal CustomUserDeta
122126
.body(csv);
123127
}
124128

129+
@PreAuthorize(CORE_OR_HIGHER_RULE)
125130
@GetMapping(value = "/summary.csv", produces = "text/csv; charset=UTF-8")
126131
public ResponseEntity<String> summaryCsvAll(
127132
@AuthenticationPrincipal CustomUserDetails me,
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
package inha.gdgoc.domain.core.attendance.dto.request;
22

3+
import inha.gdgoc.domain.core.attendance.enums.AttendanceStatus;
34
import jakarta.validation.constraints.NotNull;
45
import java.util.List;
56
import java.util.Objects;
67

78
public record SetAttendanceRequest(
89
@NotNull List<Long> userIds,
9-
@NotNull Boolean present
10+
@NotNull AttendanceStatus status
1011
) {
1112
public List<Long> safeUserIds() {
1213
return userIds == null ? List.of() : userIds.stream().filter(Objects::nonNull).toList();
1314
}
14-
public boolean presentValue() {
15-
return Boolean.TRUE.equals(present);
15+
16+
public AttendanceStatus statusValue() {
17+
return status;
1618
}
17-
}
19+
}

src/main/java/inha/gdgoc/domain/core/attendance/dto/response/DaySummaryResponse.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public class DaySummaryResponse {
1414
private String date;
1515
private List<TeamSummary> perTeam;
1616
private long present;
17+
private long late;
18+
private long preArranged;
19+
private long absent;
1720
private long total;
1821

1922
@Getter
@@ -23,6 +26,9 @@ public static class TeamSummary {
2326
private String teamId;
2427
private String teamName;
2528
private long present;
29+
private long late;
30+
private long preArranged;
31+
private long absent;
2632
private long total;
2733
}
28-
}
34+
}

src/main/java/inha/gdgoc/domain/core/attendance/entity/AttendanceRecord.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package inha.gdgoc.domain.core.attendance.entity;
22

3+
import inha.gdgoc.domain.core.attendance.enums.AttendanceStatus;
34
import inha.gdgoc.domain.user.entity.User;
45
import jakarta.persistence.*;
56
import lombok.*;
@@ -33,8 +34,9 @@ public class AttendanceRecord {
3334
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_attendance_user"))
3435
private User user;
3536

36-
@Column(name = "present", nullable = false)
37-
private boolean present;
37+
@Enumerated(EnumType.STRING)
38+
@Column(name = "status", nullable = false, length = 32)
39+
private AttendanceStatus status;
3840

3941
@Column(name = "updated_at", nullable = false)
4042
private OffsetDateTime updatedAt;
@@ -51,4 +53,4 @@ public class AttendanceRecord {
5153
void onUpdate() {
5254
updatedAt = OffsetDateTime.now();
5355
}
54-
}
56+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package inha.gdgoc.domain.core.attendance.enums;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public enum AttendanceStatus {
8+
PRESENT("출석"),
9+
LATE("지각"),
10+
PRE_ARRANGED("사전 승인"),
11+
ABSENT("결석");
12+
13+
private final String label;
14+
15+
AttendanceStatus(String label) {
16+
this.label = label;
17+
}
18+
19+
@JsonCreator
20+
public static AttendanceStatus from(String raw) {
21+
if (raw == null) {
22+
return null;
23+
}
24+
25+
String normalized = raw.trim()
26+
.replace('-', '_')
27+
.replace(' ', '_')
28+
.toUpperCase();
29+
30+
if (normalized.isBlank()) {
31+
return null;
32+
}
33+
34+
return switch (normalized) {
35+
case "PRESENT" -> PRESENT;
36+
case "LATE" -> LATE;
37+
case "PRE_ARRANGED", "PREARRANGED", "AGREED", "EXCUSED" -> PRE_ARRANGED;
38+
case "ABSENT" -> ABSENT;
39+
default -> throw new IllegalArgumentException("Unknown attendance status: " + raw);
40+
};
41+
}
42+
}

src/main/java/inha/gdgoc/domain/core/attendance/repository/AttendanceRecordRepository.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
@Repository
1313
public interface AttendanceRecordRepository extends JpaRepository<AttendanceRecord, Long> {
1414

15-
/* 조회: 특정 meetingId의 (userId, present) 목록 */
15+
/* 조회: 특정 meetingId의 (userId, status) 목록 */
1616
@Query("""
17-
select ar.user.id, ar.present
17+
select ar.user.id, ar.status
1818
from AttendanceRecord ar
1919
where ar.meeting.id = :meetingId
2020
""")
@@ -26,12 +26,12 @@ public interface AttendanceRecordRepository extends JpaRepository<AttendanceReco
2626
/* 배치 업서트(ON CONFLICT) — meeting_id 기준 */
2727
@Modifying
2828
@Query(value = """
29-
INSERT INTO public.attendance_records (meeting_id, user_id, present, updated_at)
30-
SELECT :meetingId, uid, :present, NOW()
29+
INSERT INTO public.attendance_records (meeting_id, user_id, status, updated_at)
30+
SELECT :meetingId, uid, CAST(:status AS varchar), NOW()
3131
FROM unnest(CAST(:userIds AS bigint[])) AS uid
3232
ON CONFLICT (meeting_id, user_id)
33-
DO UPDATE SET present = EXCLUDED.present, updated_at = NOW()
33+
DO UPDATE SET status = EXCLUDED.status, updated_at = NOW()
3434
""", nativeQuery = true)
3535
int upsertBatchByMeetingId(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, // 👈 배열
36-
@Param("present") boolean present);
37-
}
36+
@Param("status") String status);
37+
}

0 commit comments

Comments
 (0)