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
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sejongisc.backend.attendance.dto.AttendanceRequest;
import org.sejongisc.backend.attendance.dto.AttendanceResponse;
import org.sejongisc.backend.attendance.service.AttendanceService;
import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sejongisc.backend.attendance.dto.AttendanceCheckInRequest;
import org.sejongisc.backend.attendance.dto.AttendanceCheckInResponse;
import org.sejongisc.backend.attendance.dto.AttendanceRoundRequest;
import org.sejongisc.backend.attendance.dto.AttendanceRoundResponse;
import org.sejongisc.backend.attendance.dto.*;
import org.sejongisc.backend.attendance.service.AttendanceRoundService;
import org.sejongisc.backend.attendance.service.AttendanceService;
import org.sejongisc.backend.common.auth.jwt.JwtProvider;
Expand Down Expand Up @@ -251,4 +248,29 @@ public ResponseEntity<?> getAttendancesByRound(
}});
}
}

/**
* 라운드의 출석 상태 수정 (관리자용)
* PUT /api/attendance/rounds/{roundId}/attendances/{userId}
*/
@Operation(
summary = "출석 상태 수정",
description = "특정 라운드의 사용자 출석 상태를 수정합니다. (관리자 전용) " +
"출석(PRESENT), 지각(LATE), 결석(ABSENT), 공결(EXCUSED) 중 하나의 상태로 변경할 수 있습니다."
)
@PutMapping("/rounds/{roundId}/attendances/{userId}")
@PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')")
public ResponseEntity<AttendanceResponse> updateAttendanceStatus(
@PathVariable UUID roundId,
@PathVariable UUID userId,
@Valid @RequestBody AttendanceStatusUpdateRequest request) {
log.info("출석 상태 수정 요청: roundId={}, userId={}, 새로운상태={}, 사유={}",
roundId, userId, request.getStatus(), request.getReason());

AttendanceResponse response = attendanceService.updateAttendanceStatusByRound(roundId, userId, request.getStatus(), request.getReason());

log.info("출석 상태 수정 완료: roundId={}, userId={}", roundId, userId);

return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sejongisc.backend.attendance.dto.AttendanceSessionRequest;
import org.sejongisc.backend.attendance.dto.AttendanceSessionResponse;
import org.sejongisc.backend.attendance.dto.SessionLocationUpdateRequest;
import org.sejongisc.backend.attendance.entity.SessionStatus;
import org.sejongisc.backend.attendance.dto.*;
import org.sejongisc.backend.attendance.service.AttendanceSessionService;
import org.sejongisc.backend.attendance.service.SessionUserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
Expand All @@ -29,6 +27,7 @@
public class AttendanceSessionController {

private final AttendanceSessionService attendanceSessionService;
private final SessionUserService sessionUserService;

/**
* 출석 세션 생성 (관리자용)
Expand Down Expand Up @@ -246,4 +245,74 @@ public ResponseEntity<Void> deleteSession(@PathVariable UUID sessionId) {

return ResponseEntity.noContent().build();
}

/**
* 세션에 사용자 추가 (관리자용)
* - 사용자를 세션에 추가
* - 중복 참여 방지
* - 자동으로 이전 라운드들에 결석 처리
*/
@Operation(
summary = "세션에 사용자 추가",
description = "사용자를 출석 세션에 추가합니다. (관리자 전용) " +
"이미 참여 중인 사용자는 추가할 수 없으며, 추가 시 이전 라운드들은 자동으로 결석 처리됩니다."
)
@PostMapping("/{sessionId}/users")
@PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')")
public ResponseEntity<SessionUserResponse> addUserToSession(
@PathVariable UUID sessionId,
@Valid @RequestBody SessionUserRequest request) {
log.info("세션에 사용자 추가: 세션ID={}, 사용자ID={}", sessionId, request.getUserId());

SessionUserResponse response = sessionUserService.addUserToSession(sessionId, request.getUserId());

log.info("세션에 사용자 추가 완료: 세션ID={}, 사용자명={}", sessionId, response.getUserName());

return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

/**
* 세션에서 사용자 제거 (관리자용)
* - 사용자를 세션에서 제거
* - 해당 사용자의 모든 출석 기록도 함께 삭제
*/
@Operation(
summary = "세션에서 사용자 제거",
description = "사용자를 출석 세션에서 제거합니다. (관리자 전용) " +
"⚠️ 주의: 해당 사용자의 해당 세션에서의 모든 출석 기록이 함께 삭제됩니다."
)
@DeleteMapping("/{sessionId}/users/{userId}")
@PreAuthorize("hasRole('PRESIDENT') or hasRole('VICE_PRESIDENT')")
public ResponseEntity<Void> removeUserFromSession(
@PathVariable UUID sessionId,
@PathVariable UUID userId) {
log.info("세션에서 사용자 제거: 세션ID={}, 사용자ID={}", sessionId, userId);

sessionUserService.removeUserFromSession(sessionId, userId);

log.info("세션에서 사용자 제거 완료: 세션ID={}, 사용자ID={}", sessionId, userId);

return ResponseEntity.noContent().build();
}

/**
* 세션 참여자 조회
* - 세션에 참여 중인 모든 사용자 목록
* - 참여 순서대로 정렬
*/
@Operation(
summary = "세션 참여자 조회",
description = "세션에 참여 중인 모든 사용자 목록을 조회합니다. " +
"각 사용자의 ID, 이름, 참여 시간 등의 정보가 포함됩니다."
)
@GetMapping("/{sessionId}/users")
public ResponseEntity<List<SessionUserResponse>> getSessionUsers(@PathVariable UUID sessionId) {
log.info("세션 참여자 조회: 세션ID={}", sessionId);

List<SessionUserResponse> users = sessionUserService.getSessionUsers(sessionId);

log.info("세션 참여자 조회 완료: 세션ID={}, 참여자 수={}", sessionId, users.size());

return ResponseEntity.ok(users);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,8 @@ public static class LocationInfo {

@Schema(description = "경도", example = "127.0751")
private Double lng;

@Schema(description = "출석 인정 반경 (미터)", example = "100")
private Integer radiusMeters;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.sejongisc.backend.attendance.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* 출석 상태 수정 요청
* - POST /api/attendance/rounds/{roundId}/attendances/{userId}
* - body: {status, reason}
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AttendanceStatusUpdateRequest {

@NotBlank(message = "출석 상태는 필수입니다 (PRESENT, LATE, ABSENT, EXCUSED)")
private String status;

private String reason;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.sejongisc.backend.attendance.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.sejongisc.backend.attendance.entity.AttendanceStatus;

import java.util.UUID;

/**
* 회차별 출석 인원 정보
* - 요청: GET /api/attendance/rounds/{roundId}/attendances
* - 응답: [{userId, userName, status}, ...]
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoundAttendanceResponse {

@JsonProperty("userId")
private UUID userId;

@JsonProperty("userName")
private String userName;

@JsonProperty("status")
private AttendanceStatus status;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.sejongisc.backend.attendance.dto;

import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.UUID;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionUserRequest {

@NotNull(message = "사용자 ID는 필수입니다")
private UUID userId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.sejongisc.backend.attendance.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.UUID;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionUserResponse {

private UUID sessionUserId;

private UUID userId;

private UUID sessionId;

private String userName;

private LocalDateTime createdAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.sejongisc.backend.attendance.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity;
import org.sejongisc.backend.user.entity.User;

import java.util.UUID;

/**
* 세션에 참여하는 사용자를 관리하는 엔티티
*
* 예: "금융동아리 2024년 정기 모임" 세션에 참여하는 팀원들
* - 참여자 추가/삭제 관리
* - 세션별 참여자 조회
* - 중간 참여시 이전 라운드는 자동으로 결석 처리
*/
@Entity
@Table(
name = "session_user",
uniqueConstraints = @UniqueConstraint(
columnNames = {"session_id", "user_id"},
name = "uk_session_user"
),
indexes = {
@Index(name = "idx_session_id", columnList = "session_id"),
@Index(name = "idx_user_id", columnList = "user_id")
}
)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SessionUser extends BasePostgresEntity {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "session_user_id", columnDefinition = "uuid")
private UUID sessionUserId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "session_id", nullable = false)
private AttendanceSession attendanceSession;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Column(name = "user_name", length = 100, nullable = false)
private String userName; // 저장 시점의 user.name 캐시 (나중에 user.name이 변경되어도 유지)

/**
* toString 오버라이드 (순환 참조 방지)
*/
@Override
public String toString() {
return "SessionUser{" +
"sessionUserId=" + sessionUserId +
", sessionId=" + (attendanceSession != null ? attendanceSession.getAttendanceSessionId() : null) +
", userId=" + (user != null ? user.getUserId() : null) +
", userName='" + userName + '\'' +
", createdDate=" + getCreatedDate() +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,8 @@ Long countByAttendanceSessionAndStatus(@Param("session") AttendanceSession sessi
@Query("SELECT a FROM Attendance a WHERE a.attendanceRound.roundId = :roundId AND a.user = :user")
Optional<Attendance> findByAttendanceRound_RoundIdAndUser(@Param("roundId") UUID roundId, @Param("user") User user);

// 세션의 특정 사용자 모든 출석 기록 조회 (라운드 관계없이)
@Query("SELECT a FROM Attendance a WHERE a.attendanceSession = :session AND a.user.userId = :userId")
List<Attendance> findAllBySessionAndUserId(@Param("session") AttendanceSession session, @Param("userId") UUID userId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,16 @@ public interface AttendanceRoundRepository extends JpaRepository<AttendanceRound
"ORDER BY round_date ASC " +
"LIMIT 1 OFFSET :offset", nativeQuery = true)
Optional<AttendanceRound> findNthRoundInSession(@Param("sessionId") UUID sessionId, @Param("offset") int offset);

/**
* 세션의 특정 날짜 이전의 모든 라운드 조회
* - 세션에 유저 추가 시, 이전 라운드들에 자동으로 결석 처리하기 위해 사용
*/
@Query("SELECT r FROM AttendanceRound r " +
"WHERE r.attendanceSession.attendanceSessionId = :sessionId " +
"AND r.roundDate < :date " +
"ORDER BY r.roundDate ASC")
List<AttendanceRound> findBySession_SessionIdAndRoundDateBefore(
@Param("sessionId") UUID sessionId,
@Param("date") LocalDate date);
}
Loading