Skip to content
All work

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.

backend/…/workflow/RichiestaStateMachine.java
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();
}
Next projectPoltrona