Merge commit '8b48e281f7e860af435ae35b16c4edeb139286dc' into work/tasks
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
public enum TokenPurpose {
|
||||
ACTIVATION,
|
||||
PASSWORD_RESET
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
public record AccountCreation(long userId, boolean deliverySucceeded) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
|
||||
public record CreateAccountCommand(
|
||||
String email,
|
||||
String displayName,
|
||||
GlobalRole role,
|
||||
String studentCode,
|
||||
LocalDate internshipStart,
|
||||
LocalDate internshipEnd) {
|
||||
}
|
||||
@@ -77,6 +77,22 @@ public class AppUser {
|
||||
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
|
||||
}
|
||||
|
||||
public static AppUser pending(
|
||||
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
||||
return new AppUser(
|
||||
email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now);
|
||||
}
|
||||
|
||||
public void activate(String encodedPassword, Instant now) {
|
||||
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
||||
throw new IllegalStateException("Only a pending account can activate");
|
||||
}
|
||||
passwordHash = encodedPassword;
|
||||
accountStatus = AccountStatus.ACTIVE;
|
||||
activatedAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -100,4 +116,8 @@ public class AppUser {
|
||||
public AccountStatus getAccountStatus() {
|
||||
return accountStatus;
|
||||
}
|
||||
|
||||
public Instant getActivatedAt() {
|
||||
return activatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +58,41 @@ public class InternProfile {
|
||||
|
||||
protected InternProfile() {
|
||||
}
|
||||
|
||||
private InternProfile(
|
||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||
this.userId = userId;
|
||||
this.studentCode = studentCode;
|
||||
this.internshipStartDate = internshipStartDate;
|
||||
this.internshipEndDate = internshipEndDate;
|
||||
this.internshipStatus = InternshipStatus.NOT_STARTED;
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
public static InternProfile notStarted(
|
||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now);
|
||||
}
|
||||
|
||||
public void activate(Instant now) {
|
||||
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
||||
throw new IllegalStateException("Only a not-started internship can activate");
|
||||
}
|
||||
internshipStatus = InternshipStatus.ACTIVE;
|
||||
activatedAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public InternshipStatus getInternshipStatus() {
|
||||
return internshipStatus;
|
||||
}
|
||||
|
||||
public LocalDate getInternshipStartDate() {
|
||||
return internshipStartDate;
|
||||
}
|
||||
|
||||
public LocalDate getInternshipEndDate() {
|
||||
return internshipEndDate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.lab.labtimesheet.feature.account.model.entity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_action_tokens")
|
||||
public class UserActionToken {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 24)
|
||||
private TokenPurpose purpose;
|
||||
|
||||
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
|
||||
private byte[] tokenHash;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private Instant usedAt;
|
||||
|
||||
@Column(name = "invalidated_at")
|
||||
private Instant invalidatedAt;
|
||||
|
||||
@Column(name = "issued_by_user_id")
|
||||
private Long issuedByUserId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected UserActionToken() {
|
||||
}
|
||||
|
||||
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||
this.userId = userId;
|
||||
this.purpose = TokenPurpose.ACTIVATION;
|
||||
this.tokenHash = Arrays.copyOf(tokenHash, tokenHash.length);
|
||||
this.expiresAt = expiresAt;
|
||||
this.issuedByUserId = issuedByUserId;
|
||||
this.createdAt = now;
|
||||
}
|
||||
|
||||
public static UserActionToken activation(
|
||||
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now);
|
||||
}
|
||||
|
||||
public boolean isUsableAt(Instant now) {
|
||||
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
|
||||
}
|
||||
|
||||
public void markUsed(Instant now) {
|
||||
if (!isUsableAt(now)) {
|
||||
throw new IllegalStateException("Activation token is not usable");
|
||||
}
|
||||
usedAt = now;
|
||||
}
|
||||
|
||||
public void invalidate(Instant now) {
|
||||
if (usedAt != null) {
|
||||
throw new IllegalStateException("A used token cannot be invalidated");
|
||||
}
|
||||
if (invalidatedAt == null) {
|
||||
invalidatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public TokenPurpose getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public byte[] getTokenHash() {
|
||||
return Arrays.copyOf(tokenHash, tokenHash.length);
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public Instant getUsedAt() {
|
||||
return usedAt;
|
||||
}
|
||||
|
||||
public Instant getInvalidatedAt() {
|
||||
return invalidatedAt;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import java.util.Optional;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
@@ -13,5 +15,11 @@ public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
||||
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
||||
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select u from AppUser u where u.id = :id")
|
||||
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
||||
|
||||
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
||||
|
||||
long countByAccountStatus(AccountStatus status);
|
||||
}
|
||||
|
||||
+10
@@ -4,11 +4,21 @@ import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
||||
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
||||
|
||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||
|
||||
long countByInternshipStatus(InternshipStatus status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select p from InternProfile p where p.userId = :userId")
|
||||
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.lab.labtimesheet.feature.account.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
||||
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
||||
@Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from UserActionToken t where t.id = :id")
|
||||
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
||||
}
|
||||
@@ -1,25 +1,140 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Base64;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
|
||||
import com.lab.labtimesheet.feature.integration.service.MailDeliveryService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@Service
|
||||
public class AccountService {
|
||||
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
||||
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
|
||||
|
||||
private final AppUserRepository users;
|
||||
private final InternProfileRepository internProfiles;
|
||||
private final UserActionTokenRepository tokens;
|
||||
private final MailDeliveryService mailDelivery;
|
||||
private final PasswordEncoder passwords;
|
||||
private final Clock clock;
|
||||
private final TransactionTemplate transactions;
|
||||
private final String publicOrigin;
|
||||
|
||||
AccountService(AppUserRepository users, InternProfileRepository internProfiles) {
|
||||
AccountService(
|
||||
AppUserRepository users,
|
||||
InternProfileRepository internProfiles,
|
||||
UserActionTokenRepository tokens,
|
||||
MailDeliveryService mailDelivery,
|
||||
PasswordEncoder passwords,
|
||||
Clock clock,
|
||||
TransactionTemplate transactions,
|
||||
@Value("${lab.public-origin}") String publicOrigin) {
|
||||
this.users = users;
|
||||
this.internProfiles = internProfiles;
|
||||
this.tokens = tokens;
|
||||
this.mailDelivery = mailDelivery;
|
||||
this.passwords = passwords;
|
||||
this.clock = clock;
|
||||
this.transactions = transactions;
|
||||
this.publicOrigin = normalizeOrigin(publicOrigin);
|
||||
}
|
||||
|
||||
public AccountCreation create(CreateAccountCommand command, long adminId) {
|
||||
ValidatedAccount account = validate(command);
|
||||
if (!mailDelivery.isAvailable()) {
|
||||
throw new IllegalStateException("Active SMTP configuration is required for account creation");
|
||||
}
|
||||
|
||||
String rawToken = newRawToken();
|
||||
byte[] tokenHash = sha256(rawToken);
|
||||
PendingActivation pending = transactions.execute(status -> createPending(account, adminId, tokenHash));
|
||||
if (pending == null) {
|
||||
throw new IllegalStateException("Account creation did not complete");
|
||||
}
|
||||
|
||||
try {
|
||||
mailDelivery.send(
|
||||
account.email(),
|
||||
"Activate your Lab Timesheet account",
|
||||
"Activate your account using this single-use link:\n" + activationLink(rawToken));
|
||||
return new AccountCreation(pending.userId(), true);
|
||||
} catch (RuntimeException deliveryFailure) {
|
||||
transactions.executeWithoutResult(status -> tokens.findForUpdateById(pending.tokenId())
|
||||
.orElseThrow(() -> new IllegalStateException("Activation token is missing"))
|
||||
.invalidate(clock.instant()));
|
||||
return new AccountCreation(pending.userId(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean activate(String rawToken, String password) {
|
||||
BootstrapService.requirePassword(password);
|
||||
if (rawToken == null || rawToken.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UserActionToken token = tokens.findForUpdateByHashAndPurpose(sha256(rawToken), TokenPurpose.ACTIVATION)
|
||||
.orElse(null);
|
||||
var now = clock.instant();
|
||||
if (token == null || !token.isUsableAt(now)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AppUser user = users.findForUpdateById(token.getUserId()).orElse(null);
|
||||
if (user == null || user.getAccountStatus() != AccountStatus.PENDING_ACTIVATION) {
|
||||
return false;
|
||||
}
|
||||
user.activate(passwords.encode(password), now);
|
||||
token.markUsed(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void activateInternship(long internUserId, long adminId) {
|
||||
AppUser admin = users.findById(adminId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
|
||||
requireActiveAdmin(admin);
|
||||
|
||||
AppUser intern = users.findForUpdateById(internUserId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Intern not found"));
|
||||
if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) {
|
||||
throw new IllegalArgumentException("An active Intern account is required");
|
||||
}
|
||||
internProfiles.findForUpdateByUserId(internUserId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"))
|
||||
.activate(clock.instant());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AccountSummary summary() {
|
||||
return new AccountSummary(
|
||||
users.countByAccountStatus(AccountStatus.ACTIVE),
|
||||
users.countByAccountStatus(AccountStatus.PENDING_ACTIVATION),
|
||||
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -91,4 +206,91 @@ public class AccountService {
|
||||
return new AccountIdentity(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
|
||||
}
|
||||
|
||||
private PendingActivation createPending(ValidatedAccount account, long adminId, byte[] tokenHash) {
|
||||
AppUser admin = users.findForUpdateById(adminId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
|
||||
requireActiveAdmin(admin);
|
||||
|
||||
var now = clock.instant();
|
||||
AppUser user = users.save(AppUser.pending(
|
||||
account.email(), account.displayName(), account.role(), admin, now));
|
||||
if (account.role() == GlobalRole.INTERN) {
|
||||
internProfiles.save(InternProfile.notStarted(
|
||||
user.getId(), account.studentCode(), account.internshipStart(), account.internshipEnd(), now));
|
||||
}
|
||||
UserActionToken token = tokens.save(UserActionToken.activation(
|
||||
user.getId(), tokenHash, now.plus(ACTIVATION_LIFETIME), admin.getId(), now));
|
||||
return new PendingActivation(user.getId(), token.getId());
|
||||
}
|
||||
|
||||
private String activationLink(String rawToken) {
|
||||
return publicOrigin + "/activate?token=" + rawToken;
|
||||
}
|
||||
|
||||
private static ValidatedAccount validate(CreateAccountCommand command) {
|
||||
if (command == null || command.role() == null) {
|
||||
throw new IllegalArgumentException("Account role is required");
|
||||
}
|
||||
String email = BootstrapService.normalizeEmail(command.email());
|
||||
String displayName = requireText(command.displayName(), "Display name");
|
||||
if (command.role() != GlobalRole.INTERN) {
|
||||
if (command.studentCode() != null || command.internshipStart() != null || command.internshipEnd() != null) {
|
||||
throw new IllegalArgumentException("Internship fields are allowed only for Intern accounts");
|
||||
}
|
||||
return new ValidatedAccount(email, displayName, command.role(), null, null, null);
|
||||
}
|
||||
|
||||
String studentCode = requireText(command.studentCode(), "Student code");
|
||||
if (command.internshipStart() == null || command.internshipEnd() == null
|
||||
|| command.internshipEnd().isBefore(command.internshipStart())) {
|
||||
throw new IllegalArgumentException("A valid internship date range is required");
|
||||
}
|
||||
return new ValidatedAccount(
|
||||
email, displayName, command.role(), studentCode, command.internshipStart(), command.internshipEnd());
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " is required");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeOrigin(String value) {
|
||||
String origin = requireText(value, "Public origin");
|
||||
while (origin.endsWith("/")) {
|
||||
origin = origin.substring(0, origin.length() - 1);
|
||||
}
|
||||
if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("Public origin must use HTTP or HTTPS");
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
private static String newRawToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
TOKEN_RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static byte[] sha256(String value) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record ValidatedAccount(
|
||||
String email,
|
||||
String displayName,
|
||||
GlobalRole role,
|
||||
String studentCode,
|
||||
LocalDate internshipStart,
|
||||
LocalDate internshipEnd) {
|
||||
}
|
||||
|
||||
private record PendingActivation(long userId, long tokenId) {
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.lab.labtimesheet.feature.attendance.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
|
||||
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/attendance")
|
||||
public class AttendanceController {
|
||||
|
||||
private final AttendanceApplicationService attendance;
|
||||
private final AttendanceCurrentUserService currentUsers;
|
||||
|
||||
AttendanceController(
|
||||
AttendanceApplicationService attendance, AttendanceCurrentUserService currentUsers) {
|
||||
this.attendance = attendance;
|
||||
this.currentUsers = currentUsers;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String ownHistory(
|
||||
Principal principal,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
|
||||
Model model) {
|
||||
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
|
||||
return history(actor, actor.userId(), from, to, model);
|
||||
}
|
||||
|
||||
@GetMapping("/interns/{internId}")
|
||||
public String inspectHistory(
|
||||
Principal principal,
|
||||
@org.springframework.web.bind.annotation.PathVariable long internId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
|
||||
Model model) {
|
||||
AttendanceActor actor = currentUsers.actor(principal);
|
||||
if (actor.role() == AttendanceRole.INTERN) {
|
||||
throw new AccessDeniedException("Intern inspection is not allowed");
|
||||
}
|
||||
return history(actor, internId, from, to, model);
|
||||
}
|
||||
|
||||
@PostMapping("/check-in")
|
||||
public String checkIn(Principal principal, RedirectAttributes redirectAttributes) {
|
||||
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
|
||||
try {
|
||||
attendance.checkIn(actor.userId());
|
||||
redirectAttributes.addFlashAttribute("message", "Checked in");
|
||||
} catch (AttendanceException exception) {
|
||||
redirectAttributes.addFlashAttribute("error", exception.rejection().name());
|
||||
}
|
||||
return "redirect:/attendance";
|
||||
}
|
||||
|
||||
@PostMapping("/check-out")
|
||||
public String checkOut(Principal principal, RedirectAttributes redirectAttributes) {
|
||||
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
|
||||
try {
|
||||
attendance.checkOut(actor.userId());
|
||||
redirectAttributes.addFlashAttribute("message", "Checked out");
|
||||
} catch (AttendanceException exception) {
|
||||
redirectAttributes.addFlashAttribute("error", exception.rejection().name());
|
||||
}
|
||||
return "redirect:/attendance";
|
||||
}
|
||||
|
||||
private String history(
|
||||
AttendanceActor actor,
|
||||
long internId,
|
||||
LocalDate from,
|
||||
LocalDate to,
|
||||
Model model) {
|
||||
LocalDate effectiveTo = to == null ? attendance.currentBusinessDate() : to;
|
||||
LocalDate effectiveFrom = from == null ? effectiveTo.withDayOfMonth(1) : from;
|
||||
model.addAttribute("items", attendance.history(actor, internId, effectiveFrom, effectiveTo));
|
||||
model.addAttribute("targetInternId", internId);
|
||||
model.addAttribute("from", effectiveFrom);
|
||||
model.addAttribute("to", effectiveTo);
|
||||
model.addAttribute("ownHistory", actor.userId() == internId);
|
||||
return "attendance/history";
|
||||
}
|
||||
|
||||
private static AttendanceActor requireIntern(AttendanceActor actor) {
|
||||
if (actor.role() != AttendanceRole.INTERN) {
|
||||
throw new AccessDeniedException("Only Interns may punch attendance");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.lab.labtimesheet.feature.attendance.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
|
||||
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
|
||||
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/attendance/calendar")
|
||||
public class CalendarController {
|
||||
|
||||
private final CalendarApplicationService calendar;
|
||||
private final AttendanceApplicationService attendance;
|
||||
private final AttendanceCurrentUserService currentUsers;
|
||||
|
||||
CalendarController(
|
||||
CalendarApplicationService calendar,
|
||||
AttendanceApplicationService attendance,
|
||||
AttendanceCurrentUserService currentUsers) {
|
||||
this.calendar = calendar;
|
||||
this.attendance = attendance;
|
||||
this.currentUsers = currentUsers;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String calendar(Principal principal, Model model) {
|
||||
requireAdmin(currentUsers.actor(principal));
|
||||
LocalDate today = attendance.currentBusinessDate();
|
||||
model.addAttribute("events", calendar.list(today, today.plusYears(1)));
|
||||
model.addAttribute("today", today);
|
||||
return "attendance/calendar";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String create(
|
||||
Principal principal,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam String name,
|
||||
@RequestParam(defaultValue = "false") boolean dayOff,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
AttendanceActor actor = requireAdmin(currentUsers.actor(principal));
|
||||
calendar.createManual(actor, date, name, dayOff);
|
||||
redirectAttributes.addFlashAttribute("message", "Calendar event created");
|
||||
return "redirect:/attendance/calendar";
|
||||
}
|
||||
|
||||
@PostMapping("/{eventId}")
|
||||
public String update(
|
||||
Principal principal,
|
||||
@PathVariable long eventId,
|
||||
@RequestParam long version,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam String name,
|
||||
@RequestParam(defaultValue = "false") boolean dayOff,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
AttendanceActor actor = requireAdmin(currentUsers.actor(principal));
|
||||
calendar.updateManual(actor, eventId, version, date, name, dayOff);
|
||||
redirectAttributes.addFlashAttribute("message", "Calendar event updated");
|
||||
return "redirect:/attendance/calendar";
|
||||
}
|
||||
|
||||
private static AttendanceActor requireAdmin(AttendanceActor actor) {
|
||||
if (actor.role() != AttendanceRole.ADMIN) {
|
||||
throw new AccessDeniedException("Only Admin may manage the global calendar");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.lab.labtimesheet.feature.attendance.exception;
|
||||
|
||||
public final class AttendanceException extends RuntimeException {
|
||||
|
||||
private final AttendanceRejection rejection;
|
||||
|
||||
public AttendanceException(AttendanceRejection rejection) {
|
||||
super(rejection.name());
|
||||
this.rejection = rejection;
|
||||
}
|
||||
|
||||
public AttendanceRejection rejection() {
|
||||
return rejection;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.feature.attendance.exception;
|
||||
|
||||
public enum AttendanceRejection {
|
||||
INACTIVE_INTERN,
|
||||
NON_WORKDAY,
|
||||
GLOBAL_DAY_OFF,
|
||||
APPROVED_LEAVE,
|
||||
ALREADY_CHECKED_IN,
|
||||
NO_ATTENDANCE_RECORD,
|
||||
ALREADY_CHECKED_OUT,
|
||||
CHECKOUT_CUTOFF_PASSED
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.lab.labtimesheet.feature.attendance.exception;
|
||||
|
||||
public final class CalendarException extends RuntimeException {
|
||||
|
||||
public CalendarException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record AttendanceActor(long userId, AttendanceRole role) {
|
||||
|
||||
public AttendanceActor {
|
||||
Objects.requireNonNull(role, "role");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public record AttendancePolicy(
|
||||
long id,
|
||||
LocalDate effectiveFrom,
|
||||
ZoneId zoneId,
|
||||
LocalTime scheduledStart,
|
||||
LocalTime scheduledEnd,
|
||||
int checkInGraceMinutes,
|
||||
int checkoutGraceMinutes,
|
||||
int monthlyLeaveQuota,
|
||||
BigDecimal violationPenalty,
|
||||
Set<DayOfWeek> workdays) {
|
||||
|
||||
private static final int MAX_GRACE_MINUTES = 720;
|
||||
private static final int SECONDS_PER_DAY = 86_400;
|
||||
|
||||
public AttendancePolicy {
|
||||
Objects.requireNonNull(effectiveFrom, "effectiveFrom");
|
||||
Objects.requireNonNull(zoneId, "zoneId");
|
||||
Objects.requireNonNull(scheduledStart, "scheduledStart");
|
||||
Objects.requireNonNull(scheduledEnd, "scheduledEnd");
|
||||
Objects.requireNonNull(violationPenalty, "violationPenalty");
|
||||
workdays = Set.copyOf(workdays);
|
||||
|
||||
requireGraceInRange(checkInGraceMinutes, "checkInGraceMinutes");
|
||||
requireGraceInRange(checkoutGraceMinutes, "checkoutGraceMinutes");
|
||||
if (!scheduledEnd.isAfter(scheduledStart)) {
|
||||
throw new IllegalArgumentException("scheduledEnd must be after scheduledStart");
|
||||
}
|
||||
if (scheduledEnd.toSecondOfDay() + checkoutGraceMinutes * 60 >= SECONDS_PER_DAY) {
|
||||
throw new IllegalArgumentException("checkout cutoff must be before local midnight");
|
||||
}
|
||||
}
|
||||
|
||||
public static AttendancePolicy seeded(long id) {
|
||||
return new AttendancePolicy(
|
||||
id,
|
||||
LocalDate.of(1970, 1, 1),
|
||||
ZoneId.of("Asia/Ho_Chi_Minh"),
|
||||
LocalTime.of(8, 30),
|
||||
LocalTime.of(15, 30),
|
||||
30,
|
||||
30,
|
||||
3,
|
||||
new BigDecimal("0.25"),
|
||||
Set.of(
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY));
|
||||
}
|
||||
|
||||
public boolean isWorkday(LocalDate date) {
|
||||
return workdays.contains(date.getDayOfWeek());
|
||||
}
|
||||
|
||||
private static void requireGraceInRange(int value, String field) {
|
||||
if (value < 0 || value > MAX_GRACE_MINUTES) {
|
||||
throw new IllegalArgumentException(field + " must be between 0 and 720");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
public record AttendanceRecord(
|
||||
long internId,
|
||||
LocalDate workDate,
|
||||
AttendancePolicy policy,
|
||||
Instant checkInAt,
|
||||
Instant checkOutAt) {
|
||||
|
||||
public AttendanceRecord {
|
||||
Objects.requireNonNull(workDate, "workDate");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
Objects.requireNonNull(checkInAt, "checkInAt");
|
||||
}
|
||||
|
||||
public AttendanceRecord checkOut(Instant at) {
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (checkOutAt != null) {
|
||||
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT);
|
||||
}
|
||||
if (at.isAfter(checkoutCutoff())) {
|
||||
throw new AttendanceException(AttendanceRejection.CHECKOUT_CUTOFF_PASSED);
|
||||
}
|
||||
return new AttendanceRecord(internId, workDate, policy, checkInAt, at);
|
||||
}
|
||||
|
||||
public AttendanceViolations violations(Instant observedAt) {
|
||||
boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L));
|
||||
boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff());
|
||||
boolean earlyDeparture = checkOutAt != null && checkOutAt.isBefore(scheduledEnd());
|
||||
return new AttendanceViolations(late, earlyDeparture, missingCheckout);
|
||||
}
|
||||
|
||||
private Instant scheduledStart() {
|
||||
return ZonedDateTime.of(workDate, policy.scheduledStart(), policy.zoneId()).toInstant();
|
||||
}
|
||||
|
||||
private Instant scheduledEnd() {
|
||||
return ZonedDateTime.of(workDate, policy.scheduledEnd(), policy.zoneId()).toInstant();
|
||||
}
|
||||
|
||||
private Instant checkoutCutoff() {
|
||||
return scheduledEnd().plusSeconds(policy.checkoutGraceMinutes() * 60L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
public enum AttendanceRole {
|
||||
ADMIN,
|
||||
MENTOR,
|
||||
INTERN
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.dto;
|
||||
|
||||
public enum AttendanceCurrentState {
|
||||
NOT_CHECKED_IN,
|
||||
CHECKED_IN,
|
||||
CHECKED_OUT
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record AttendanceHistoryItem(
|
||||
LocalDate workDate,
|
||||
Instant checkInAt,
|
||||
Instant checkOutAt,
|
||||
AttendancePolicy policy,
|
||||
AttendanceViolations violations) {}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record GlobalCalendarEvent(long id, LocalDate date, String name, boolean dayOff, long version) {}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.entity;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Entity
|
||||
@Table(name = "attendance_policy_versions")
|
||||
public class AttendancePolicyEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "effective_from", nullable = false)
|
||||
private LocalDate effectiveFrom;
|
||||
|
||||
@Column(name = "timezone_name", nullable = false)
|
||||
private String timezoneName;
|
||||
|
||||
@Column(name = "scheduled_start", nullable = false)
|
||||
private LocalTime scheduledStart;
|
||||
|
||||
@Column(name = "scheduled_end", nullable = false)
|
||||
private LocalTime scheduledEnd;
|
||||
|
||||
@Column(name = "check_in_grace_minutes", nullable = false)
|
||||
private int checkInGraceMinutes;
|
||||
|
||||
@Column(name = "checkout_grace_minutes", nullable = false)
|
||||
private int checkoutGraceMinutes;
|
||||
|
||||
@Column(name = "monthly_leave_quota", nullable = false)
|
||||
private int monthlyLeaveQuota;
|
||||
|
||||
@Column(name = "violation_penalty", nullable = false)
|
||||
private BigDecimal violationPenalty;
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(
|
||||
name = "attendance_policy_workdays",
|
||||
joinColumns = @JoinColumn(name = "policy_version_id"))
|
||||
@Column(name = "iso_weekday", nullable = false)
|
||||
private Set<Short> isoWeekdays;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected AttendancePolicyEntity() {}
|
||||
|
||||
public AttendancePolicy toDomain() {
|
||||
Set<DayOfWeek> workdays = isoWeekdays.stream()
|
||||
.map(day -> DayOfWeek.of(day.intValue()))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
return new AttendancePolicy(
|
||||
id,
|
||||
effectiveFrom,
|
||||
ZoneId.of(timezoneName),
|
||||
scheduledStart,
|
||||
scheduledEnd,
|
||||
checkInGraceMinutes,
|
||||
checkoutGraceMinutes,
|
||||
monthlyLeaveQuota,
|
||||
violationPenalty,
|
||||
workdays);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.entity;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "attendance_records")
|
||||
public class AttendanceRecordEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "intern_user_id", nullable = false)
|
||||
private long internUserId;
|
||||
|
||||
@Column(name = "work_date", nullable = false)
|
||||
private LocalDate workDate;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER, optional = false)
|
||||
@JoinColumn(name = "policy_version_id", nullable = false)
|
||||
private AttendancePolicyEntity policy;
|
||||
|
||||
@Column(name = "check_in_at", nullable = false)
|
||||
private Instant checkInAt;
|
||||
|
||||
@Column(name = "check_out_at")
|
||||
private Instant checkOutAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected AttendanceRecordEntity() {}
|
||||
|
||||
public AttendanceRecordEntity(
|
||||
long internUserId,
|
||||
LocalDate workDate,
|
||||
AttendancePolicyEntity policy,
|
||||
Instant checkInAt,
|
||||
Instant checkOutAt) {
|
||||
this.internUserId = internUserId;
|
||||
this.workDate = workDate;
|
||||
this.policy = policy;
|
||||
this.checkInAt = checkInAt;
|
||||
this.checkOutAt = checkOutAt;
|
||||
}
|
||||
|
||||
public AttendanceRecord toDomain() {
|
||||
return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt);
|
||||
}
|
||||
|
||||
public void setCheckOutAt(Instant checkOutAt) {
|
||||
this.checkOutAt = checkOutAt;
|
||||
}
|
||||
|
||||
public LocalDate workDate() {
|
||||
return workDate;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.entity;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "global_calendar_events")
|
||||
public class GlobalCalendarEventEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "calendar_date", nullable = false)
|
||||
private LocalDate calendarDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String source;
|
||||
|
||||
@Column(name = "is_day_off", nullable = false)
|
||||
private boolean dayOff;
|
||||
|
||||
@Column(name = "created_by_user_id", nullable = false, updatable = false)
|
||||
private long createdByUserId;
|
||||
|
||||
@Column(name = "updated_by_user_id", nullable = false)
|
||||
private long updatedByUserId;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected GlobalCalendarEventEntity() {}
|
||||
|
||||
public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) {
|
||||
this.calendarDate = date;
|
||||
this.name = name;
|
||||
this.source = "CUSTOM";
|
||||
this.dayOff = dayOff;
|
||||
this.createdByUserId = actorUserId;
|
||||
this.updatedByUserId = actorUserId;
|
||||
}
|
||||
|
||||
public void update(LocalDate date, String name, boolean dayOff, long actorUserId) {
|
||||
this.calendarDate = date;
|
||||
this.name = name;
|
||||
this.dayOff = dayOff;
|
||||
this.updatedByUserId = actorUserId;
|
||||
}
|
||||
|
||||
public GlobalCalendarEvent toDomain() {
|
||||
return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version);
|
||||
}
|
||||
|
||||
public LocalDate calendarDate() {
|
||||
return calendarDate;
|
||||
}
|
||||
|
||||
public long version() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "leave_requests")
|
||||
public class LeaveRequestEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "intern_user_id", nullable = false)
|
||||
private long internUserId;
|
||||
|
||||
@Column(name = "start_date", nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String status;
|
||||
|
||||
protected LeaveRequestEntity() {}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.lab.labtimesheet.feature.attendance.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AttendancePolicyRepository extends JpaRepository<AttendancePolicyEntity, Long> {
|
||||
|
||||
List<AttendancePolicyEntity> findAllByOrderByEffectiveFromAsc();
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.lab.labtimesheet.feature.attendance.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity;
|
||||
import java.time.LocalDate;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface AttendanceQueryRepository extends Repository<LeaveRequestEntity, Long> {
|
||||
|
||||
@Query("""
|
||||
select count(request) > 0
|
||||
from LeaveRequestEntity request
|
||||
where request.internUserId = :internId and request.status = 'APPROVED'
|
||||
and :workDate between request.startDate and request.endDate
|
||||
""")
|
||||
boolean hasApprovedLeave(
|
||||
@Param("internId") long internId, @Param("workDate") LocalDate workDate);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.lab.labtimesheet.feature.attendance.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AttendanceRecordRepository extends JpaRepository<AttendanceRecordEntity, Long> {
|
||||
|
||||
Optional<AttendanceRecordEntity> findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate);
|
||||
|
||||
List<AttendanceRecordEntity> findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(
|
||||
long internUserId, LocalDate from, LocalDate to);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.feature.attendance.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface GlobalCalendarEventRepository extends JpaRepository<GlobalCalendarEventEntity, Long> {
|
||||
|
||||
boolean existsByCalendarDateAndDayOffTrue(LocalDate date);
|
||||
|
||||
List<GlobalCalendarEventEntity> findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(
|
||||
LocalDate from, LocalDate to);
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AttendanceApplicationService {
|
||||
|
||||
private final Clock clock;
|
||||
private final AttendancePolicyRepository policyEntities;
|
||||
private final AttendanceRecordRepository recordEntities;
|
||||
private final AttendanceQueryRepository queries;
|
||||
private final AccountService accounts;
|
||||
private final CalendarApplicationService calendar;
|
||||
private final AttendanceService attendance;
|
||||
|
||||
AttendanceApplicationService(
|
||||
Clock clock,
|
||||
AttendancePolicyRepository policyEntities,
|
||||
AttendanceRecordRepository recordEntities,
|
||||
AttendanceQueryRepository queries,
|
||||
AccountService accounts,
|
||||
CalendarApplicationService calendar,
|
||||
AttendanceService attendance) {
|
||||
this.clock = clock;
|
||||
this.policyEntities = policyEntities;
|
||||
this.recordEntities = recordEntities;
|
||||
this.queries = queries;
|
||||
this.accounts = accounts;
|
||||
this.calendar = calendar;
|
||||
this.attendance = attendance;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AttendanceRecord checkIn(long internId) {
|
||||
Instant now = clock.instant();
|
||||
AttendancePolicy policy = timeline().resolve(now);
|
||||
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
|
||||
Optional<AttendanceRecord> existing = recordEntities
|
||||
.findByInternUserIdAndWorkDate(internId, workDate)
|
||||
.map(AttendanceRecordEntity::toDomain);
|
||||
AttendanceRecord record = attendance.checkIn(
|
||||
internId, now, policy, dayContext(internId, workDate), existing);
|
||||
return recordEntities.saveAndFlush(new AttendanceRecordEntity(
|
||||
record.internId(),
|
||||
record.workDate(),
|
||||
policyEntities.getReferenceById(record.policy().id()),
|
||||
record.checkInAt(),
|
||||
record.checkOutAt()))
|
||||
.toDomain();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AttendanceRecord checkOut(long internId) {
|
||||
Instant now = clock.instant();
|
||||
AttendancePolicy currentPolicy = timeline().resolve(now);
|
||||
LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate();
|
||||
Optional<AttendanceRecordEntity> entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate);
|
||||
AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now);
|
||||
AttendanceRecordEntity persisted = entity.orElseThrow();
|
||||
persisted.setCheckOutAt(checkedOut.checkOutAt());
|
||||
return recordEntities.saveAndFlush(persisted).toDomain();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AttendanceCurrentState currentState(long internId) {
|
||||
Instant now = clock.instant();
|
||||
AttendancePolicy policy = timeline().resolve(now);
|
||||
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
|
||||
if (!accounts.isEligibleIntern(internId, workDate)) {
|
||||
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
|
||||
}
|
||||
return recordEntities.findByInternUserIdAndWorkDate(internId, workDate)
|
||||
.map(AttendanceRecordEntity::toDomain)
|
||||
.map(record -> record.checkOutAt() == null
|
||||
? AttendanceCurrentState.CHECKED_IN
|
||||
: AttendanceCurrentState.CHECKED_OUT)
|
||||
.orElse(AttendanceCurrentState.NOT_CHECKED_IN);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AttendanceHistoryItem> history(
|
||||
AttendanceActor actor, long internId, LocalDate from, LocalDate to) {
|
||||
if (actor.role() == AttendanceRole.INTERN && actor.userId() != internId) {
|
||||
throw new AccessDeniedException("Interns may view only their own attendance");
|
||||
}
|
||||
if (from.isAfter(to)) {
|
||||
throw new IllegalArgumentException("from must not be after to");
|
||||
}
|
||||
return recordEntities.findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(internId, from, to)
|
||||
.stream()
|
||||
.map(AttendanceRecordEntity::toDomain)
|
||||
.map(record -> new AttendanceHistoryItem(
|
||||
record.workDate(),
|
||||
record.checkInAt(),
|
||||
record.checkOutAt(),
|
||||
record.policy(),
|
||||
record.violations(clock.instant())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public LocalDate currentBusinessDate() {
|
||||
AttendancePolicy policy = timeline().resolve(clock.instant());
|
||||
return clock.instant().atZone(policy.zoneId()).toLocalDate();
|
||||
}
|
||||
|
||||
private AttendancePolicyTimeline timeline() {
|
||||
return new AttendancePolicyTimeline(policyEntities
|
||||
.findAllByOrderByEffectiveFromAsc()
|
||||
.stream()
|
||||
.map(AttendancePolicyEntity::toDomain)
|
||||
.toList());
|
||||
}
|
||||
|
||||
private AttendanceDayContext dayContext(long internId, LocalDate workDate) {
|
||||
return new AttendanceDayContext(
|
||||
accounts.isEligibleIntern(internId, workDate),
|
||||
calendar.isGlobalDayOff(workDate),
|
||||
queries.hasApprovedLeave(internId, workDate));
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import java.security.Principal;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AttendanceCurrentUserService {
|
||||
|
||||
private final AccountService accounts;
|
||||
|
||||
AttendanceCurrentUserService(AccountService accounts) {
|
||||
this.accounts = accounts;
|
||||
}
|
||||
|
||||
public AttendanceActor actor(Principal principal) {
|
||||
if (principal == null || principal.getName() == null) {
|
||||
throw new AccessDeniedException("Authentication is required");
|
||||
}
|
||||
AccountIdentity identity;
|
||||
try {
|
||||
identity = accounts.requireIdentityByEmail(principal.getName());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new AccessDeniedException(
|
||||
"No active application user matches the authenticated identity", exception);
|
||||
}
|
||||
if (identity.status() != AccountStatus.ACTIVE) {
|
||||
throw new AccessDeniedException(
|
||||
"No active application user matches the authenticated identity");
|
||||
}
|
||||
return new AttendanceActor(identity.id(), AttendanceRole.valueOf(identity.role().name()));
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class AttendancePolicyTimeline {
|
||||
|
||||
private final List<AttendancePolicy> policies;
|
||||
|
||||
public AttendancePolicyTimeline(Collection<AttendancePolicy> policies) {
|
||||
this.policies = policies.stream()
|
||||
.sorted(Comparator.comparing(AttendancePolicy::effectiveFrom))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public AttendancePolicy resolve(LocalDate date) {
|
||||
Objects.requireNonNull(date, "date");
|
||||
return policies.stream()
|
||||
.filter(policy -> !policy.effectiveFrom().isAfter(date))
|
||||
.reduce((first, second) -> second)
|
||||
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date));
|
||||
}
|
||||
|
||||
public AttendancePolicy resolve(Instant instant) {
|
||||
Objects.requireNonNull(instant, "instant");
|
||||
return policies.stream()
|
||||
.filter(policy -> !policy.effectiveFrom().isAfter(
|
||||
instant.atZone(policy.zoneId()).toLocalDate()))
|
||||
.reduce((first, second) -> second)
|
||||
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies at " + instant));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public final class AttendanceService {
|
||||
|
||||
public AttendanceRecord checkIn(
|
||||
long internId,
|
||||
Instant now,
|
||||
AttendancePolicy policy,
|
||||
AttendanceDayContext context,
|
||||
Optional<AttendanceRecord> existingRecord) {
|
||||
LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate();
|
||||
requireEligible(policy, workDate, context);
|
||||
if (existingRecord.isPresent()) {
|
||||
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN);
|
||||
}
|
||||
return new AttendanceRecord(internId, workDate, policy, now, null);
|
||||
}
|
||||
|
||||
public AttendanceRecord checkOut(Optional<AttendanceRecord> record, Instant now) {
|
||||
return record
|
||||
.orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD))
|
||||
.checkOut(now);
|
||||
}
|
||||
|
||||
private static void requireEligible(
|
||||
AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) {
|
||||
if (!context.activeIntern()) {
|
||||
throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN);
|
||||
}
|
||||
if (!policy.isWorkday(workDate)) {
|
||||
throw new AttendanceException(AttendanceRejection.NON_WORKDAY);
|
||||
}
|
||||
if (context.globalDayOff()) {
|
||||
throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF);
|
||||
}
|
||||
if (context.approvedLeave()) {
|
||||
throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.exception.CalendarException;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class CalendarApplicationService {
|
||||
|
||||
private final Clock clock;
|
||||
private final AttendancePolicyRepository policies;
|
||||
private final GlobalCalendarEventRepository events;
|
||||
|
||||
CalendarApplicationService(
|
||||
Clock clock,
|
||||
AttendancePolicyRepository policies,
|
||||
GlobalCalendarEventRepository events) {
|
||||
this.clock = clock;
|
||||
this.policies = policies;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GlobalCalendarEvent createManual(
|
||||
AttendanceActor actor, LocalDate date, String name, boolean dayOff) {
|
||||
requireAdmin(actor);
|
||||
requireMutableDate(date);
|
||||
return events.saveAndFlush(new GlobalCalendarEventEntity(date, requireName(name), dayOff, actor.userId()))
|
||||
.toDomain();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GlobalCalendarEvent updateManual(
|
||||
AttendanceActor actor,
|
||||
long eventId,
|
||||
long expectedVersion,
|
||||
LocalDate date,
|
||||
String name,
|
||||
boolean dayOff) {
|
||||
requireAdmin(actor);
|
||||
GlobalCalendarEventEntity event = events.findById(eventId)
|
||||
.orElseThrow(() -> new CalendarException("Calendar event not found"));
|
||||
requireMutableDate(event.calendarDate());
|
||||
requireMutableDate(date);
|
||||
if (event.version() != expectedVersion) {
|
||||
throw new CalendarException("Calendar event was changed by another request");
|
||||
}
|
||||
event.update(date, requireName(name), dayOff, actor.userId());
|
||||
return events.saveAndFlush(event).toDomain();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<GlobalCalendarEvent> list(LocalDate from, LocalDate to) {
|
||||
if (from.isAfter(to)) {
|
||||
throw new IllegalArgumentException("from must not be after to");
|
||||
}
|
||||
return events.findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(from, to)
|
||||
.stream()
|
||||
.map(GlobalCalendarEventEntity::toDomain)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isGlobalDayOff(LocalDate date) {
|
||||
return events.existsByCalendarDateAndDayOffTrue(date);
|
||||
}
|
||||
|
||||
private void requireMutableDate(LocalDate date) {
|
||||
AttendancePolicy policy = new AttendancePolicyTimeline(policies
|
||||
.findAllByOrderByEffectiveFromAsc()
|
||||
.stream()
|
||||
.map(AttendancePolicyEntity::toDomain)
|
||||
.toList())
|
||||
.resolve(clock.instant());
|
||||
LocalDate today = clock.instant().atZone(policy.zoneId()).toLocalDate();
|
||||
if (date.isBefore(today)) {
|
||||
throw new CalendarException("Past calendar events are immutable");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireAdmin(AttendanceActor actor) {
|
||||
if (actor.role() != AttendanceRole.ADMIN) {
|
||||
throw new AccessDeniedException("Only Admin may manage the global calendar");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireName(String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("name must not be blank");
|
||||
}
|
||||
return name.strip();
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class MailDeliveryService {
|
||||
private final SmtpConfigurationRepository configurations;
|
||||
private final SecretCipher secrets;
|
||||
private final SmtpProbe probe;
|
||||
|
||||
MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) {
|
||||
this.configurations = configurations;
|
||||
this.secrets = secrets;
|
||||
this.probe = probe;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isAvailable() {
|
||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||
}
|
||||
|
||||
public void send(String recipient, String subject, String body) {
|
||||
probe.send(activeConnection(), recipient, subject, body);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||
.map(this::connection)
|
||||
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
|
||||
}
|
||||
|
||||
SmtpConnection connection(SmtpConfiguration configuration) {
|
||||
byte[] ciphertext = configuration.getPasswordCiphertext();
|
||||
return new SmtpConnection(
|
||||
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
|
||||
configuration.getUsername(),
|
||||
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
|
||||
configuration.getFromAddress(), configuration.getFromName());
|
||||
}
|
||||
}
|
||||
+8
-12
@@ -23,15 +23,18 @@ public class SmtpConfigurationService {
|
||||
private final SmtpProbe probe;
|
||||
private final Environment environment;
|
||||
private final Clock clock;
|
||||
private final MailDeliveryService mailDelivery;
|
||||
|
||||
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
|
||||
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
|
||||
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock,
|
||||
MailDeliveryService mailDelivery) {
|
||||
this.configurations = configurations;
|
||||
this.accounts = accounts;
|
||||
this.secrets = secrets;
|
||||
this.probe = probe;
|
||||
this.environment = environment;
|
||||
this.clock = clock;
|
||||
this.mailDelivery = mailDelivery;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -72,27 +75,20 @@ public class SmtpConfigurationService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasActiveConfiguration() {
|
||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||
return mailDelivery.isAvailable();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||
.map(this::connection)
|
||||
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
|
||||
return mailDelivery.activeConnection();
|
||||
}
|
||||
|
||||
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||
probe.send(activeConnection(), recipient, subject, body);
|
||||
mailDelivery.send(recipient, subject, body);
|
||||
}
|
||||
|
||||
private SmtpConnection connection(SmtpConfiguration configuration) {
|
||||
byte[] ciphertext = configuration.getPasswordCiphertext();
|
||||
return new SmtpConnection(
|
||||
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
|
||||
configuration.getUsername(),
|
||||
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
|
||||
configuration.getFromAddress(), configuration.getFromName());
|
||||
return mailDelivery.connection(configuration);
|
||||
}
|
||||
|
||||
private void validate(SmtpDraft draft) {
|
||||
|
||||
@@ -7,6 +7,7 @@ spring:
|
||||
host: ${LAB_SMTP_HOST:localhost}
|
||||
port: ${LAB_SMTP_PORT:1025}
|
||||
lab:
|
||||
public-origin: ${LAB_PUBLIC_ORIGIN:http://localhost:8080}
|
||||
security:
|
||||
# Explicit non-production key; production must supply its own 256-bit key.
|
||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Global calendar</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Global calendar</h1>
|
||||
<p th:if="${message}" role="status" th:text="${message}"></p>
|
||||
|
||||
<form method="post" th:action="@{/attendance/calendar}">
|
||||
<label for="date">Date</label>
|
||||
<input id="date" name="date" type="date" required th:min="${today}">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" maxlength="200" required>
|
||||
<label><input name="dayOff" type="checkbox" value="true"> Day off</label>
|
||||
<button type="submit">Add event</button>
|
||||
</form>
|
||||
|
||||
<p th:if="${#lists.isEmpty(events)}">No upcoming calendar events.</p>
|
||||
<table th:unless="${#lists.isEmpty(events)}">
|
||||
<caption>Upcoming global events</caption>
|
||||
<thead><tr><th scope="col">Date</th><th scope="col">Name</th><th scope="col">Day off</th><th scope="col">Save</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="event : ${events}">
|
||||
<td><input name="date" type="date" required th:min="${today}" th:value="${event.date}" th:attr="form=|event-${event.id}|" aria-label="Event date"></td>
|
||||
<td><input name="name" type="text" maxlength="200" required th:value="${event.name}" th:attr="form=|event-${event.id}|" aria-label="Event name"></td>
|
||||
<td><input name="dayOff" type="checkbox" value="true" th:checked="${event.dayOff}" th:attr="form=|event-${event.id}|" aria-label="Day off"></td>
|
||||
<td>
|
||||
<form method="post" th:id="|event-${event.id}|" th:action="@{/attendance/calendar/{id}(id=${event.id})}">
|
||||
<input name="version" type="hidden" th:value="${event.version}">
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Attendance history</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 th:text="${ownHistory} ? 'My attendance' : 'Intern attendance'">Attendance</h1>
|
||||
<p th:if="${message}" role="status" th:text="${message}"></p>
|
||||
<p th:if="${error}" role="alert" th:text="${error}"></p>
|
||||
|
||||
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-in}">
|
||||
<button type="submit">Check in</button>
|
||||
</form>
|
||||
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-out}">
|
||||
<button type="submit">Check out</button>
|
||||
</form>
|
||||
|
||||
<form method="get">
|
||||
<label for="from">From</label>
|
||||
<input id="from" name="from" type="date" th:value="${from}">
|
||||
<label for="to">To</label>
|
||||
<input id="to" name="to" type="date" th:value="${to}">
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
<p th:if="${#lists.isEmpty(items)}">No attendance records in this period.</p>
|
||||
<table th:unless="${#lists.isEmpty(items)}">
|
||||
<caption>Attendance records and applied policy</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Check in</th>
|
||||
<th scope="col">Check out</th>
|
||||
<th scope="col">Applied schedule</th>
|
||||
<th scope="col">Grace</th>
|
||||
<th scope="col">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="item : ${items}">
|
||||
<td th:text="${item.workDate}"></td>
|
||||
<td th:text="${item.checkInAt}"></td>
|
||||
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></td>
|
||||
<td th:text="|${item.policy.scheduledStart}–${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
|
||||
<td th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></td>
|
||||
<td th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.lab.labtimesheet.architecture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.repository.Repository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
class AttendanceLayerStructureTest {
|
||||
|
||||
@Test
|
||||
void attendanceUsesAuthoritativeLayerPackagesWithoutLegacyFeaturePackage() throws Exception {
|
||||
for (String className : List.of(
|
||||
"com.lab.labtimesheet.feature.attendance.controller.AttendanceController",
|
||||
"com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem",
|
||||
"com.lab.labtimesheet.feature.attendance.exception.AttendanceException",
|
||||
"com.lab.labtimesheet.feature.attendance.model.AttendancePolicy",
|
||||
"com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity",
|
||||
"com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository",
|
||||
"com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService")) {
|
||||
assertThat(Class.forName(className)).isNotNull();
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.attendance.AttendanceService"))
|
||||
.isInstanceOf(ClassNotFoundException.class);
|
||||
assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.controller.AttendanceController"))
|
||||
.isInstanceOf(ClassNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void attendanceQueriesUseSpringDataJpaRatherThanJdbcTemplate() throws Exception {
|
||||
Class<?> queryRepository = Class.forName(
|
||||
"com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository");
|
||||
assertThat(Repository.class).isAssignableFrom(queryRepository);
|
||||
|
||||
for (String serviceName : List.of(
|
||||
"com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService",
|
||||
"com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService")) {
|
||||
assertThat(Class.forName(serviceName).getDeclaredFields())
|
||||
.allSatisfy(field -> assertThat(field.getType()).isNotEqualTo(JdbcTemplate.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void attendanceDoesNotMapOrExposeAccountFeatureTables() {
|
||||
for (String className : List.of(
|
||||
"com.lab.labtimesheet.feature.attendance.model.entity.AppUserEntity",
|
||||
"com.lab.labtimesheet.feature.attendance.model.entity.InternProfileEntity",
|
||||
"com.lab.labtimesheet.feature.attendance.repository.AppUserRepository",
|
||||
"com.lab.labtimesheet.feature.attendance.repository.InternProfileRepository")) {
|
||||
assertThatThrownBy(() -> Class.forName(className))
|
||||
.isInstanceOf(ClassNotFoundException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class AccountActivationIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrap;
|
||||
|
||||
@Autowired
|
||||
private AccountService accounts;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtp;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe mail;
|
||||
|
||||
@Autowired
|
||||
private AppUserRepository users;
|
||||
|
||||
@Autowired
|
||||
private InternProfileRepository internProfiles;
|
||||
|
||||
@Autowired
|
||||
private UserActionTokenRepository tokens;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwords;
|
||||
|
||||
@Test
|
||||
void smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory() throws Exception {
|
||||
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
long adminId = accounts.requireActiveAdminId("admin@example.com");
|
||||
|
||||
var mentor = new CreateAccountCommand(
|
||||
" MENTOR@EXAMPLE.COM ", " Mentor One ", GlobalRole.MENTOR, null, null, null);
|
||||
assertThatThrownBy(() -> accounts.create(mentor, adminId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("SMTP");
|
||||
assertThat(users.count()).isEqualTo(1);
|
||||
|
||||
activateSmtp(adminId);
|
||||
mail.messages.clear();
|
||||
|
||||
var mentorCreation = accounts.create(mentor, adminId);
|
||||
assertThat(mentorCreation.deliverySucceeded()).isTrue();
|
||||
var pendingMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(pendingMentor.getEmail()).isEqualTo("mentor@example.com");
|
||||
assertThat(pendingMentor.getDisplayName()).isEqualTo("Mentor One");
|
||||
assertThat(pendingMentor.getGlobalRole()).isEqualTo(GlobalRole.MENTOR);
|
||||
assertThat(pendingMentor.getAccountStatus()).isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(pendingMentor.getPasswordHash()).isNull();
|
||||
|
||||
String rawMentorToken = mail.onlyActivationToken();
|
||||
var mentorToken = tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(mentorCreation.userId()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(mentorToken.getPurpose()).isEqualTo(TokenPurpose.ACTIVATION);
|
||||
assertThat(mentorToken.getTokenHash()).containsExactly(sha256(rawMentorToken));
|
||||
assertThat(mentorToken.getExpiresAt()).isEqualTo(Instant.parse("2026-08-15T00:00:00Z"));
|
||||
assertThat(mentorToken.isUsableAt(mentorToken.getExpiresAt())).isFalse();
|
||||
assertThat(HexFormat.of().formatHex(mentorToken.getTokenHash())).doesNotContain(rawMentorToken);
|
||||
|
||||
assertThat(accounts.activate("not-the-token", "new secure mentor password")).isFalse();
|
||||
assertThat(accounts.activate(rawMentorToken, "new secure mentor password")).isTrue();
|
||||
assertThat(accounts.activate(rawMentorToken, "another secure password")).isFalse();
|
||||
var activeMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(activeMentor.getAccountStatus()).isEqualTo(AccountStatus.ACTIVE);
|
||||
assertThat(passwords.matches("new secure mentor password", activeMentor.getPasswordHash())).isTrue();
|
||||
assertThat(tokens.findById(mentorToken.getId()).orElseThrow().getUsedAt()).isNotNull();
|
||||
|
||||
mail.fail = true;
|
||||
var failedIntern = accounts.create(new CreateAccountCommand(
|
||||
"intern-failed@example.com", "Failed Intern", GlobalRole.INTERN, "STU-FAIL",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(failedIntern.deliverySucceeded()).isFalse();
|
||||
assertThat(users.findById(failedIntern.userId()).orElseThrow().getAccountStatus())
|
||||
.isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(failedIntern.userId()))
|
||||
.findFirst().orElseThrow().getInvalidatedAt()).isNotNull();
|
||||
|
||||
mail.fail = false;
|
||||
mail.messages.clear();
|
||||
var activeInternCreation = accounts.create(new CreateAccountCommand(
|
||||
"intern@example.com", "Active Intern", GlobalRole.INTERN, "STU-001",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
|
||||
accounts.activateInternship(activeInternCreation.userId(), adminId);
|
||||
|
||||
var profile = internProfiles.findById(activeInternCreation.userId()).orElseThrow();
|
||||
assertThat(profile.getInternshipStatus()).isEqualTo(InternshipStatus.ACTIVE);
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 8, 1))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 12, 31))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2027, 1, 1))).isFalse();
|
||||
|
||||
var summary = accounts.summary();
|
||||
assertThat(summary.activeAccounts()).isEqualTo(3);
|
||||
assertThat(summary.pendingActivations()).isEqualTo(1);
|
||||
assertThat(summary.activeInternships()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private void activateSmtp(long adminId) {
|
||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||
smtp.testDraft(draftId, adminId, "admin@example.com");
|
||||
smtp.activate(draftId, adminId);
|
||||
}
|
||||
|
||||
private static byte[] sha256(String value) throws Exception {
|
||||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class MailProbeConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
RecordingSmtpProbe recordingSmtpProbe() {
|
||||
return new RecordingSmtpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
private boolean fail;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
if (fail) {
|
||||
throw new IllegalStateException("simulated SMTP failure");
|
||||
}
|
||||
messages.add(new Message(recipient, subject, body));
|
||||
}
|
||||
|
||||
String onlyActivationToken() {
|
||||
assertThat(messages).hasSize(1);
|
||||
String body = messages.getFirst().body();
|
||||
int tokenStart = body.indexOf("token=");
|
||||
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||
return body.substring(tokenStart + "token=".length()).trim();
|
||||
}
|
||||
}
|
||||
|
||||
record Message(String recipient, String subject, String body) {
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.lab.labtimesheet.feature.attendance.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
|
||||
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
|
||||
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
|
||||
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@WebMvcTest({AttendanceController.class, CalendarController.class})
|
||||
class AttendanceControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private AttendanceApplicationService attendance;
|
||||
|
||||
@MockitoBean
|
||||
private CalendarApplicationService calendar;
|
||||
|
||||
@MockitoBean
|
||||
private AttendanceCurrentUserService currentUsers;
|
||||
|
||||
@Test
|
||||
void internPunchesOnlyForAuthenticatedSelf() throws Exception {
|
||||
AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN);
|
||||
when(currentUsers.actor(any())).thenReturn(actor);
|
||||
|
||||
mockMvc.perform(post("/attendance/check-in")
|
||||
.with(user("intern@example.test").roles("INTERN"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/attendance"));
|
||||
|
||||
verify(attendance).checkIn(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownHistoryRendersAttachedHistoricalPolicy() throws Exception {
|
||||
AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN);
|
||||
when(currentUsers.actor(any())).thenReturn(actor);
|
||||
when(attendance.history(eq(actor), eq(42L), any(), any())).thenReturn(List.of(new AttendanceHistoryItem(
|
||||
LocalDate.of(2026, 8, 14),
|
||||
Instant.parse("2026-08-14T02:00:00Z"),
|
||||
Instant.parse("2026-08-14T09:00:00Z"),
|
||||
AttendancePolicy.seeded(1L),
|
||||
new AttendanceViolations(false, false, false))));
|
||||
|
||||
mockMvc.perform(get("/attendance")
|
||||
.with(user("intern@example.test").roles("INTERN"))
|
||||
.param("from", "2026-08-01")
|
||||
.param("to", "2026-08-31"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("attendance/history"))
|
||||
.andExpect(model().attribute("targetInternId", 42L))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("30 min")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mentorCanInspectInternHistory() throws Exception {
|
||||
AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR);
|
||||
when(currentUsers.actor(any())).thenReturn(mentor);
|
||||
when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14));
|
||||
when(attendance.history(eq(mentor), eq(42L), any(), any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/attendance/interns/42")
|
||||
.with(user("mentor@example.test").roles("MENTOR")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("attendance/history"));
|
||||
|
||||
verify(attendance).history(
|
||||
mentor, 42L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 14));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyAdminCanOpenCalendarManagement() throws Exception {
|
||||
when(currentUsers.actor(any())).thenReturn(new AttendanceActor(7L, AttendanceRole.MENTOR));
|
||||
|
||||
mockMvc.perform(get("/attendance/calendar")
|
||||
.with(user("mentor@example.test").roles("MENTOR")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCreatesManualDayOffFromServerAuthorizedIdentity() throws Exception {
|
||||
AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN);
|
||||
when(currentUsers.actor(any())).thenReturn(admin);
|
||||
|
||||
mockMvc.perform(post("/attendance/calendar")
|
||||
.with(user("admin@example.test").roles("ADMIN"))
|
||||
.with(csrf())
|
||||
.param("date", "2026-08-20")
|
||||
.param("name", "Lab closure")
|
||||
.param("dayOff", "true"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/attendance/calendar"));
|
||||
|
||||
verify(calendar).createManual(admin, LocalDate.of(2026, 8, 20), "Lab closure", true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminCalendarRendersEditableVersionedEvents() throws Exception {
|
||||
AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN);
|
||||
when(currentUsers.actor(any())).thenReturn(admin);
|
||||
when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14));
|
||||
when(calendar.list(LocalDate.of(2026, 8, 14), LocalDate.of(2027, 8, 14)))
|
||||
.thenReturn(List.of(new GlobalCalendarEvent(
|
||||
9L, LocalDate.of(2026, 8, 20), "Lab closure", true, 3L)));
|
||||
|
||||
mockMvc.perform(get("/attendance/calendar")
|
||||
.with(user("admin@example.test").roles("ADMIN")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("attendance/calendar"))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Lab closure")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("value=\"3\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminUpdateCarriesOptimisticVersion() throws Exception {
|
||||
AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN);
|
||||
when(currentUsers.actor(any())).thenReturn(admin);
|
||||
|
||||
mockMvc.perform(post("/attendance/calendar/9")
|
||||
.with(user("admin@example.test").roles("ADMIN"))
|
||||
.with(csrf())
|
||||
.param("version", "3")
|
||||
.param("date", "2026-08-20")
|
||||
.param("name", "Lab closure")
|
||||
.param("dayOff", "true"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
|
||||
verify(calendar).updateManual(
|
||||
admin, 9L, 3L, LocalDate.of(2026, 8, 20), "Lab closure", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.lab.labtimesheet.feature.attendance.model;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.service.AttendancePolicyTimeline;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AttendancePolicyTest {
|
||||
|
||||
@Test
|
||||
void resolvesSeedPolicyForHistoricalAndCurrentDates() {
|
||||
AttendancePolicy seeded = AttendancePolicy.seeded(1L);
|
||||
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
|
||||
|
||||
assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1)));
|
||||
assertEquals(seeded, timeline.resolve(LocalDate.of(2026, 8, 14)));
|
||||
assertEquals(ZoneId.of("Asia/Ho_Chi_Minh"), seeded.zoneId());
|
||||
assertEquals(LocalTime.of(8, 30), seeded.scheduledStart());
|
||||
assertEquals(LocalTime.of(15, 30), seeded.scheduledEnd());
|
||||
assertEquals(30, seeded.checkInGraceMinutes());
|
||||
assertEquals(30, seeded.checkoutGraceMinutes());
|
||||
assertEquals(3, seeded.monthlyLeaveQuota());
|
||||
assertEquals(new BigDecimal("0.25"), seeded.violationPenalty());
|
||||
assertEquals(
|
||||
Set.of(
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY),
|
||||
seeded.workdays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGraceOutsideZeroThroughSevenHundredTwenty() {
|
||||
assertThrows(IllegalArgumentException.class, () -> policy(-1, 30, LocalTime.of(15, 30)));
|
||||
assertThrows(IllegalArgumentException.class, () -> policy(30, 721, LocalTime.of(15, 30)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCheckoutCutoffAtLocalMidnight() {
|
||||
assertThrows(IllegalArgumentException.class, () -> policy(30, 30, LocalTime.of(23, 30)));
|
||||
}
|
||||
|
||||
private static AttendancePolicy policy(
|
||||
int checkInGraceMinutes, int checkoutGraceMinutes, LocalTime scheduledEnd) {
|
||||
return new AttendancePolicy(
|
||||
2L,
|
||||
LocalDate.of(2026, 9, 1),
|
||||
ZoneId.of("Asia/Ho_Chi_Minh"),
|
||||
LocalTime.of(8, 30),
|
||||
scheduledEnd,
|
||||
checkInGraceMinutes,
|
||||
checkoutGraceMinutes,
|
||||
3,
|
||||
new BigDecimal("0.25"),
|
||||
Set.of(DayOfWeek.MONDAY));
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity;
|
||||
import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AttendanceApplicationServiceTest {
|
||||
|
||||
private static final long INTERN_ID = 42L;
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T02:00:00Z");
|
||||
private static final LocalDate WORK_DATE = LocalDate.of(2026, 8, 14);
|
||||
|
||||
private final AttendancePolicyRepository policies = mock(AttendancePolicyRepository.class);
|
||||
private final AttendanceRecordRepository records = mock(AttendanceRecordRepository.class);
|
||||
private final AccountService accounts = mock(AccountService.class);
|
||||
private AttendanceApplicationService attendance;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class);
|
||||
when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L));
|
||||
when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity));
|
||||
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true);
|
||||
attendance = new AttendanceApplicationService(
|
||||
Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
policies,
|
||||
records,
|
||||
mock(AttendanceQueryRepository.class),
|
||||
accounts,
|
||||
mock(CalendarApplicationService.class),
|
||||
new AttendanceService());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsCurrentBusinessDatePunchStateWithoutExposingPersistenceTypes() {
|
||||
when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE))
|
||||
.thenReturn(Optional.empty())
|
||||
.thenReturn(Optional.of(entityFor(null)))
|
||||
.thenReturn(Optional.of(entityFor(NOW.plusSeconds(60))));
|
||||
|
||||
assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN);
|
||||
assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_IN);
|
||||
assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_OUT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCurrentStateLookupForIneligibleIntern() {
|
||||
when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> attendance.currentState(INTERN_ID))
|
||||
.isInstanceOfSatisfying(AttendanceException.class,
|
||||
exception -> assertThat(exception.rejection())
|
||||
.isEqualTo(AttendanceRejection.INACTIVE_INTERN));
|
||||
}
|
||||
|
||||
private static AttendanceRecordEntity entityFor(Instant checkOutAt) {
|
||||
AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class);
|
||||
when(entity.toDomain()).thenReturn(new AttendanceRecord(
|
||||
INTERN_ID,
|
||||
WORK_DATE,
|
||||
AttendancePolicy.seeded(1L),
|
||||
NOW,
|
||||
checkOutAt));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.CalendarException;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
|
||||
import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class)
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class AttendancePersistenceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private AttendanceApplicationService attendance;
|
||||
|
||||
@Autowired
|
||||
private CalendarApplicationService calendar;
|
||||
|
||||
@Autowired
|
||||
private AttendanceCurrentUserService currentUsers;
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrap;
|
||||
|
||||
@Autowired
|
||||
private AccountService accounts;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtp;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe mail;
|
||||
|
||||
@Autowired
|
||||
private AttendanceRecordRepository records;
|
||||
|
||||
@Autowired
|
||||
private MutableClock clock;
|
||||
|
||||
private long internId;
|
||||
private long adminId;
|
||||
private long mentorId;
|
||||
|
||||
@BeforeEach
|
||||
void seedUsers() {
|
||||
clock.set(Instant.parse("2026-08-14T00:00:00Z"));
|
||||
bootstrap.bootstrap("admin@example.test", "Admin", "correct horse battery staple");
|
||||
adminId = accounts.requireActiveAdminId("admin@example.test");
|
||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit",
|
||||
1025,
|
||||
SecurityMode.NONE,
|
||||
null,
|
||||
null,
|
||||
"admin@example.test",
|
||||
"Lab Timesheet"));
|
||||
smtp.testDraft(draftId, adminId, "admin@example.test");
|
||||
smtp.activate(draftId, adminId);
|
||||
mail.clear();
|
||||
|
||||
var creation = accounts.create(new CreateAccountCommand(
|
||||
"intern@example.test",
|
||||
"Intern",
|
||||
GlobalRole.INTERN,
|
||||
"INT-001",
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(creation.deliverySucceeded()).isTrue();
|
||||
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
|
||||
accounts.activateInternship(creation.userId(), adminId);
|
||||
|
||||
mentorId = adminId + 1;
|
||||
internId = creation.userId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesServerPunchesWithSeededPolicyAndHistoricalPolicyDetails() {
|
||||
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
|
||||
|
||||
assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN);
|
||||
attendance.checkIn(internId);
|
||||
assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_IN);
|
||||
|
||||
var persisted = records.findByInternUserIdAndWorkDate(internId, LocalDate.of(2026, 8, 14))
|
||||
.orElseThrow()
|
||||
.toDomain();
|
||||
assertThat(persisted.checkInAt()).isEqualTo(clock.instant());
|
||||
assertThat(persisted.policy().id()).isEqualTo(1L);
|
||||
assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class);
|
||||
|
||||
clock.set(Instant.parse("2026-08-14T09:00:00Z"));
|
||||
attendance.checkOut(internId);
|
||||
assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_OUT);
|
||||
|
||||
AttendanceHistoryItem item = attendance.history(
|
||||
new AttendanceActor(internId, AttendanceRole.INTERN),
|
||||
internId,
|
||||
LocalDate.of(2026, 8, 14),
|
||||
LocalDate.of(2026, 8, 14))
|
||||
.getFirst();
|
||||
assertThat(item.policy().id()).isEqualTo(1L);
|
||||
assertThat(item.policy().checkoutGraceMinutes()).isEqualTo(30);
|
||||
assertThat(item.checkOutAt()).isEqualTo(clock.instant());
|
||||
assertThat(item.violations().missingCheckout()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() {
|
||||
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
|
||||
AttendanceActor intern = new AttendanceActor(internId, AttendanceRole.INTERN);
|
||||
LocalDate workDate = LocalDate.of(2026, 8, 14);
|
||||
clock.set(Instant.parse("2026-08-13T02:00:00Z"));
|
||||
|
||||
assertThatThrownBy(() -> calendar.createManual(intern, workDate, "Blocked", true))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
var event = calendar.createManual(admin, workDate, "Team holiday", true);
|
||||
|
||||
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
|
||||
assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class);
|
||||
|
||||
clock.set(Instant.parse("2026-08-15T02:00:00Z"));
|
||||
assertThatThrownBy(() -> calendar.updateManual(
|
||||
admin, event.id(), event.version(), workDate, "Changed", false))
|
||||
.isInstanceOf(CalendarException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarRejectsStaleOptimisticVersion() {
|
||||
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
|
||||
LocalDate date = LocalDate.of(2026, 8, 20);
|
||||
var event = calendar.createManual(admin, date, "Lab closure", true);
|
||||
|
||||
calendar.updateManual(admin, event.id(), event.version(), date, "Lab open", false);
|
||||
|
||||
assertThatThrownBy(() -> calendar.updateManual(
|
||||
admin, event.id(), event.version(), date, "Stale edit", true))
|
||||
.isInstanceOf(CalendarException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicCalendarServiceReportsAuthoritativeDayOff() {
|
||||
AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN);
|
||||
LocalDate date = LocalDate.of(2026, 8, 20);
|
||||
var event = calendar.createManual(admin, date, "Observance", false);
|
||||
|
||||
assertThat(calendar.isGlobalDayOff(date)).isFalse();
|
||||
|
||||
calendar.updateManual(admin, event.id(), event.version(), date, "Lab closure", true);
|
||||
assertThat(calendar.isGlobalDayOff(date)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownHistoryAndMentorAdminInspectionAreAuthorized() {
|
||||
clock.set(Instant.parse("2026-08-14T02:00:00Z"));
|
||||
attendance.checkIn(internId);
|
||||
LocalDate date = LocalDate.of(2026, 8, 14);
|
||||
|
||||
assertThat(attendance.history(
|
||||
new AttendanceActor(internId, AttendanceRole.INTERN), internId, date, date))
|
||||
.hasSize(1);
|
||||
assertThat(attendance.history(
|
||||
new AttendanceActor(mentorId, AttendanceRole.MENTOR), internId, date, date))
|
||||
.hasSize(1);
|
||||
assertThat(attendance.history(
|
||||
new AttendanceActor(adminId, AttendanceRole.ADMIN), internId, date, date))
|
||||
.hasSize(1);
|
||||
assertThatThrownBy(() -> attendance.history(
|
||||
new AttendanceActor(internId + 100, AttendanceRole.INTERN), internId, date, date))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentActorComesFromActiveNormalizedAccountServiceIdentity() {
|
||||
assertThat(currentUsers.actor(() -> " INTERN@EXAMPLE.TEST "))
|
||||
.isEqualTo(new AttendanceActor(internId, AttendanceRole.INTERN));
|
||||
|
||||
assertThatThrownBy(() -> currentUsers.actor(() -> "missing@example.test"))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class IntegrationConfiguration {
|
||||
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
PostgreSQLContainer postgresContainer() {
|
||||
return new PostgreSQLContainer(DockerImageName.parse("postgres:18.4"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
MutableClock mutableClock() {
|
||||
return new MutableClock(Instant.parse("2026-08-14T00:00:00Z"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
RecordingSmtpProbe recordingSmtpProbe() {
|
||||
return new RecordingSmtpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
messages.add(body);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
messages.clear();
|
||||
}
|
||||
|
||||
String onlyActivationToken() {
|
||||
assertThat(messages).hasSize(1);
|
||||
String body = messages.getFirst();
|
||||
int tokenStart = body.indexOf("token=");
|
||||
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||
return body.substring(tokenStart + "token=".length()).trim();
|
||||
}
|
||||
}
|
||||
|
||||
static final class MutableClock extends Clock {
|
||||
|
||||
private Instant instant;
|
||||
|
||||
MutableClock(Instant instant) {
|
||||
this.instant = instant;
|
||||
}
|
||||
|
||||
void set(Instant instant) {
|
||||
this.instant = instant;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return instant;
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.lab.labtimesheet.feature.attendance.service;
|
||||
|
||||
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_IN;
|
||||
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_OUT;
|
||||
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE;
|
||||
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.CHECKOUT_CUTOFF_PASSED;
|
||||
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.GLOBAL_DAY_OFF;
|
||||
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.INACTIVE_INTERN;
|
||||
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.NON_WORKDAY;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
|
||||
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AttendanceServiceTest {
|
||||
|
||||
private static final long INTERN_ID = 42L;
|
||||
private static final LocalDate WORKDAY = LocalDate.of(2026, 8, 14);
|
||||
|
||||
@Test
|
||||
void exactCheckInGraceBoundaryIsOnTimeAndFirstLaterInstantIsLate() {
|
||||
AttendanceRecord exactBoundary = checkInAt("2026-08-14T02:00:00Z", activeDay(), seededPolicy());
|
||||
AttendanceRecord firstLater = checkInAt("2026-08-14T02:00:00.001Z", activeDay(), seededPolicy());
|
||||
|
||||
assertFalse(exactBoundary.violations(at("2026-08-14T02:00:00Z")).late());
|
||||
assertTrue(firstLater.violations(at("2026-08-14T02:00:00.001Z")).late());
|
||||
assertEquals(WORKDAY, exactBoundary.workDate());
|
||||
assertEquals(1L, exactBoundary.policy().id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsIneligibleAndDuplicateCheckIns() {
|
||||
assertCheckInRejected(INACTIVE_INTERN, new AttendanceDayContext(false, false, false));
|
||||
assertCheckInRejected(GLOBAL_DAY_OFF, new AttendanceDayContext(true, true, false));
|
||||
assertCheckInRejected(APPROVED_LEAVE, new AttendanceDayContext(true, false, true));
|
||||
|
||||
AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY));
|
||||
assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly);
|
||||
|
||||
AttendanceService service = new AttendanceService();
|
||||
AttendanceRecord existing = checkInAt("2026-08-14T01:30:00Z", activeDay(), seededPolicy());
|
||||
|
||||
AttendanceException exception = assertThrows(
|
||||
AttendanceException.class,
|
||||
() -> service.checkIn(
|
||||
INTERN_ID,
|
||||
at("2026-08-14T01:30:00Z"),
|
||||
seededPolicy(),
|
||||
activeDay(),
|
||||
Optional.of(existing)));
|
||||
assertEquals(ALREADY_CHECKED_IN, exception.rejection());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() {
|
||||
AttendanceService service = new AttendanceService();
|
||||
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
|
||||
|
||||
AttendanceRecord checkedOut = service.checkOut(
|
||||
Optional.of(checkedIn), at("2026-08-14T09:00:00Z"));
|
||||
|
||||
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
|
||||
AttendanceException repeated = assertThrows(
|
||||
AttendanceException.class,
|
||||
() -> service.checkOut(Optional.of(checkedOut), at("2026-08-14T09:00:00.001Z")));
|
||||
assertEquals(ALREADY_CHECKED_OUT, repeated.rejection());
|
||||
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() {
|
||||
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
|
||||
AttendanceService service = new AttendanceService();
|
||||
|
||||
AttendanceException exception = assertThrows(
|
||||
AttendanceException.class,
|
||||
() -> service.checkOut(Optional.of(checkedIn), at("2026-08-14T09:00:00.001Z")));
|
||||
|
||||
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
|
||||
assertNull(checkedIn.checkOutAt());
|
||||
AttendanceViolations violations = checkedIn.violations(at("2026-08-14T09:00:00.001Z"));
|
||||
assertTrue(violations.missingCheckout());
|
||||
assertFalse(violations.earlyDeparture());
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroGraceCheckoutUsesScheduledEndAsInclusiveCutoff() {
|
||||
AttendancePolicy zeroGrace = policy(
|
||||
0,
|
||||
Set.of(
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY));
|
||||
AttendanceService service = new AttendanceService();
|
||||
AttendanceRecord checkedIn = checkedInRecord(zeroGrace);
|
||||
|
||||
AttendanceRecord checkedOut = service.checkOut(
|
||||
Optional.of(checkedIn), at("2026-08-14T08:30:00Z"));
|
||||
|
||||
assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt());
|
||||
|
||||
AttendanceRecord lateRecord = checkedInRecord(zeroGrace);
|
||||
AttendanceException exception = assertThrows(
|
||||
AttendanceException.class,
|
||||
() -> service.checkOut(Optional.of(lateRecord), at("2026-08-14T08:30:00.001Z")));
|
||||
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
|
||||
assertNull(lateRecord.checkOutAt());
|
||||
}
|
||||
|
||||
private static AttendanceRecord checkInAt(
|
||||
String instant, AttendanceDayContext context, AttendancePolicy policy) {
|
||||
return new AttendanceService().checkIn(
|
||||
INTERN_ID, at(instant), policy, context, Optional.empty());
|
||||
}
|
||||
|
||||
private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) {
|
||||
assertCheckInRejected(rejection, context, seededPolicy());
|
||||
}
|
||||
|
||||
private static void assertCheckInRejected(
|
||||
AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) {
|
||||
AttendanceException exception = assertThrows(
|
||||
AttendanceException.class,
|
||||
() -> new AttendanceService().checkIn(
|
||||
INTERN_ID,
|
||||
at("2026-08-14T01:30:00Z"),
|
||||
policy,
|
||||
context,
|
||||
Optional.empty()));
|
||||
assertEquals(rejection, exception.rejection());
|
||||
}
|
||||
|
||||
private static AttendanceRecord checkedInRecord(AttendancePolicy policy) {
|
||||
return checkInAt("2026-08-14T01:30:00Z", activeDay(), policy);
|
||||
}
|
||||
|
||||
private static AttendanceDayContext activeDay() {
|
||||
return new AttendanceDayContext(true, false, false);
|
||||
}
|
||||
|
||||
private static AttendancePolicy seededPolicy() {
|
||||
return AttendancePolicy.seeded(1L);
|
||||
}
|
||||
|
||||
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {
|
||||
return new AttendancePolicy(
|
||||
2L,
|
||||
LocalDate.of(1970, 1, 1),
|
||||
ZoneId.of("Asia/Ho_Chi_Minh"),
|
||||
LocalTime.of(8, 30),
|
||||
LocalTime.of(15, 30),
|
||||
30,
|
||||
checkoutGraceMinutes,
|
||||
3,
|
||||
new BigDecimal("0.25"),
|
||||
workdays);
|
||||
}
|
||||
|
||||
private static Instant at(String instant) {
|
||||
return Instant.parse(instant);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,5 +3,6 @@ spring:
|
||||
compose:
|
||||
enabled: false
|
||||
lab:
|
||||
public-origin: http://localhost:8080
|
||||
security:
|
||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
|
||||
Reference in New Issue
Block a user