Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b91aee4
feat: add findActiveContractByWorker for per-worker active contract l…
yoandiny Jul 25, 2026
269dc04
feat: add getRemainingDaysOnActiveContractOrZero on ContractService (…
yoandiny Jul 25, 2026
440c92a
Feat/low remaining days alert event (#57)
haja171106 Jul 25, 2026
af5a9e9
Feature/wave1 active contract and calendar UI (#59)
Tom-1747 Jul 25, 2026
dde2f5a
feat: add LowRemainingDaysAlertService for low contract days alert
nelio-gio Jul 25, 2026
46ad931
chore: format code
nelio-gio Jul 25, 2026
d033f3a
test: clean up alert IT fixtures to avoid shared DB pollution
yoandiny Jul 26, 2026
859e39b
Merge pull request #60 from Bradon614/feat/low-remaining-alert-service
Bradon614 Jul 26, 2026
ca67b3e
Feature/daily execution default date tests (#61)
saviola24 Jul 26, 2026
b76ad26
feat(calendar): integrate low remaining days alert service (#62)
Bradon614 Jul 26, 2026
4120bbb
fix: prevent alert email on calendar view and trigger after pointage …
haja171106 Jul 26, 2026
e6cf78e
fix: reject punch-in when contract is inactive (#64)
nyamyjese Jul 27, 2026
0ffd661
fix: calendar assets, zero-days banner, and pointage Loza guard (#65)
yoandiny Jul 27, 2026
f30f75d
chore: restore application.properties without session jdbc init (#66)
yoandiny Jul 27, 2026
d506bcf
docs: document ACCOUNTANTS and LOW_REMAINING_DAYS_THRESHOLD in README…
yoandiny Jul 27, 2026
fc2f01f
refactor: simplify low remaining alert wiring (#68)
yoandiny Jul 28, 2026
757b63e
fix: align pointage and calendar flow with review feedback (#70)
yoandiny Jul 28, 2026
8f74be7
fix: show low-days banner from calendar GET (#72)
yoandiny Jul 28, 2026
c350a92
test: move alert IT fixtures to Flyway migration (#73)
yoandiny Jul 28, 2026
b42402d
refactor: use double for remaining days calculations (#75)
Tom-1747 Jul 28, 2026
4dc7a55
refactor: rename checkRemainingDays alert method to verify (#76)
yoandiny Jul 28, 2026
1896683
refactor: add DailyExecutionService.verifyAndSave for pointage (#77)
yoandiny Jul 28, 2026
f4f4bdc
Move pointage IT contract fixtures to Flyway to avoid runtime team sa…
yoandiny Jul 28, 2026
a57abe4
Rename verifyAndSave to saveAndAlert for clearer pointage save flow (…
yoandiny Jul 28, 2026
69ad638
Show low remaining days warning banner on daily-execution GET. (#80)
yoandiny Jul 28, 2026
6869606
Mirror calendar contract banners on daily-execution GET (#81)
yoandiny Jul 28, 2026
a75122a
Unify contract warning into one banner with conditional color (#82)
yoandiny Jul 28, 2026
e65f003
Simplify calendar alert to message-only banner and remove toast. (#83)
yoandiny Jul 28, 2026
8777251
Remove unnecessary empty accountants check per review. (#84)
yoandiny Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ ASA_CARE_PRODUCT_CODE=
ASA_PAID_CARE_MISSION_CODES=
SENSITIVE_WORKERS_CODES=
MAX_LATENESS_REPORT=
ACCOUNTANTS=
LOW_REMAINING_DAYS_THRESHOLD=
```

Then, run Spring Boot as usual,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package school.hei.asa.endpoint.event.model;

import java.time.Duration;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;

@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
@Data
@EqualsAndHashCode(callSuper = false)
@ToString
public class LowRemainingDaysAlertRequested extends PojaEvent {

private String workerCode;
private int remainingDays;

@Override
public Duration maxConsumerDuration() {
return Duration.ofSeconds(45);
}

@Override
public Duration maxConsumerBackoffBetweenRetries() {
return Duration.ofSeconds(30);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import school.hei.asa.model.Mission;
import school.hei.asa.model.Worker;
import school.hei.asa.service.CalendarService;
import school.hei.asa.service.LowRemainingDaysAlertService;

@AllArgsConstructor
@Controller
Expand All @@ -35,6 +36,7 @@ public class CalendarController {
private final CalendarService calendarService;
private final WorkerFromAuthentication workerFromAuthentication;
private final WorkerToModelAdder workerToModelAdder;
private final LowRemainingDaysAlertService lowRemainingDaysAlertService;

@GetMapping("/work-and-care-calendar")
public String getCalendar(
Expand All @@ -43,7 +45,6 @@ public String getCalendar(
@RequestParam(required = false) String workerCode,
@RequestParam(required = false) Integer year) {
year = year == null ? now().getYear() : year;
model.addAttribute("year", year);

var workerCodeOrAuth =
workerCode == null || workerCode.isBlank()
Expand All @@ -66,7 +67,11 @@ public String getCalendar(
missionCounts.put(month, typeCounts);
});
var lateReportedDaysByMonth = calendarService.lateReportedDaysByMonth(worker, year);
var warningBannerMessage =
lowRemainingDaysAlertService.verifyRemainingDaysAndBuildAlertMessage(worker).orElse(null);

model.addAttribute("year", year);
model.addAttribute("warningBannerMessage", warningBannerMessage);
model.addAttribute("workerCode", workerCodeOrAuth);
model.addAttribute("currentYear", now().getYear());
model.addAttribute(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,28 @@
import school.hei.asa.endpoint.rest.model.th.ThDailyExecutionForm;
import school.hei.asa.endpoint.rest.security.WorkerFromAuthentication;
import school.hei.asa.endpoint.rest.service.ThMissionService;
import school.hei.asa.repository.DailyExecutionRepository;
import school.hei.asa.service.DailyExecutionService;
import school.hei.asa.service.LowRemainingDaysAlertService;

@Controller
@AllArgsConstructor
public class DailyExecutionController {
private final ThDailyExecutionFormMapper thDailyExecutionFormMapper;
private final DailyExecutionRepository dailyExecutionRepository;
private final DailyExecutionService dailyExecutionService;
private final WorkerFromAuthentication workerFromAuthentication;
private final ThMissionService thMissionService;
private final LowRemainingDaysAlertService lowRemainingDaysAlertService;

@GetMapping("/daily-execution")
public String getDailyExecutionForm(Model model) {
public String getDailyExecutionForm(Model model, Authentication authentication) {
var worker = workerFromAuthentication.apply(authentication).get();
var sortedMissions = thMissionService.sortedMissionsWithoutMissionExecution();
var warningBannerMessage =
lowRemainingDaysAlertService.verifyRemainingDaysAndBuildAlertMessage(worker).orElse(null);

model.addAttribute("missions", sortedMissions);
model.addAttribute("warningBannerMessage", warningBannerMessage);

return "daily-execution";
}

Expand All @@ -32,7 +40,8 @@ public String createDailyExecution(Authentication authentication, ThDailyExecuti
var worker = workerFromAuthentication.apply(authentication).get();
var dailyExecution = thDailyExecutionFormMapper.toDomain(dmeForm, worker);

Comment thread
yoandiny marked this conversation as resolved.
dailyExecutionRepository.save(dailyExecution);
dailyExecutionService.saveAndAlert(dailyExecution);

return "redirect:/work-and-care-calendar";
}
}
16 changes: 16 additions & 0 deletions src/main/java/school/hei/asa/number/DaysFormatter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package school.hei.asa.number;

import java.math.BigDecimal;

public final class DaysFormatter {

private DaysFormatter() {}

public static String format(double days) {
var normalized = BigDecimal.valueOf(days).stripTrailingZeros();
if (normalized.scale() <= 0) {
return normalized.toBigInteger().toString();
}
return normalized.toPlainString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Optional;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Repository;
import school.hei.asa.model.Worker;
Expand All @@ -25,6 +26,14 @@ public List<Contract> findAllByWorker(Worker worker) {
workerMapper.toEntity(worker)));
}

@Transactional
public Optional<Contract> findActiveContractByWorker(Worker worker) {
return jContractRepository
.findFirstByWorkerAndDurationInDaysIsNotNullOrderByEntranceInstantDesc(
workerMapper.toEntity(worker))
.map(jContract -> contractMapper.toDomain(List.of(jContract)).getFirst());
}

public List<Contract> findAll() {
return contractMapper.toDomain(jContractRepository.findAll());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
@Repository
public class DailyExecutionRepository {

private final ContractRepository contractRepository;
private final MissionExecutionRepository missionExecutionRepository;
private final JMissionExecutionRepository jMissionExecutionRepository;
private final JWorkerRepository jWorkerRepository;
Expand All @@ -33,6 +34,9 @@ public class DailyExecutionRepository {
@Transactional(isolation = SERIALIZABLE)
public void save(DailyExecution dailyExecution) {
var date = dailyExecution.date();
if (contractRepository.findActiveContractByWorker(dailyExecution.worker()).isEmpty()) {
throw new IllegalStateException("Unable to punch in : you have no active contract.");
}
if (!missionExecutionRepository.findAllBy(dailyExecution.worker(), date).isEmpty()) {
throw new IllegalArgumentException("Day already has MissionExecution: " + date);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package school.hei.asa.repository.jrepository;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
Expand All @@ -25,4 +26,7 @@ public interface JContractRepository extends JpaRepository<JContract, String> {

@Query("SELECT c FROM JContract c WHERE c.endInstant IS NULL AND c.durationInDays != 0")
List<JContract> findActiveContracts();

Optional<JContract> findFirstByWorkerAndDurationInDaysIsNotNullOrderByEntranceInstantDesc(
JWorker jWorker);
}
26 changes: 25 additions & 1 deletion src/main/java/school/hei/asa/service/ContractService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package school.hei.asa.service;

import static java.time.ZoneId.systemDefault;
import static java.util.Locale.FRENCH;
import static java.util.Locale.US;
import static school.hei.asa.model.DailyExecution.Type.fullCare;
import static school.hei.asa.model.DailyExecution.Type.fullWork;

Expand All @@ -9,6 +11,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -49,6 +52,27 @@ public List<Contract> getAllContractsByWorker(Worker worker) {
return contractRepository.findAllByWorker(worker);
}

public Optional<Contract> findActiveContractByWorker(Worker worker) {
return contractRepository.findActiveContractByWorker(worker);
}

public double getRemainingDaysOnActiveContractOrZero(Worker worker) {
var activeContractOpt = findActiveContractByWorker(worker);
if (activeContractOpt.isEmpty()) {
return 0d;
}

var contract = activeContractOpt.get();
var startDate = contract.entranceInstant().atZone(systemDefault()).toLocalDate();
var endDate =
contract.endInstant() == null
? LocalDate.now()
: contract.endInstant().atZone(systemDefault()).toLocalDate();
var actualWorkedDays = getActualWorkedDaysByDateByWorker(startDate, worker.code(), endDate);
var workedDays = actualWorkedDays.equals("-") ? 0d : Double.parseDouble(actualWorkedDays);
return contract.duration().toDays() - workedDays;
}

public String getActualWorkedDaysByDateByWorker(
LocalDate startDate, String workerCode, LocalDate endDate) {
var dailyExecutions =
Expand Down Expand Up @@ -80,7 +104,7 @@ private String executedDays(List<DailyExecution> executions) {
})
.reduce(Double::sum)
.get();
return String.format("%.1f", result);
return String.format(US, "%.1f", result);
}

public List<Contract> findActiveContracts() {
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/school/hei/asa/service/DailyExecutionService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package school.hei.asa.service;

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import school.hei.asa.model.DailyExecution;
import school.hei.asa.repository.DailyExecutionRepository;

@Service
@AllArgsConstructor
public class DailyExecutionService {
private final DailyExecutionRepository dailyExecutionRepository;
private final LowRemainingDaysAlertService lowRemainingDaysAlertService;

public void saveAndAlert(DailyExecution dailyExecution) {
dailyExecutionRepository.save(dailyExecution);
lowRemainingDaysAlertService.sendAlertEmailIfLowRemainingDays(dailyExecution.worker());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package school.hei.asa.service;

import static java.util.Locale.US;

import java.util.List;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import school.hei.asa.endpoint.event.EventProducer;
import school.hei.asa.endpoint.event.model.LowRemainingDaysAlertRequested;
import school.hei.asa.model.Worker;

@Slf4j
@Service
public class LowRemainingDaysAlertService {

private final ContractService contractService;
private final EventProducer<LowRemainingDaysAlertRequested> eventProducer;
private final int lowRemainingDaysThreshold;

public LowRemainingDaysAlertService(
ContractService contractService,
EventProducer<LowRemainingDaysAlertRequested> eventProducer,
@Value("${LOW_REMAINING_DAYS_THRESHOLD}") int lowRemainingDaysThreshold) {
this.contractService = contractService;
this.eventProducer = eventProducer;
this.lowRemainingDaysThreshold = lowRemainingDaysThreshold;
}

public Optional<String> verifyRemainingDaysAndBuildAlertMessage(Worker worker) {
var remainingDays = contractService.getRemainingDaysOnActiveContractOrZero(worker);

if (remainingDays <= 0) {
return Optional.of(
"Please note : You do not have an active contract. Please contact your administrator.");
}

if (!isBelowThreshold(remainingDays)) {
return Optional.empty();
}

return Optional.of(
"Please note : You have " + formatDays(remainingDays) + " day(s) left on your contract !");
}

public void sendAlertEmailIfLowRemainingDays(Worker worker) {
var remainingDays = contractService.getRemainingDaysOnActiveContractOrZero(worker);

if (!isBelowThreshold(remainingDays)) {
return;
}

log.info("Requesting alert email to accountants for worker '{}'", worker.code());
eventProducer.accept(
List.of(
LowRemainingDaysAlertRequested.builder()
.workerCode(worker.code())
.remainingDays((int) remainingDays)
.build()));
}

private boolean isBelowThreshold(double remainingDays) {
return remainingDays > 0 && remainingDays < lowRemainingDaysThreshold;
}

private static String formatDays(double days) {
return days == Math.floor(days)
? String.format(US, "%.0f", days)
: String.format(US, "%.1f", days);
}
}
Loading
Loading