Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion database_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,48 +29,48 @@
@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<ApiResponse<Map<String, Object>, Void>> okUpdated(long updated, List<Long> ignored) {
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.ATTENDANCE_ALL_SET_SUCCESS, Map.of("updated", updated, "ignoredUserIds", ignored)));
}

/* ===== Meetings(λ‚ μ§œ) λͺ©λ‘ ===== */
@PreAuthorize(CORE_OR_HIGHER_RULE)
@GetMapping
public ResponseEntity<ApiResponse<DateListResponse, Void>> 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<ApiResponse<DateListResponse, Void>> 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<ApiResponse<DateListResponse, Void>> deleteDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
service.deleteDate(date.toString());
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_DELETED_SUCCESS, new DateListResponse(service.getDates())));
}

/* ===== νŒ€ λͺ©λ‘ (λ¦¬λ“œ=본인 νŒ€λ§Œ / κ΄€λ¦¬μž=전체) ===== */
@PreAuthorize(CORE_OR_HIGHER_RULE)
@GetMapping("/teams")
public ResponseEntity<ApiResponse<List<TeamResponse>, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) {
List<TeamResponse> list = service.isLeadScoped(me.getRole(), me.getTeam())
List<TeamResponse> list = service.isTeamScoped(me.getRole(), me.getTeam())
? service.getTeamsForLead(service.resolveEffectiveTeam(me.getRole(), me.getTeam(), null))
: service.getTeamsForOrganizerOrAdmin();

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

/* ===== νŠΉμ • λ‚ μ§œμ˜ νŒ€μ›+ν˜„μž¬ μΆœμ„ μƒνƒœ 쑰회 (λ¦¬λ“œ=본인 νŒ€λ§Œ) ===== */
// ν”„λ‘ νŠΈκ°€ μ²΄ν¬λ°•μŠ€ μ±„μš°κΈ° 전에 ν•„μš”ν•œ λͺ©λ‘/μƒνƒœ
@PreAuthorize(CORE_OR_HIGHER_RULE)
@GetMapping("/{date}/members")
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 // κ΄€λ¦¬μžλ§Œ μ‚¬μš©, λ¦¬λ“œλŠ” λ¬΄μ‹œ
) {
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<ApiResponse<Map<String, Object>, 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()
);
return okUpdated(result.updatedCount(), result.ignoredUserIds());
}

/* ===== λ‚ μ§œ μš”μ•½(JSON) ===== */
@PreAuthorize(CORE_OR_HIGHER_RULE)
@GetMapping("/{date}/summary")
public ResponseEntity<ApiResponse<DaySummaryResponse, Void>> 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);
Expand All @@ -113,6 +116,7 @@ public ResponseEntity<ApiResponse<DaySummaryResponse, Void>> summary(@Authentica
}

/* ===== λ‚ μ§œ μš”μ•½(CSV) ===== */
@PreAuthorize(CORE_OR_HIGHER_RULE)
@GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8")
public ResponseEntity<String> 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);
Expand All @@ -122,6 +126,7 @@ public ResponseEntity<String> summaryCsv(@AuthenticationPrincipal CustomUserDeta
.body(csv);
}

@PreAuthorize(CORE_OR_HIGHER_RULE)
@GetMapping(value = "/summary.csv", produces = "text/csv; charset=UTF-8")
public ResponseEntity<String> summaryCsvAll(
@AuthenticationPrincipal CustomUserDetails me,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long> userIds,
@NotNull Boolean present
@NotNull AttendanceStatus status
) {
public List<Long> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public class DaySummaryResponse {
private String date;
private List<TeamSummary> perTeam;
private long present;
private long late;
private long preArranged;
private long absent;
private long total;

@Getter
Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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.*;
Expand Down Expand Up @@ -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;
Expand All @@ -51,4 +53,4 @@ public class AttendanceRecord {
void onUpdate() {
updatedAt = OffsetDateTime.now();
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
@Repository
public interface AttendanceRecordRepository extends JpaRepository<AttendanceRecord, Long> {

/* 쑰회: νŠΉμ • 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
""")
Expand All @@ -26,12 +26,12 @@ public interface AttendanceRecordRepository extends JpaRepository<AttendanceReco
/* 배치 μ—…μ„œνŠΈ(ON CONFLICT) β€” meeting_id κΈ°μ€€ */
@Modifying
@Query(value = """
INSERT INTO public.attendance_records (meeting_id, user_id, present, updated_at)
SELECT :meetingId, uid, :present, NOW()
INSERT INTO public.attendance_records (meeting_id, user_id, status, updated_at)
SELECT :meetingId, uid, CAST(:status AS varchar), NOW()
FROM unnest(CAST(:userIds AS bigint[])) AS uid
ON CONFLICT (meeting_id, user_id)
DO UPDATE SET present = EXCLUDED.present, updated_at = NOW()
DO UPDATE SET status = EXCLUDED.status, updated_at = NOW()
""", nativeQuery = true)
int upsertBatchByMeetingId(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, // πŸ‘ˆ λ°°μ—΄
@Param("present") boolean present);
}
@Param("status") String status);
}
Loading
Loading