Iter
Leave requests with a three-role approval workflow.

The problem
Approvals need clear rules about who can do what, and a trail of who approved what and when. When those rules live only in the interface, anyone with a REST client can skip them.
- Status
- Finished, live demo with seed users
- Stack
- Java 21, Spring Boot 3, Spring Security, PostgreSQL 16, Flyway, React 19, TanStack Query, Testcontainers, Docker
- Facts
- 32 test methods against real Postgres
- 7 REST endpoints
- CI on every push
Decisions worth explaining
One state machine also decides who may act
The state machine has no Spring dependency. Each state and action maps to a transition that names the next state, the role allowed to perform it and whether a reason is required. An action that isn’t allowed from the current state returns 409 Conflict.
Every transition leaves a record
Each transition writes an event row with the previous state, the new state, the author and the reason, inside the same transaction as the change. If the record can’t be written, the change doesn’t happen.
Your role depends on the request, not on your title
The role of whoever acts is computed from their relationship to the request, owner or direct manager, not from their global role. A manager can’t approve their own leave, and never sees drafts that haven’t been submitted. Integration tests run against a real Postgres through Testcontainers.
A piece of the code
State, role and reason are checked in one place, with no framework in the way.
public StatoRichiesta applica(StatoRichiesta statoCorrente, Azione azione, RuoloAttore ruoloAttore, String motivazione) {
Transizione transizione = TRANSIZIONI.get(statoCorrente).get(azione);
if (transizione == null) {
throw new TransizioneNonConsentitaException(
"L'azione %s non è consentita dallo stato %s".formatted(azione, statoCorrente));
}
if (transizione.ruoloRichiesto() != ruoloAttore) {
throw new TransizioneNonConsentitaException(
"L'azione %s richiede il ruolo %s, non %s".formatted(azione, transizione.ruoloRichiesto(), ruoloAttore));
}
if (transizione.motivazioneObbligatoria() && (motivazione == null || motivazione.isBlank())) {
throw new TransizioneNonConsentitaException(
"L'azione %s richiede una motivazione".formatted(azione));
}
return transizione.statoSuccessivo();
}