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 .github/workflows/_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
java-version:
description: 'Java version to use'
required: false
default: '17'
default: '21'
type: string
run-tests:
description: 'Run tests'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
name: Build & Test
uses: ./.github/workflows/_build.yml
with:
java-version: '17'
java-version: '21'
run-tests: true
generate-coverage: true
publish-build-scan: true
Expand All @@ -41,7 +41,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
java-version: 21
cache: gradle

- name: Setup Gradle
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
name: Build & Test
uses: ./.github/workflows/_build.yml
with:
java-version: '17'
java-version: '21'
run-tests: true
generate-coverage: true
publish-build-scan: true
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
name: Build & Test
uses: ./.github/workflows/_build.yml
with:
java-version: '17'
java-version: '21'
run-tests: true
generate-coverage: false
publish-build-scan: true
Expand Down Expand Up @@ -46,7 +46,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
cache: gradle

- name: Setup Gradle
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package me.chan99k.learningmanager.authorization;

import java.util.List;
import java.util.Optional;

import org.springframework.stereotype.Service;

import me.chan99k.learningmanager.attendance.Attendance;
import me.chan99k.learningmanager.attendance.AttendanceQueryRepository;
import me.chan99k.learningmanager.attendance.CorrectionRequested;
import me.chan99k.learningmanager.course.CourseRole;
import me.chan99k.learningmanager.member.Member;
import me.chan99k.learningmanager.member.MemberQueryRepository;
import me.chan99k.learningmanager.member.SystemRole;
import me.chan99k.learningmanager.session.Session;
import me.chan99k.learningmanager.session.SessionQueryRepository;

@Service("attendanceSecurity")
public class AttendanceSecurity {
private final AttendanceQueryRepository attendanceQueryRepository;
private final SessionQueryRepository sessionQueryRepository;
private final MemberQueryRepository memberQueryRepository;
private final CourseAuthorizationPort courseAuthorizationPort;

public AttendanceSecurity(AttendanceQueryRepository attendanceQueryRepository,
SessionQueryRepository sessionQueryRepository, MemberQueryRepository memberQueryRepository,
CourseAuthorizationPort courseAuthorizationPort) {
this.attendanceQueryRepository = attendanceQueryRepository;
this.sessionQueryRepository = sessionQueryRepository;
this.memberQueryRepository = memberQueryRepository;
this.courseAuthorizationPort = courseAuthorizationPort;
}

public boolean canRequestCorrection(String attendanceId, Long memberId) {
Optional<Attendance> foundAttendance = attendanceQueryRepository.findById(attendanceId);
if (foundAttendance.isEmpty()) {
return false;
}

// SystemRole 우선 확인
Optional<Member> foundMember = memberQueryRepository.findById(memberId);
if (foundMember.isEmpty()) {
return false;
}

var member = foundMember.get();
var systemRole = member.getRole();
if (systemRole == SystemRole.ADMIN || systemRole == SystemRole.REGISTRAR) {
return true;
}

var attendance = foundAttendance.get();
Optional<Session> foundSession = sessionQueryRepository.findById(attendance.getSessionId());
if (foundSession.isEmpty()) {
return false;
}

var courseId = foundSession.get().getCourseId();
if (courseId == null) {
return false;
}

// CourseRole 확인
return courseAuthorizationPort.hasAnyRole(
memberId,
courseId,
List.of(CourseRole.MENTOR, CourseRole.MANAGER, CourseRole.LEAD_MANAGER)
);
}

public boolean canApproveCorrection(String attendanceId, Long memberId) {
Optional<Attendance> foundAttendance = attendanceQueryRepository.findById(attendanceId);
if (foundAttendance.isEmpty()) {
return false;
}

var attendance = foundAttendance.get();
CorrectionRequested pending;
try {
pending = attendance.getPendingRequest();
} catch (IllegalStateException e) {
return false; // 대기중인 수정 요청 없음
}

Optional<Member> foundMember = memberQueryRepository.findById(memberId);
if (foundMember.isEmpty()) {
return false;
}

var requestedApprover = foundMember.get();
var systemRole = requestedApprover.getRole();
if (systemRole == SystemRole.ADMIN || systemRole == SystemRole.REGISTRAR) {
return true;
}

Optional<Session> foundSession = sessionQueryRepository.findById(attendance.getSessionId());
if (foundSession.isEmpty()) {
return false;
}

var courseId = foundSession.get().getCourseId();
if (courseId == null) {
return false; // 독립 세션은 시스템 권한으로만 승인 가능 (위에서 이미 처리됨)
}

boolean isLeadManager = courseAuthorizationPort.hasRole(memberId, courseId, CourseRole.LEAD_MANAGER);
if (isLeadManager) {
return true; // 본인의 수정 요청 포함 모두 승인 가능
}

if (pending.requestedBy().equals(memberId)) {
return false; // 본인의 수정 요청을 스스로 승인하는 것을 방지
}

return hasHigherCourseRoleThan(memberId, pending.requestedBy(), courseId);
}

private boolean hasHigherCourseRoleThan(Long approverId, Long requesterId, Long courseId) {
boolean requesterIsMentor = courseAuthorizationPort
.hasRole(requesterId, courseId, CourseRole.MENTOR);
boolean requesterIsManager = courseAuthorizationPort
.hasRole(requesterId, courseId, CourseRole.MANAGER);

if (requesterIsMentor) {
return courseAuthorizationPort.hasAnyRole(
approverId, courseId,
List.of(CourseRole.MANAGER, CourseRole.LEAD_MANAGER)
);
}

if (requesterIsManager) {
return courseAuthorizationPort.hasRole(
approverId, courseId, CourseRole.LEAD_MANAGER
);
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.List;
import java.util.Optional;

import org.bson.types.ObjectId;
import org.springframework.stereotype.Repository;

import me.chan99k.learningmanager.adapter.persistence.attendance.documents.AttendanceDocument;
Expand All @@ -19,6 +20,20 @@ public AttendanceQueryAdapter(AttendanceMongoRepository repository) {
this.repository = repository;
}

@Override
public Optional<Attendance> findById(String attendanceId) {
ObjectId objectId;
try {
objectId = new ObjectId(attendanceId);
} catch (IllegalArgumentException e) {
return Optional.empty(); // 서비스 레이어에서 처리
}

return repository
.findById(objectId)
.map(AttendanceDocument::toDomain);
}

@Override
public Optional<Attendance> findBySessionIdAndMemberId(Long sessionId, Long memberId) {
return repository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
import me.chan99k.learningmanager.attendance.Attendance;
import me.chan99k.learningmanager.attendance.AttendanceEvent;
import me.chan99k.learningmanager.attendance.AttendanceStatus;
import me.chan99k.learningmanager.attendance.CheckedIn;
import me.chan99k.learningmanager.attendance.CheckedOut;

@Document(collection = "attendances")
@CompoundIndex(name = "session_member_idx",
Expand Down Expand Up @@ -121,24 +119,4 @@ public Long getCreatedBy() {
return createdBy;
}

public record AttendanceEventDocument(
String type,
Instant timestamp
) {
public static AttendanceEventDocument from(AttendanceEvent event) {
String type = event instanceof CheckedIn ? "CheckedIn" : "CheckedOut";

return new AttendanceEventDocument(type, event.timestamp());
}

public AttendanceEvent toDomain() {
return switch (this.type) {
case "CheckedIn" -> new CheckedIn(this.timestamp);
case "CheckedOut" -> new CheckedOut(this.timestamp);
default -> throw new IllegalArgumentException("[System] 유효하지 않은 출석 이벤트 타입입니다: " + this.type);
};
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package me.chan99k.learningmanager.adapter.persistence.attendance.documents;

import java.time.Instant;

import me.chan99k.learningmanager.attendance.AttendanceEvent;
import me.chan99k.learningmanager.attendance.AttendanceStatus;
import me.chan99k.learningmanager.attendance.CheckedIn;
import me.chan99k.learningmanager.attendance.CheckedOut;
import me.chan99k.learningmanager.attendance.CorrectionRejected;
import me.chan99k.learningmanager.attendance.CorrectionRequested;
import me.chan99k.learningmanager.attendance.StatusCorrected;

public record AttendanceEventDocument(
String type,
Instant timestamp,
AttendanceStatus previousStatus,
AttendanceStatus newStatus,
String reason,
Long actorId,
String rejectionReason
) {
public static AttendanceEventDocument from(AttendanceEvent event) {
return switch (event) {
case CheckedIn e -> new AttendanceEventDocument(
"CheckedIn", e.timestamp(),
null, null, null, null, null
);

case CheckedOut e -> new AttendanceEventDocument(
"CheckedOut", e.timestamp(),
null, null, null, null, null
);

case CorrectionRequested e -> new AttendanceEventDocument(
"CorrectionRequested", e.timestamp(),
e.currentStatus(), e.requestedStatus(), e.reason(), e.requestedBy(), null
);

case StatusCorrected e -> new AttendanceEventDocument(
"StatusCorrected", e.timestamp(),
e.previousStatus(), e.newStatus(), e.reason(), e.correctedBy(), null
);

case CorrectionRejected e -> new AttendanceEventDocument(
"CorrectionRejected", e.timestamp(),
null, null, null, e.rejectedBy(), e.rejectionReason()
);
};
}

public AttendanceEvent toDomain() {
return switch (this.type) {
case "CheckedIn" -> new CheckedIn(this.timestamp);

case "CheckedOut" -> new CheckedOut(this.timestamp);

case "CorrectionRequested" -> new CorrectionRequested(
this.timestamp, this.previousStatus, this.newStatus,
this.reason, this.actorId
);

case "StatusCorrected" -> new StatusCorrected(
this.timestamp, this.previousStatus, this.newStatus,
this.reason, this.actorId
);

case "CorrectionRejected" -> new CorrectionRejected(
this.timestamp, this.rejectionReason, this.actorId
);

default -> throw new IllegalArgumentException(
"[System] 유효하지 않은 출석 이벤트 타입입니다: " + this.type
);
};
}
}
Loading
Loading