A production-oriented, full-stack logistics management system for booking shipments, dispatching drivers and vehicles, recording journey events, and publishing public parcel tracking.
The platform is built as two independently deployable applications:
| Layer | Technology | Location |
|---|---|---|
| Backend | Java 17 · Spring Boot 4.1.0 · Spring Security · Spring Data JPA · PostgreSQL | backend/ |
| Frontend | React 19 · Vite 8 · Material UI 7 · React Router 7 · Axios | frontend/ |
Authentication is stateless JWT carried in an Authorization: Bearer <token> header, with
role-based access control (RBAC) enforced on both the server (authoritative) and the client
(navigation and UX only).
- Architecture
- Tech Stack
- Repository Structure
- Getting Started
- Backend Documentation
- API Reference
- Frontend Documentation
- Environment Variables
- Docker & Deployment
- Development Conventions
- Troubleshooting
┌─────────────────────────────┐ ┌──────────────────────────────────┐
│ Browser (SPA) │ │ Spring Boot API │
│ React 19 + MUI 7 + Vite │ HTTPS │ Controllers → Services → JPA │
│ │ ──────► │ │
│ • AuthContext (JWT in │ JSON │ • JwtAuthFilter (stateless) │
│ localStorage) │ Bearer │ • @PreAuthorize (RBAC) │
│ • Axios instance + │ │ • GlobalExceptionHandler │
│ interceptors │ │ • Swagger / OpenAPI 3 │
└─────────────────────────────┘ └───────────────┬──────────────────┘
│ JDBC / HikariCP
▼
┌────────────────────────┐
│ PostgreSQL 15 │
│ (H2 in tests) │
└────────────────────────┘
Request lifecycle (authenticated call):
- The browser sends
Authorization: Bearer <jwt>(injected by the Axios request interceptor). JwtAuthFilterparses the token, loads the principal from the database, and populates theSecurityContext. Invalid tokens are ignored (the request continues anonymously) rather than failing with a500.- Spring Security URL rules in
SecurityConfigperform coarse-grained authorization. @PreAuthorizeon the controller method performs fine-grained authorization.- The service layer re-validates ownership rules (e.g. a customer may only read their own shipments), because role checks alone are insufficient for row-level security.
GlobalExceptionHandlerconverts any exception into the uniform response envelope.
In development, the Vite dev server proxies /api to the backend so the browser only ever talks
to a single origin. In production, Nginx serves the built SPA and reverse-proxies /api to the
backend container — the same single-origin behaviour, so CORS never enters the picture in either
mode.
| Component | Version | Purpose |
|---|---|---|
| Java | 17 | Language / runtime |
| Spring Boot | 4.1.0 | Application framework |
| Spring Web (MVC) | managed | REST controllers |
| Spring Security | managed | Authentication & authorization |
| Spring Data JPA / Hibernate | managed | Persistence |
| Bean Validation (Jakarta) | managed | Request DTO validation |
| PostgreSQL JDBC Driver | managed | Database driver |
| HikariCP | managed | Connection pool |
jjwt (jjwt-api / -impl / -jackson) |
0.12.6 | JWT creation & verification |
springdoc-openapi (webmvc-ui) |
2.7.0 | OpenAPI 3 + Swagger UI |
| Lombok | managed | Boilerplate reduction |
| JSpecify | 1.0.0 | Nullability annotations (provided) |
| Spring Boot Actuator | managed | Health / info endpoints (opt-in) |
| Maven Wrapper | 3.3.4 | Reproducible builds (./mvnw) |
Test-only dependencies: JUnit 5, AssertJ, Mockito, spring-boot-starter-test,
spring-boot-resttestclient, spring-boot-restclient, spring-security-test, and H2 (in-memory).
| Component | Version | Purpose |
|---|---|---|
| React / React DOM | ^19.2.7 | UI runtime |
| Vite | ^8.1.1 | Dev server & bundler |
@vitejs/plugin-react |
^6.0.3 | React fast refresh / JSX transform |
Material UI (@mui/material, @mui/icons-material) |
^7.0.2 | Component library |
Emotion (@emotion/react, @emotion/styled) |
^11.14.0 | MUI styling engine |
| React Router DOM | ^7.5.3 | Client-side routing |
| Axios | ^1.8.4 | HTTP client |
| Recharts | ^2.15.3 | Dashboard charts |
| ESLint (+ react-hooks, react-refresh) | ^10.6.0 | Linting |
v1/
├── backend/
│ ├── .mvn/wrapper/ # Maven Wrapper configuration
│ ├── mvnw, mvnw.cmd # Wrapper scripts
│ ├── pom.xml # Dependencies & build
│ ├── Dockerfile # Multi-stage, layered-jar, non-root
│ └── src/
│ ├── main/
│ │ ├── java/com/slp/backend/
│ │ │ ├── BackendApplication.java
│ │ │ ├── config/ # Security, JWT filter, Swagger, admin bootstrap
│ │ │ ├── controller/ # REST controllers (8)
│ │ │ ├── dto/request/ # Inbound payloads (8)
│ │ │ ├── dto/response/ # Outbound projections (10)
│ │ │ ├── entity/ # JPA entities + enums
│ │ │ ├── exception/ # Custom exceptions + global handler
│ │ │ ├── repository/ # Spring Data repositories (6)
│ │ │ ├── service/ # Service interfaces
│ │ │ │ └── impl/ # Service implementations (8)
│ │ │ └── util/ # JwtUtil, CurrentUserUtil, AppConstants
│ │ └── resources/
│ │ ├── application.properties
│ │ └── logback-spring.xml
│ └── test/
│ ├── java/com/slp/backend/
│ │ ├── config/ # AdminBootstrapTest
│ │ ├── controller/ # *IT end-to-end HTTP suites
│ │ ├── security/ # AuthorizationIT (RBAC regression suite)
│ │ ├── service/ # Unit tests
│ │ ├── support/ # AbstractIntegrationTest, TestDataFactory
│ │ └── util/ # JwtUtilTest
│ └── resources/application-test.properties
├── frontend/
│ ├── index.html # Shell with inline first-paint splash
│ ├── nginx.conf # SPA fallback + /api reverse proxy
│ ├── vite.config.js # Dev proxy, manual vendor chunks
│ ├── eslint.config.js # Flat ESLint config
│ ├── package.json
│ ├── Dockerfile # Node build → Nginx runtime
│ └── src/
│ ├── main.jsx # React root
│ ├── App.jsx # Providers + route table
│ ├── index.css # Global, print & a11y styles
│ ├── components/ # Shared UI components (15)
│ ├── constants/index.js # Enums, colours, pagination defaults
│ ├── context/AuthContext.jsx # Session state & role helpers
│ ├── pages/ # Route-level pages (15)
│ ├── services/ # api.js + 8 domain API modules
│ ├── theme/ # tokens.js, index.js, ColorModeContext.jsx
│ └── utils/format.js # Formatting & error-message helpers
├── docker-compose.yml # postgres + backend + frontend
└── README.md
Schema management: the project ships no migration tool (no Flyway / Liquibase) and no
schema.sql. Schema creation is delegated to Hibernateddl-auto— see Configuration Reference.
| Tool | Version | Needed for |
|---|---|---|
| JDK | 17+ | Running / building the backend |
| Node.js | 20+ (18 minimum) | Running / building the frontend |
| PostgreSQL | 15 | Local (non-Docker) backend runs |
| Docker + Compose | recent | The all-in-one Docker workflow |
| Maven | not required | Use the bundled ./mvnw wrapper |
The fastest path — brings up PostgreSQL, the API, and the Nginx-served SPA:
# JWT_SECRET is required by the compose file; generate a strong one first.
export JWT_SECRET="$(openssl rand -hex 32)"
docker compose up --build| Service | URL | Notes |
|---|---|---|
| Frontend | http://localhost | Nginx, port 80 |
| Backend API | http://localhost:8080 | Spring profile prod |
| Swagger UI | http://localhost:8080/swagger-ui.html | Interactive API docs |
| PostgreSQL | localhost:5432 | Volume postgres_data persists data |
The backend waits for PostgreSQL's health check before starting
(depends_on: condition: service_healthy).
1. Database
createdb slp_db
# or: psql -c "CREATE DATABASE slp_db;"Important:
spring.jpa.hibernate.ddl-autodefaults tovalidate, which means Hibernate will not create tables — it only verifies that the existing schema matches the entities. On a brand-new database, either apply your own DDL, or run the first boot with schema generation enabled:JPA_DDL_AUTO=update ./mvnw spring-boot:runAfter the schema exists, revert to the default
validateso drift is caught early.
2. Backend
cd backend
./mvnw spring-boot:runRuns on http://localhost:8080 with the dev profile. On first boot with an empty users table,
AdminBootstrap creates a default administrator (see Security Model).
3. Frontend
cd frontend
npm install
npm run devRuns on http://localhost:5173 and proxies /api and /api-docs to http://localhost:8080
(override with VITE_PROXY_TARGET).
4. Sign in
Use the bootstrapped administrator credentials, then change the password immediately:
| Password | |
|---|---|
admin@slp.com |
admin123 |
The backend is a Spring Boot 4 application exposing a REST API under /api/**. It is organised as a
classic layered architecture with a strict separation between API contracts (dto), domain state
(entity), and business rules (service).
Key characteristics:
- Stateless — no HTTP session; every request is authenticated independently from its JWT.
- Method-level RBAC — every controller method declares its own
@PreAuthorizeexpression. - Row-level ownership — services additionally verify that the caller owns/participates in the resource, not merely that they hold the right role.
- Uniform error envelope — all errors are serialised as
{ success, message, data }. - Open-in-view disabled —
spring.jpa.open-in-view=false, so all data needed for a response is fetched inside the service transaction and mapped to DTOs before returning.
| Layer | Package | Responsibility |
|---|---|---|
| API / Controller | controller |
HTTP binding, status codes, @PreAuthorize, OpenAPI annotations |
| Contract | dto.request, dto.response |
Validated inbound payloads; safe outbound projections |
| Business logic | service, service.impl |
Transactions, invariants, ownership checks, entity→DTO mapping |
| Persistence | repository |
Spring Data JPA interfaces with derived query methods |
| Domain | entity |
JPA entities and enums |
| Cross-cutting | config, exception, util |
Security, JWT, Swagger, error handling, current-user resolution |
Dependency direction: controller → service → repository → entity. Controllers never touch
repositories, and entities are never returned directly from an endpoint — a DTO is always used so
lazy associations are not serialised and sensitive fields (notably password) can never leak.
config
| Class | Purpose |
|---|---|
SecurityConfig |
SecurityFilterChain, URL authorization rules, CORS, BCrypt encoder, 401/403 entry points. Carries @EnableWebSecurity and @EnableMethodSecurity — without the latter every @PreAuthorize is silently ignored. |
JwtAuthFilter |
OncePerRequestFilter that resolves Authorization: Bearer <jwt> into a Spring Security authentication. Never fails a request itself; a bad token simply leaves the context anonymous so downstream rules answer 401. |
SwaggerConfig |
OpenAPI metadata (bearer auth scheme) plus two GroupedOpenApi groups: public (/api/auth/**) and authenticated (/api/**). |
AdminBootstrap |
ApplicationRunner that seeds the first ADMIN account on an empty database. |
controller — AuthController, ShipmentController, DriverController, VehicleController,
UserController, TrackingController, DashboardController, NotificationController.
exception — BadRequestException (400), ResourceNotFoundException (404),
UnauthorizedException (403), and GlobalExceptionHandler (@RestControllerAdvice).
util
| Class | Purpose |
|---|---|
JwtUtil |
Token generation/validation using the jjwt 0.12 API (Jwts.parser().verifyWith(...)). |
CurrentUserUtil |
Resolves the authenticated User entity from the SecurityContext, plus a hasRole(Role...) helper. |
AppConstants |
Default admin email / password / name constants. |
Entities
| Entity | Table | Key fields |
|---|---|---|
User |
users |
id, email (unique), password (BCrypt hash), firstName, lastName, phoneNumber, role, enabled, createdAt, updatedAt |
Driver |
drivers |
id, licenseNumber (unique), status, user (1:1, unique FK), vehicle (1:1, mapped by Vehicle.driver) |
Vehicle |
vehicles |
id, plateNumber (unique), model, vehicleType, capacity, status, driver (1:1, unique FK) |
Shipment |
shipments |
id, trackingNumber (unique), sender/receiver (M:1 → users), driver/vehicle (M:1, nullable), status, origin/destination address blocks, weight, description, estimatedDelivery, actualDelivery |
TrackingEvent |
tracking_events |
id, shipment (M:1), status, location, description, timestamp |
Notification |
notifications |
id, user (M:1), title, message, read, createdAt |
Relationships
User 1 ──── 1 Driver 1 ──── 1 Vehicle
│ │
│ sender / receiver │ vehicle (nullable)
▼ ▼
Shipment ──── M:1 Driver Shipment ──── M:1 Vehicle
│
└── 1:N TrackingEvent
User 1 ──── N Notification
Enums
| Enum | Values | Notes |
|---|---|---|
Role |
ADMIN, CUSTOMER, DRIVER, DISPATCHER |
Persisted as STRING; granted as ROLE_<name> authorities |
ShipmentStatus |
PENDING, ASSIGNED, IN_TRANSIT, DELIVERED, CANCELLED |
|
DriverStatus |
AVAILABLE, ON_TRIP, INACTIVE |
An enum so invalid values are rejected at the API boundary |
VehicleStatus |
AVAILABLE, IN_USE, MAINTENANCE |
Referential integrity notes
- Deleting a
Shipmentcascades to itsTrackingEventrows (cascade = ALL,orphanRemoval). - Deleting a
Usercascades to theirNotificationrows. Driver.user_idandVehicle.driver_idare bothunique, enforcing a strict 1:1 pairing.
Domain invariants enforced in the service layer
| Rule | Where |
|---|---|
| Sender and receiver must be different users | ShipmentServiceImpl.createShipment |
| A CUSTOMER may only create a shipment where they are the sender | ShipmentServiceImpl.createShipment |
ASSIGNED requires both a driver and a vehicle |
TrackingServiceImpl.applyStatusFromEvent |
A vehicle without a driver cannot be IN_USE |
VehicleServiceImpl.createVehicle |
| A driver cannot be attached to two vehicles | VehicleServiceImpl.createVehicle / assignDriver |
A driver attached to a vehicle cannot be set INACTIVE |
DriverServiceImpl.updateDriverStatus |
| A DRIVER may only append tracking events to their own shipment | TrackingServiceImpl.addTrackingEvent |
| An admin cannot delete their own account | UserController.deleteUser |
A DELIVERED event stamps actualDelivery and syncs the shipment status |
TrackingServiceImpl.addTrackingEvent |
Tracking number generation: SLP- + the first 8 characters of a random UUID, upper-cased
(e.g. SLP-4F2A91C3). Driver licence numbers are generated as DL-<epoch millis>.
Authentication flow
POST /api/auth/login→AuthServiceImpldelegates toAuthenticationManager→DaoAuthenticationProvider→CustomUserDetailsService.loadUserByUsername(email).CustomUserDetailsServiceloads theUserand returns a SpringUserDetailswhose authorities areROLE_<ROLE>and whosedisabledflag mirrorsUser.enabled.- On success,
JwtUtil.generateToken(email)signs an HS256 token withapp.jwt.secret, subject = email, expiry =app.jwt.expirationms. - The token plus user identity are returned in
AuthResponse; the SPA stores both inlocalStorage. - On each subsequent request,
JwtAuthFilterverifies the signature/expiry and re-loads the principal from the database — so a deleted user or a disabled account is rejected even while their token is still cryptographically valid.
Failure semantics (deliberate and client-visible)
| Situation | Status | Reason |
|---|---|---|
| Missing / malformed / expired / forged token | 401 | authenticationEntryPoint — the SPA clears the session and returns to /login |
| Valid token, insufficient role | 403 | accessDeniedHandler — the SPA routes to /unauthorized |
| Wrong password or unknown email | 401 | BadCredentialsException — deliberately generic so email existence is never disclosed |
| Disabled account | 401 | DisabledException ("Account is disabled") |
| Authenticated but violates an ownership rule | 403 | UnauthorizedException — the session is not invalid, so 403 keeps the user signed in |
Spring Security defaults to 403 for unauthenticated requests; the explicit
AuthenticationEntryPointoverrides that to 401, because the SPA must distinguish "session expired" from "not permitted".
URL-level rules (SecurityConfig, evaluated in order)
| Matcher | Rule |
|---|---|
OPTIONS /** |
permit all (CORS pre-flight) |
/api/auth/** |
permit all |
GET /api/shipments/public/** |
permit all (redacted public tracking) |
/swagger-ui/**, /swagger-ui.html, /v3/api-docs/**, /api-docs/** |
permit all |
/error |
permit all |
/actuator/health/**, /actuator/info |
permit all — only when app.actuator.enabled=true |
/actuator/** |
hasRole('ADMIN') — only when actuator is enabled |
/api/admin/** |
hasRole('ADMIN') |
/api/driver/** |
hasAnyRole('DRIVER','ADMIN') |
/api/dispatcher/** |
hasAnyRole('DISPATCHER','ADMIN') |
/api/** |
authenticated() |
| anything else | denyAll() |
Other hardening
| Control | Setting |
|---|---|
| Password hashing | BCryptPasswordEncoder(12) |
| CSRF | Disabled (stateless token API, no cookies) |
| Session | SessionCreationPolicy.STATELESS |
| Frame options | sameOrigin (permits the bundled Swagger UI) |
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' |
| CORS | Allowed origin patterns from app.cors.allowed-origins; methods GET, POST, PUT, PATCH, DELETE, OPTIONS; credentials allowed; maxAge 3600s; registered for /api/** |
| Public tracking payload | Redacted — no names, phone numbers, street addresses, weight, or internal ids |
| Error responses | Internal exception messages are logged, never returned (500 is generic) |
First-run administrator bootstrap (AdminBootstrap)
| Guard | Behaviour |
|---|---|
app.bootstrap.admin.enabled=false |
Skipped entirely |
| An ADMIN already exists | Skipped (never overwrites an existing deployment) |
prod profile and the default password |
Refused, with a warning telling you to set APP_BOOTSTRAP_ADMIN_PASSWORD |
| Otherwise | Creates the admin and logs a warning to change the password |
Production checklist: disable the bootstrap (
APP_BOOTSTRAP_ADMIN_ENABLED=false) after the first successful boot, and replaceJWT_SECRETwith a strong random value.
GlobalExceptionHandler guarantees that every error response has this shape:
{ "success": false, "message": "Human readable description", "data": null }Validation failures put per-field messages into data:
{
"success": false,
"message": "Validation failed",
"data": { "originCity": "Origin city is required", "weight": "Weight must be greater than 0" }
}Exception → status mapping
| Exception | Status | Message source |
|---|---|---|
ResourceNotFoundException |
404 | Exception message |
BadRequestException |
400 | Exception message |
UnauthorizedException |
403 | Exception message |
AccessDeniedException (Spring Security) |
403 | Fixed: "You do not have permission to perform this action" |
BadCredentialsException |
401 | Fixed: "Invalid email or password" |
DisabledException |
401 | Fixed: "Account is disabled" |
AuthenticationException (other) |
401 | Generic |
MethodArgumentNotValidException |
400 | Per-field map in data |
ConstraintViolationException |
400 | Per-field map in data |
HttpMessageNotReadableException |
400 | "Malformed request body or unsupported value" |
MethodArgumentTypeMismatchException |
400 | "Invalid value for parameter ''" |
MissingServletRequestParameterException |
400 | "Missing required parameter ''" |
HttpRequestMethodNotSupportedException |
405 | Spring's message |
NoResourceFoundException |
404 | "Resource not found" |
DataIntegrityViolationException |
409 | "The operation conflicts with existing data…" (cause logged) |
Exception (catch-all) |
500 | Generic; stack trace logged only |
All configuration lives in backend/src/main/resources/application.properties. Every value is
environment-overridable using ${VAR:default} syntax.
Core
| Property | Env var | Default | Description |
|---|---|---|---|
spring.profiles.active |
APP_PROFILE |
dev |
Active Spring profile |
server.port |
SERVER_PORT |
8080 |
HTTP port |
server.compression.enabled |
— | true |
Gzip responses |
server.http2.enabled |
— | true |
HTTP/2 support |
server.error.include-message |
ERROR_INCLUDE_MESSAGE |
always |
Include message in Spring's error payload |
server.error.include-binding-errors |
ERROR_INCLUDE_BINDING_ERRORS |
always |
Include binding errors |
server.error.include-exception |
— | true |
Include exception class |
Database
| Property | Env var | Default |
|---|---|---|
spring.datasource.url |
DB_HOST, DB_PORT, DB_NAME |
jdbc:postgresql://localhost:5432/slp_db |
spring.datasource.username |
DB_USERNAME |
postgres |
spring.datasource.password |
DB_PASSWORD |
mysecretpassword |
spring.datasource.hikari.maximum-pool-size |
DB_POOL_MAX_SIZE |
10 |
spring.datasource.hikari.minimum-idle |
DB_POOL_MIN_IDLE |
5 |
spring.datasource.hikari.connection-timeout |
— | 30000 ms |
spring.datasource.hikari.idle-timeout |
— | 600000 ms |
spring.datasource.hikari.max-lifetime |
— | 1800000 ms |
spring.datasource.hikari.pool-name |
— | slp-hikari-pool |
JPA / Hibernate
| Property | Env var | Default | Description |
|---|---|---|---|
spring.jpa.hibernate.ddl-auto |
JPA_DDL_AUTO |
validate |
validate verifies the schema; it does not create tables |
spring.jpa.properties.hibernate.dialect |
— | PostgreSQLDialect |
|
spring.jpa.show-sql |
JPA_SHOW_SQL |
true |
Log SQL (set false in production) |
spring.jpa.properties.hibernate.format_sql |
JPA_FORMAT_SQL |
true |
Pretty-print SQL |
spring.jpa.open-in-view |
— | false |
Disabled to force explicit fetching in services |
JWT, CORS & Actuator
| Property | Env var | Default |
|---|---|---|
app.jwt.secret |
JWT_SECRET |
404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970 (dev only) |
app.jwt.expiration |
JWT_EXPIRATION |
86400000 (24 h, in ms) |
app.cors.allowed-origins |
CORS_ALLOWED_ORIGINS |
http://localhost:3000,http://localhost:5173 |
app.actuator.enabled |
ACTUATOR_ENABLED |
false |
Admin bootstrap
| Property | Env var | Default |
|---|---|---|
app.bootstrap.admin.enabled |
APP_BOOTSTRAP_ADMIN_ENABLED |
true |
app.bootstrap.admin.email |
APP_BOOTSTRAP_ADMIN_EMAIL |
admin@slp.com |
app.bootstrap.admin.password |
APP_BOOTSTRAP_ADMIN_PASSWORD |
admin123 |
Swagger / OpenAPI
| Property | Value |
|---|---|
springdoc.api-docs.path |
/api-docs |
springdoc.swagger-ui.path |
/swagger-ui.html |
springdoc.swagger-ui.operationsSorter |
method |
springdoc.swagger-ui.try-it-out-enabled |
true |
JWT_SECRETmust be at least 32 bytes for HS256 (Keys.hmacShaKeyFor). The shipped dev default is a 64-hex-character value; always override it in production.
Configured in backend/src/main/resources/logback-spring.xml (logging.config points at it).
Appenders
| Appender | Target | Pattern / policy |
|---|---|---|
CONSOLE |
stdout | %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n |
FILE |
logs/slp-backend.log |
Time + size based rollover, 50 MB per file, 30 days history, 5 GB cap, gzipped |
ERROR_FILE |
logs/slp-backend-error.log |
LevelFilter accepting only ERROR, 25 MB per file, 30 days, 2 GB cap |
The log path and application name resolve from logging.file.path and spring.application.name
(both defaulted: logs, slp-backend). The configuration reloads every 30 seconds (scan="true").
Levels
| Logger | dev (default) |
prod profile |
|---|---|---|
| root | INFO |
WARN |
com.slp.backend |
DEBUG |
INFO |
org.springframework.web |
INFO |
WARN |
org.springframework.security |
INFO |
WARN |
org.hibernate.SQL |
DEBUG |
WARN |
org.hibernate.type.descriptor.sql.BasicBinder |
TRACE |
OFF |
org.springframework.data.jpa |
INFO |
WARN |
The test profile (application-test.properties) overrides everything to WARN except
com.slp.backend (INFO) to keep CI output readable.
Commands
cd backend
./mvnw test # full unit + integration suite
./mvnw test -Dtest=JwtUtilTest # a single class
./mvnw verify # package + testsHow the suite is wired
- Surefire explicitly includes
**/*Test.java,**/*Tests.javaand**/*IT.java— integration suites named*ITwould otherwise be silently skipped. forkCount=1withreuseForks=truekeeps H2 state predictable;-Xshare:offsuppresses the CDS self-attach warning.AbstractIntegrationTestboots the full application on a random port (@SpringBootTest(webEnvironment = RANDOM_PORT)+@AutoConfigureTestRestTemplate) with thetestprofile, so the real security filter chain, JWT filter, controllers, services, repositories, and exception handling are all exercised. Only the database is swapped for in-memory H2 in PostgreSQL compatibility mode.TestDataFactoryseeds valid persisted domain graphs directly through repositories (fast), while authentication always goes through the realPOST /api/auth/loginendpoint so token generation uses production code.- The test profile sets
ddl-auto=create-drop,server.port=0, andapp.bootstrap.admin.enabled=falseso tests fully control their own fixtures.
Test inventory
| Suite | Type | Focus |
|---|---|---|
BackendApplicationTests |
Integration | Context loads; actuator unreachable while disabled |
config/AdminBootstrapTest |
Unit (Mockito) | All four bootstrap guard branches |
controller/AuthControllerIT |
End-to-end | Register/login, role escalation rejected, token usability |
controller/ShipmentWorkflowIT |
End-to-end | Creation, sender forgery, listing scope per role, status rules, deletion |
controller/DriverVehicleIT |
End-to-end | Driver creation/status, vehicle CRUD, assign/unassign pairing rules |
controller/TrackingIT |
End-to-end | Event creation, status sync, actualDelivery, driver ownership, pagination |
controller/PublicTrackingIT |
End-to-end | Anonymous access, PII redaction, 404 envelope, tenant isolation |
controller/UserAdminIT |
End-to-end | User CRUD, password never returned, dashboard stats |
controller/NotificationIT |
End-to-end | Per-user isolation, unread counts, mark as read |
security/AuthorizationIT |
End-to-end | RBAC regression suite — anonymous 401, malformed/forged/deleted-user tokens, per-role access |
service/impl/*Test |
Unit (Mockito) | Business rules per service |
service/CustomUserDetailsServiceTest |
Unit | Authority mapping and disabled flag |
util/JwtUtilTest |
Unit | Generate/extract/validate, expiry, tampering |
Base URL: http://localhost:8080
Interactive docs: Swagger UI at /swagger-ui.html, OpenAPI JSON at /api-docs
Auth header: Authorization: Bearer <jwt> (except endpoints marked public)
Single-resource and mutating endpoints return the resource or DTO directly. Errors — and the
DELETE operations — always use the envelope:
{ "success": true, "message": "Shipment deleted successfully", "data": null }List endpoints return Spring Data's Page shape:
{
"content": [ /* items */ ],
"totalElements": 42, "totalPages": 5, "number": 0, "size": 10,
"first": true, "last": false, "numberOfElements": 10, "empty": false
}Paginated endpoints accept standard Spring Data query parameters:
| Parameter | Default | Notes |
|---|---|---|
page |
0 |
Zero-based page index |
size |
20 (controllers), 10 (SPA) |
Items per page |
sort |
varies | e.g. createdAt,desc, id,asc, timestamp,desc |
The SPA's unwrapPage() helper reads content, totalElements, and totalPages.
| Method | Path | Access | Description |
|---|---|---|---|
POST |
/api/auth/register |
public | Register. Self-registration is limited to CUSTOMER; ADMIN/DISPATCHER/DRIVER are rejected with 400. Returns 201 + AuthResponse. |
POST |
/api/auth/login |
public | Authenticate and receive a JWT. Returns 200 + AuthResponse; invalid credentials return 401. |
RegisterRequest
| Field | Validation |
|---|---|
email |
@NotBlank, @Email |
password |
@NotBlank, @Size(min = 6) |
firstName |
@NotBlank |
lastName |
@NotBlank |
phoneNumber |
optional |
role |
optional; only CUSTOMER accepted |
LoginRequest — email (@NotBlank, @Email), password (@NotBlank)
AuthResponse — token, type (Bearer), id, email, firstName, lastName, role,
phoneNumber
| Method | Path | Access | Description |
|---|---|---|---|
POST |
/api/shipments |
CUSTOMER, DISPATCHER, ADMIN |
Create a shipment (201). Customers must be the sender. |
GET |
/api/shipments |
authenticated | Paginated list, scoped by role (see below) |
GET |
/api/shipments/{id} |
authenticated | Single shipment; ownership enforced |
GET |
/api/shipments/tracking/{trackingNumber} |
ADMIN/DISPATCHER/DRIVER/CUSTOMER |
Full details by tracking number |
GET |
/api/shipments/public/tracking/{trackingNumber} |
public | Redacted tracking projection (no PII) |
GET |
/api/shipments/status/{status} |
authenticated | Filter by ShipmentStatus, role-scoped |
GET |
/api/shipments/driver/{driverId} |
ADMIN, DISPATCHER |
Non-paginated list for one driver. A DRIVER is refused by design (they cannot enumerate another driver's work) — a driver wanting their own list uses GET /api/shipments, which is role-scoped |
DELETE |
/api/shipments/{id} |
ADMIN |
Delete (cascades tracking events) |
There is no
PATCH /api/shipments/{id}/status. A shipment's status is derived exclusively from its journey history: every transition is recorded as a tracking event viaPOST /api/tracking. This guarantees the timeline and the shipment status can never disagree.
Listing scope by role
| Role | Sees |
|---|---|
ADMIN, DISPATCHER |
All shipments |
CUSTOMER |
Only where they are the sender or the receiver |
DRIVER |
Only shipments assigned to their driver profile |
ShipmentRequest
| Field | Validation |
|---|---|
senderId, receiverId |
@NotNull; must differ |
originStreet, originCity, originState, originCountry, originZip |
@NotBlank |
destStreet, destCity, destState, destCountry, destZip |
@NotBlank |
weight |
optional; if present must be > 0 |
description |
optional |
vehicleId |
optional; when supplied and that vehicle has a driver, the shipment is created ASSIGNED and linked to both |
ShipmentResponse — id, trackingNumber, senderId, senderName, receiverId,
receiverName, driverId, driverName, vehicleId, vehiclePlate, vehicleDriverId,
vehicleDriverName, status, the ten address fields, weight, description,
estimatedDelivery, actualDelivery, createdAt, updatedAt.
PublicTrackingResponse — trackingNumber, status, originCity, originCountry,
destCity, destCountry, estimatedDelivery, actualDelivery, createdAt, events[].
Deliberately omits names, phone numbers, street addresses, weight, and internal ids.
| Method | Path | Access | Description |
|---|---|---|---|
POST |
/api/drivers/{userId} |
ADMIN |
Create a driver profile. The user must have role DRIVER and must not already have a profile. |
GET |
/api/drivers/me |
DRIVER, ADMIN, DISPATCHER |
The signed-in user's own driver profile (404 if none) |
GET |
/api/drivers/{id} |
ADMIN/DISPATCHER; a DRIVER may read only their own |
Driver by id |
GET |
/api/drivers |
ADMIN, DISPATCHER |
Paginated list |
GET |
/api/drivers/available |
DISPATCHER, ADMIN |
Paginated list of AVAILABLE drivers with no vehicle |
PATCH |
/api/drivers/{id}/status |
DISPATCHER, ADMIN |
Update status (JSON body, not a raw string) |
DELETE |
/api/drivers/{id} |
ADMIN |
Delete |
DriverStatusUpdateRequest — status (@NotNull, DriverStatus).
Setting INACTIVE while a vehicle is attached returns 400.
DriverResponse — id, licenseNumber, userId, userName, email, phoneNumber,
status, vehicleId, vehiclePlate, createdAt, updatedAt.
| Method | Path | Access | Description |
|---|---|---|---|
POST |
/api/vehicles |
ADMIN |
Create (201). driverId is optional; assigning one forces IN_USE and sets the driver ON_TRIP. |
GET |
/api/vehicles/{id} |
ADMIN/DISPATCHER/DRIVER |
Vehicle by id |
GET |
/api/vehicles |
ADMIN, DISPATCHER |
Paginated list |
GET |
/api/vehicles/available |
DISPATCHER, ADMIN |
Only AVAILABLE |
GET |
/api/vehicles/assignable |
DISPATCHER, ADMIN |
AVAILABLE or IN_USE — the set usable for dispatch |
PUT |
/api/vehicles/{id} |
ADMIN |
Partial update (only non-null fields are applied) |
POST |
/api/vehicles/{vehicleId}/assign/{driverId} |
DISPATCHER, ADMIN |
Pair a driver with a vehicle |
DELETE |
/api/vehicles/{vehicleId}/unassign |
DISPATCHER, ADMIN |
Break the pairing; both return to AVAILABLE |
DELETE |
/api/vehicles/{id} |
ADMIN |
Delete |
VehicleRequest — plateNumber (@NotBlank, unique), model (@NotBlank),
vehicleType (@NotBlank), capacity (@NotNull, > 0), status (@NotNull), driverId
(optional). A vehicle created without a driver cannot be IN_USE.
VehicleResponse — id, plateNumber, model, vehicleType, status, capacity,
driverId, driverName, createdAt, updatedAt.
| Method | Path | Access | Description |
|---|---|---|---|
GET |
/api/users |
ADMIN |
Paginated list |
GET |
/api/users/lookup |
any authenticated user | Full (non-paginated) summary list for sender/receiver dropdowns |
GET |
/api/users/{id} |
ADMIN |
User by id |
GET |
/api/users/role/{role} |
ADMIN |
Paginated filter by role |
POST |
/api/users |
ADMIN |
Create a user with any role (defaults to CUSTOMER) |
PUT |
/api/users/{id} |
ADMIN |
Update profile / role / enabled |
DELETE |
/api/users/{id} |
ADMIN |
Delete; deleting your own account returns 400 |
UserUpdateRequest — email (@NotBlank, @Email), firstName (@NotBlank),
lastName (@NotBlank), phoneNumber, role (@NotNull), enabled (@NotNull).
UserSummaryResponse — id, firstName, lastName, email, role, phoneNumber,
enabled. password is never returned.
| Method | Path | Access | Description |
|---|---|---|---|
POST |
/api/tracking |
ADMIN, DISPATCHER, DRIVER |
Append an event (201). A DRIVER may only add events to shipments assigned to them. |
GET |
/api/tracking/shipment/{shipmentId} |
authenticated | Full history, newest first |
GET |
/api/tracking/shipment/{shipmentId}/page |
authenticated | Paginated history |
Side effects (this is the only way a shipment status changes): the event's status is applied to
the shipment — IN_TRANSIT moves it to IN_TRANSIT, DELIVERED sets DELIVERED and stamps
actualDelivery, and PENDING / ASSIGNED / CANCELLED are applied as-is. ASSIGNED is rejected
with 400 unless both a driver and a vehicle are attached.
TrackingEventRequest — shipmentId (@NotNull), status (@NotNull, ShipmentStatus),
location (@NotBlank), description (optional).
TrackingEventResponse — id, shipmentId, status, location, description, timestamp.
| Method | Path | Access | Description |
|---|---|---|---|
GET |
/api/dashboard/stats |
ADMIN, DISPATCHER |
Aggregate counts for the dashboard |
DashboardStatsResponse — totalShipments, pendingShipments, inTransitShipments,
deliveredShipments, totalUsers, totalDrivers, totalVehicles, and shipmentsByStatus
(a map of every ShipmentStatus name → count).
All notification endpoints are per-user: an ADMIN may access any user's notifications, while
every other role may only access their own (otherwise 403).
| Method | Path | Access | Description |
|---|---|---|---|
POST |
/api/notifications/{userId}?title=…&message=… |
ADMIN |
Create (201). title and message are query parameters. |
GET |
/api/notifications/{userId} |
owner / ADMIN |
Paginated list |
GET |
/api/notifications/unread/{userId} |
owner / ADMIN |
Non-paginated unread list |
GET |
/api/notifications/unread/{userId}/page |
owner / ADMIN |
Paginated unread list |
GET |
/api/notifications/unread-count/{userId} |
owner / ADMIN |
Raw number of unread notifications |
PATCH |
/api/notifications/{notificationId}/read/{userId} |
owner / ADMIN |
Mark one as read |
PATCH |
/api/notifications/read-all/{userId} |
owner / ADMIN |
Mark all as read |
NotificationResponse — id, userId, title, message, read, createdAt.
BASE=http://localhost:8080
# 1. Log in as the bootstrapped administrator
TOKEN=$(curl -s -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"admin@slp.com","password":"admin123"}' | jq -r .token)
# 2. Register a customer
curl -s -X POST "$BASE/api/auth/register" \
-H 'Content-Type: application/json' \
-d '{"email":"cust@slp.com","password":"Secret123!","firstName":"Cara","lastName":"Stone"}'
# 3. Create a shipment (senderId / receiverId are user ids)
curl -s -X POST "$BASE/api/shipments" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"senderId":2,"receiverId":3,
"originStreet":"1 Dock Rd","originCity":"Karachi","originState":"Sindh",
"originCountry":"PK","originZip":"74000",
"destStreet":"9 Harbor St","destCity":"Dubai","destState":"DXB",
"destCountry":"AE","destZip":"00000","weight":12.5,"description":"Fragile"}'
# 4. Public tracking — no token required (use the trackingNumber from step 3)
curl -s "$BASE/api/shipments/public/tracking/SLP-XXXXXXXX"The frontend is a single-page application built with React 19 and Vite 8, styled with Material UI 7. It renders a role-aware application shell (collapsible sidebar, breadcrumb top bar, toast feedback), guards routes on both authentication and role, and talks to the backend through one shared Axios instance.
Design goals visible throughout the code:
- No hardcoded URLs — the API base URL resolves from
VITE_API_URL, otherwise same-origin. - No silent failures — every mutation reports success or failure through
FeedbackProvidertoasts, and pages render explicit loading / empty / error states. - No duplicated styling values — all colours, gradients, and layout constants live in
theme/tokens.js. - Resilient bootstrap — an inline splash in
index.htmlprevents a white flash before React mounts, and a top-levelErrorBoundaryconverts render crashes into a recovery screen.
src/
├── main.jsx # createRoot + StrictMode
├── App.jsx # Provider stack + route table
├── index.css # Global reset, print styles, focus rings, a11y
├── components/ # 16 shared presentational/behavioural components
├── constants/index.js # Enums, status colours, pagination defaults, humanize()
├── context/AuthContext.jsx # Session state, login/register/logout, role helpers
├── pages/ # 15 route-level screens
├── services/ # api.js (Axios) + 8 domain API modules
├── theme/
│ ├── tokens.js # BRAND, DOMAIN_COLORS, GRADIENTS, LAYOUT, storage keys
│ ├── index.js # createAppTheme(mode) MUI theme factory
│ └── ColorModeContext.jsx # Light/dark provider + toggle
└── utils/format.js # Error extraction, date/weight formatting, unwrapPage
App.jsx nests providers so that each layer can rely on the one above it:
ErrorBoundary → catches render-time crashes
└── ColorModeProvider → MUI ThemeProvider + CssBaseline (light/dark)
└── FeedbackProvider → Snackbar/Alert toast queue + useFeedback()
└── AuthProvider → session state, role helpers
└── Router → BrowserRouter + <AppRoutes />
AppRoutes blocks rendering behind a centred LoadingState while AuthContext restores the
session, then computes the role-appropriate landing route.
Public routes (no account required)
| Path | Component | Notes |
|---|---|---|
/login |
Login |
Redirects to the role default route if already signed in |
/register |
Register |
CUSTOMER-only self sign-up; redirects if signed in |
/track |
TrackingPublic |
Anonymous parcel lookup form |
/track/:trackingNumber |
TrackingPublic |
Deep link straight to a tracking number |
/unauthorized |
Unauthorized |
403 landing page with a route back |
Protected routes (wrapped in PrivateRoute with allowedRoles)
| Path | Roles | Component |
|---|---|---|
/ |
all | Layout shell; index redirects to the role default |
/dashboard |
ADMIN, DISPATCHER |
Dashboard |
/shipments |
all | Shipments |
/shipments/create |
ADMIN, DISPATCHER, CUSTOMER |
ShipmentCreate |
/shipments/:id |
all | ShipmentDetail |
/tracking |
ADMIN, DISPATCHER, DRIVER |
Tracking |
/drivers |
ADMIN, DISPATCHER |
Drivers |
/vehicles |
ADMIN, DISPATCHER |
Vehicles |
/users |
ADMIN |
Users |
/notifications |
all | Notifications |
* (inside shell) |
all | NotFound |
Default landing route
| Role | Lands on |
|---|---|
ADMIN, DISPATCHER |
/dashboard |
DRIVER, CUSTOMER |
/shipments |
An unmatched top-level path redirects to /login.
Guard components
| Component | Behaviour |
|---|---|
PrivateRoute |
Renders nothing while the session restores (avoids a login flash), redirects anonymous visitors to /login (preserving state.from), and sends wrong-role users to /unauthorized |
RoleGate |
Inline role check for use inside a page; redirects to /unauthorized when the role is not allowed |
Client-side guards are UX only. The server independently authorizes every request; the UI mirrors those rules so users are not shown actions that would fail.
context/AuthContext.jsx owns all session state and exposes a single hook, useAuth().
Persisted keys (both in localStorage)
| Key | Contents |
|---|---|
slp.token |
Raw JWT (TOKEN_STORAGE_KEY) |
slp.user |
JSON of the AuthResponse (USER_STORAGE_KEY) |
Restoration logic — readStoredSession() runs synchronously on mount:
- Both keys must be present, otherwise there is no session.
slp.usermust parse and contain bothidandrole(a token without these cannot drive role gating).- Corrupt JSON clears both keys and yields no session.
There is deliberately no server round-trip on boot — the JWT carries the identity, and an expired token is rejected by the Axios interceptor on first use.
API exposed by useAuth()
| Member | Description |
|---|---|
user |
The stored session object (or null) |
loading |
true until restoration completes |
login(email, password) |
Calls authService.login, persists the session |
register(userData) |
Calls authService.register, persists the session |
logout() |
Clears both storage keys and the in-memory user |
hasRole(roles) |
true if the user's role is in the array (or equals the single value) |
isAdmin() |
ADMIN |
isDispatcher() |
ADMIN or DISPATCHER |
isDriver() |
ADMIN or DRIVER |
isCustomer() |
ADMIN, CUSTOMER, or DISPATCHER |
useAuth() throws a descriptive error if used outside <AuthProvider>.
services/api.js exports one configured Axios instance (the default export) plus the two storage-key
constants. Each domain module wraps it.
Base URL resolution
| Priority | Source | Result |
|---|---|---|
| 1 | VITE_API_URL |
Explicit override (Docker / CI builds) |
| 2 | (unset) | '' → same origin; Nginx proxies /api in production, Vite proxies it in dev |
localhost is never hardcoded — doing so would break Docker, vite preview, and any non-local
deployment.
Instance configuration — timeout: 30000; headers Content-Type: application/json and
Accept: application/json.
Request interceptor — reads slp.token from localStorage and, when present, sets
config.headers.Authorization = 'Bearer <token>'.
Response interceptor
| Condition | Behaviour |
|---|---|
URL is /api/auth/login or /api/auth/register |
Rejects immediately, so a failed login renders on the form instead of redirecting |
401 |
Clears slp.token + slp.user and navigates to /login (unless already there) |
403 and the URL is not in IN_PLACE_FORBIDDEN_ENDPOINTS |
Navigates to /unauthorized |
403 and the URL is /api/shipments or /api/tracking |
Surfaces the error in place |
The in-place list exists because the backend uses 403 for business-rule rejections as well as for missing roles. A blanket redirect used to yank users off a half-completed shipment form and lose their input.
Domain service modules
| Module | Base path | Methods |
|---|---|---|
authService.js |
/api/auth |
login, register |
shipmentService.js |
/api/shipments |
create, getAll, getById, getByTrackingNumber, getPublicTracking, getByStatus, getByDriver, delete |
driverService.js |
/api/drivers |
getAll, getAvailable, getById, getMine, create, updateStatus, delete |
vehicleService.js |
/api/vehicles |
create, getAll, getAssignable, getById, update, assignDriver, unassignDriver, delete |
userService.js |
/api/users |
getAll, getById, getByRole, getLookup, create, update, delete |
trackingService.js |
/api/tracking |
createEvent, getByShipmentId, getByShipmentIdPage |
dashboardService.js |
/api/dashboard |
getStats |
notificationService.js |
/api/notifications |
create, getByUserId, getUnread, getUnreadCount, markAsRead, markAllAsRead |
Each module returns response.data so callers work with the payload directly. Paginated calls build
query strings with URLSearchParams (page, size, sort).
theme/tokens.js — the single source of truth for visual constants.
| Export | Contents |
|---|---|
BRAND |
primary (#1565c0 / light #5e92f3 / dark #003c8f) and secondary (#00897b / #4ebaaa / #005b4f) |
DOMAIN_COLORS |
Hex colours per domain status: shipment, vehicle, driver, role |
CHART_FALLBACK_COLORS |
7-colour fallback palette for charts with unknown series counts |
GRADIENTS |
brand, brandDark (hero surfaces) and surface (subtle page wash) |
LAYOUT |
drawerWidth: 268, drawerCollapsedWidth: 76, topBarHeight: 64, contentMaxWidth: 'xl' |
COLOR_MODE_STORAGE_KEY |
slp.colorMode |
Domain status colours (shared by StatusChip and the dashboard charts)
| Domain | Values |
|---|---|
shipment |
PENDING #ed6c02 · ASSIGNED #0288d1 · IN_TRANSIT #1565c0 · DELIVERED #2e7d32 · CANCELLED #d32f2f |
vehicle |
AVAILABLE #2e7d32 · IN_USE #0288d1 · MAINTENANCE #ed6c02 |
driver |
AVAILABLE #2e7d32 · ON_TRIP #0288d1 · INACTIVE #757575 |
role |
ADMIN #d32f2f · DISPATCHER #ed6c02 · DRIVER #0288d1 · CUSTOMER #616161 |
theme/index.js — createAppTheme(mode) builds the MUI theme for 'light' or 'dark' from
those tokens, so the two schemes can never drift.
| Aspect | Light | Dark |
|---|---|---|
background.default / paper |
#f4f6fb / #ffffff |
#0b1220 / #131c2e |
text.primary / secondary |
#152238 / #5b6b86 |
#e8edf7 / #9aa8c0 |
divider |
rgba(21,34,56,0.10) |
rgba(255,255,255,0.09) |
Notable component overrides: borderRadius: 10 shape; Inter-first font stack; elevation-less
buttons / cards / paper / app bar; small outlined text fields by default; uppercase, non-wrapping
table headers; a dark gradient drawer (#0d2b52 light / #0d1526 dark); 14 px dialog radius; and a
wave skeleton animation. The theme also paints the fixed background gradient and custom scrollbars,
and re-exports LAYOUT.
theme/ColorModeContext.jsx — resolves the initial mode from slp.colorMode, falling back to
prefers-color-scheme: dark, and defaults to light. It provides { mode, toggle, setMode }, wraps
children in ThemeProvider + CssBaseline, persists the choice (best-effort, in a try/catch), and
keeps document.documentElement.style.colorScheme in sync so native controls follow the theme.
Consumed via useColorMode().
index.css — global reset, 100% height chain, iOS text-size fix, a --slp-topbar-height
variable, a print stylesheet (hides nav / app bar / drawer / pagination / toasts, forces black on
white and full-width tables for dispatch paperwork), prefers-reduced-motion handling, a visible
:focus-visible ring, and forced-colors borders for high-contrast mode.
index.html — declares light/dark theme-color meta tags, a color-scheme hint, a
noindex, nofollow robots directive, inline first-paint styles, a spinner splash element that React
replaces on mount, and a <noscript> fallback explaining that JavaScript is required.
| Component | Purpose |
|---|---|
Layout |
App shell. Permanent collapsible drawer on desktop (persisted to slp.sidebarCollapsed), temporary overlay drawer on mobile; renders <Outlet /> in a max-width container plus a footer |
Sidebar |
Primary navigation in three sections — Operations (Dashboard, Shipments, Tracking), Fleet (Drivers, Vehicles), Administration (Users, Notifications). Each item declares the roles that may see it, mirroring the route guards. Collapsed mode keeps every control reachable via tooltips; exports DRAWER_WIDTH / DRAWER_COLLAPSED_WIDTH |
TopBar |
Breadcrumb trail derived from the URL, light/dark toggle, unread-notification badge, and the user avatar menu with sign-out. Polls unread-count every 60 s and reports a polling failure once per outage instead of silently |
PrivateRoute |
Auth + role route guard (see Routing) |
RoleGate |
Inline role guard for page-level sections |
AuthLayout / AuthBrandMark |
Split marketing-panel layout for the login/register screens, with a compact brand mark for narrow viewports |
PageHeader |
Consistent page title, subtitle, breadcrumb / back affordance, and action slot |
SectionCard / DetailItem |
Titled content card; DetailItem renders a labelled value in a responsive grid |
StatCard |
Dashboard KPI tile with icon, colour accent, hint text, optional click-through, and a skeleton state |
StatusChip |
Colour-aware status pill driven by DOMAIN_COLORS, with a neutral fallback for unknown values |
SearchInput |
Debounced search field used across list pages |
ConfirmDialog |
Reusable destructive-action confirmation dialog |
StateFeedback |
Exports LoadingState, InlineLoading (thin progress bar for background refreshes), TableSkeleton, CardGridSkeleton, EmptyState (with optional call to action), and ErrorState |
FeedbackProvider / useFeedback |
Application-wide toast queue (notifySuccess, notifyError, notifyInfo, notifyWarning) with a 5 s auto-hide |
ErrorBoundary |
Class component catching render errors; shows a branded recovery screen with Reload / Sign-in actions and logs to the console (stack trace only in dev) |
| Page | Route | Behaviour |
|---|---|---|
Login |
/login |
Email/password form with a show-password toggle; on success redirects to the role default (or back to the originally requested path). Failures render inline via getErrorMessage |
Register |
/register |
CUSTOMER-only sign-up with a 0–4 password-strength meter and inline validation. The role selector was intentionally removed — privileged accounts are created by an admin |
Dashboard |
/dashboard |
KPI tiles (total / pending / in-transit / delivered shipments, users, drivers, vehicles), a bar/pie toggle for shipmentsByStatus, a share breakdown, and manual refresh with a silent background refresh path |
Shipments |
/shipments |
Paginated table with search, status filter, page-size selector, row actions (view / track / delete), and a "New shipment" action shown only to ADMIN / DISPATCHER / CUSTOMER |
ShipmentCreate |
/shipments/create |
Booking form for sender/receiver (populated from /api/users/lookup), full origin/destination address blocks, weight, description, and an optional vehicle. Customers are pinned to themselves as sender; staff may pick anyone |
ShipmentDetail |
/shipments/:id |
Full shipment view with DetailItem panels, a paginated tracking timeline, and an "Add event" dialog for ADMIN / DISPATCHER / DRIVER. That dialog is the only way to change a shipment's status |
Tracking |
/tracking |
Tracking management. The shipment picker is populated from GET /api/shipments, which the server scopes by role — so an ADMIN / DISPATCHER sees every shipment and a DRIVER sees only their own assignments, with no role branching in the UI. The selected shipment is mirrored into the shipmentId query parameter so the view is shareable |
TrackingPublic |
/track, /track/:trackingNumber |
Anonymous lookup using the redacted public endpoint. Shows a status stepper, origin/destination summary, and the event table; supports ?trackingNumber= deep links |
Drivers |
/drivers |
Paginated driver table with search, status chips, a status-change dialog, driver-profile creation for an existing DRIVER user, and delete (ADMIN only) |
Vehicles |
/vehicles |
Paginated vehicle table with search, status chips, a create/edit dialog, driver assign/unassign, and delete (ADMIN only) |
Users |
/users |
Paginated user table with search, role filter, create/edit dialog (role + enabled switch), and delete. Self-deletion is blocked with a toast |
Notifications |
/notifications |
Paginated notification list, unread badge, mark-one / mark-all as read, and (ADMIN only) a compose dialog that posts to another user |
Unauthorized |
/unauthorized |
403 screen explaining that the role lacks access, with a route back and a sign-out option |
NotFound |
* inside the shell |
404 screen aware of whether a user is signed in |
utils/format.js
| Helper | Behaviour |
|---|---|
getErrorMessage(error, fallback) |
Prefers per-field validation messages from error.response.data.data (joined as field: message lines), then data.message, then distinguishes timeout (ECONNABORTED) and unreachable-server cases, and finally falls back to the supplied default |
formatDateTime(value) |
Locale date + time, — for null / invalid |
formatDate(value) |
Locale date only, — for null / invalid |
orDash(value) |
— for null / undefined / '' |
formatWeight(value) |
Thousands-separated value with a kg suffix, — when absent |
unwrapPage(data) |
Normalises a Spring Page into { content, totalElements, totalPages } with safe defaults |
constants/index.js
| Export | Contents |
|---|---|
ShipmentStatus |
PENDING, ASSIGNED, IN_TRANSIT, DELIVERED, CANCELLED |
VehicleStatus |
AVAILABLE, IN_USE, MAINTENANCE |
DriverStatus |
AVAILABLE, ON_TRIP, INACTIVE |
UserRole |
ADMIN, CUSTOMER, DRIVER, DISPATCHER |
SHIPMENT_STATUSES, DRIVER_STATUSES, VEHICLE_STATUSES, USER_ROLES |
Array forms for Select options |
SHIPMENT_STATUS_COLORS, VEHICLE_STATUS_COLORS, DRIVER_STATUS_COLORS, USER_ROLE_COLORS |
MUI colour names per status |
SHIPMENT_STATUS_MUI_COLOR |
MUI colour name per shipment status |
STATUS_DOMAIN |
Domain keys (shipment, vehicle, driver, role) for StatusChip |
PAGE_SIZE_OPTIONS |
[5, 10, 25, 50] |
DEFAULT_PAGE_SIZE |
10 |
humanize(value) |
IN_TRANSIT → In Transit |
statusLabel(value) |
humanize with an 'Unknown' fallback for null / undefined |
The frontend enums mirror the backend enums (
com.slp.backend.entity.*) by string value. When you add a status server-side, add it here too orStatusChipwill fall back to its neutral colour.
| Variable | Default | Description |
|---|---|---|
APP_PROFILE |
dev |
Active Spring profile |
SERVER_PORT |
8080 |
HTTP port |
DB_HOST |
localhost |
PostgreSQL host |
DB_PORT |
5432 |
PostgreSQL port |
DB_NAME |
slp_db |
Database name |
DB_USERNAME |
postgres |
Database user |
DB_PASSWORD |
mysecretpassword |
Database password |
DB_POOL_MAX_SIZE |
10 |
HikariCP maximum pool size |
DB_POOL_MIN_IDLE |
5 |
HikariCP minimum idle connections |
JPA_DDL_AUTO |
validate |
Hibernate schema mode (validate does not create tables) |
JPA_SHOW_SQL |
true |
Log SQL statements |
JPA_FORMAT_SQL |
true |
Pretty-print logged SQL |
JWT_SECRET |
dev default | Required in production. Must be ≥ 32 bytes |
JWT_EXPIRATION |
86400000 |
Token lifetime in milliseconds (24 h) |
CORS_ALLOWED_ORIGINS |
http://localhost:3000,http://localhost:5173 |
Comma-separated allowed origin patterns |
ACTUATOR_ENABLED |
false |
Expose /actuator/** (health/info public, the rest ADMIN-only) |
ERROR_INCLUDE_MESSAGE |
always |
Include message in Spring's error payload |
ERROR_INCLUDE_BINDING_ERRORS |
always |
Include binding errors |
APP_BOOTSTRAP_ADMIN_ENABLED |
true |
Seed a first admin on an empty database |
APP_BOOTSTRAP_ADMIN_EMAIL |
admin@slp.com |
Bootstrapped admin email |
APP_BOOTSTRAP_ADMIN_PASSWORD |
admin123 |
Bootstrapped admin password — must be overridden in production |
| Variable | Default | Description |
|---|---|---|
VITE_API_URL |
(empty) | API base URL. Empty means same-origin (dev proxy / Nginx). Set explicitly for a cross-origin backend |
VITE_PROXY_TARGET |
http://localhost:8080 |
Dev-server only. Target for the Vite /api and /api-docs proxy |
VITE_*variables are inlined at build time, not read at runtime — changingVITE_API_URLrequires rebuilding the frontend image.
docker compose up --build
| Service | Image / build | Ports | Notes |
|---|---|---|---|
postgres |
postgres:15-alpine |
5432:5432 |
Health-checked with pg_isready; data in the postgres_data volume |
backend |
./backend/Dockerfile |
8080:8080 |
SPRING_PROFILES_ACTIVE=prod; starts only after Postgres is healthy; restart: unless-stopped |
frontend |
./frontend/Dockerfile |
80:80 |
Nginx-served static build; starts after the backend |
All three share the slp-network bridge network. The compose file reads DB_PASSWORD,
JWT_SECRET, and CORS_ALLOWED_ORIGINS from the environment (.env or exported shell variables)
with sensible fallbacks — except JWT_SECRET, which has no fallback and must be supplied.
Two stages: maven:3.9-eclipse-temurin-17 builds with mvn clean package -DskipTests, then the jar
is copied into eclipse-temurin:17-jre-alpine. The image runs
java -Djarmode=layertools -jar app.jar extract to produce Spring Boot's layered layout for
efficient caching, creates a non-root slp user (UID 1001), and starts via
java org.springframework.boot.loader.launch.JarLauncher.
node:20-alpine runs npm ci then npm run build, and the resulting dist/ is copied into
nginx:1.27-alpine along with nginx.conf. The container runs as the non-root slp user (UID
1001) on port 80.
| Location | Behaviour |
|---|---|
/ |
try_files $uri $uri/ /index.html — SPA history fallback so deep links like /shipments/42 work |
/api/ |
proxy_pass http://backend:8080/api/ with Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto headers |
/api-docs/ |
proxy_pass http://backend:8080/api-docs/ |
Because Nginx serves the SPA and the API from one origin, the browser never makes a cross-origin
request, so CORS_ALLOWED_ORIGINS only matters for separate-origin deployments.
- Set a strong, randomly generated
JWT_SECRET(≥ 32 bytes). - Set
APP_BOOTSTRAP_ADMIN_ENABLED=falseafter the first successful boot. - Override
APP_BOOTSTRAP_ADMIN_PASSWORDif the bootstrap must run inprod. - Change the default admin password immediately after signing in.
- Keep
JPA_DDL_AUTO=validate(the default) so schema drift fails fast. - Set
JPA_SHOW_SQL=falseto stop logging SQL in production. - Set
CORS_ALLOWED_ORIGINSto the real frontend origin(s) — never a wildcard. - Terminate TLS in front of Nginx (
server.http2.enabledand compression are already on). - Keep
ACTUATOR_ENABLED=falseunless you need health probes, and protect them accordingly.
Backend
- Constructor injection only — no field injection (
@RequiredArgsConstructorfor Lombok-based services, explicit constructors for controllers and config). - DTOs at the boundary; entities never leave the service layer. Response DTOs are built in a private
mapToResponse/mapToSummarymethod inside the service implementation. - Every controller method carries
@PreAuthorizeand@Operation(OpenAPI). Role checks are the coarse gate; ownership checks live in the service. - Mutating service methods are
@Transactional; read-only ones use@Transactional(readOnly = true). - Throw
ResourceNotFoundException(404),BadRequestException(400), orUnauthorizedException(403, business-rule rejection). Never throw raw exceptions — the global handler owns the contract. - Prefer derived query methods (
findByStatusAndVehicleIsNull) over hand-written JPQL. - JavaDoc on non-obvious decisions documents the why (client-visible failure semantics, ownership rules, guard rails) more often than the what.
Frontend
- Function components with hooks only; one default export per component file, named exports for
auxiliary pieces (
DetailItem,AuthBrandMark,DRAWER_WIDTH). - Pages own data fetching with
useCallback-memoised loaders and explicitloading/error/refreshingstate; mutations report throughuseFeedback(). - Never hardcode a colour, gradient, radius, or layout size — read it from
theme/tokens.js. - Never hardcode an API URL — use the shared
apiinstance. - Mirror server-side rules in the UI (e.g. disabling
ASSIGNEDwithout a driver and vehicle) so users are not invited into a guaranteed 400. - Keep
eslint.config.jsclean:npm run lint.
| Symptom | Likely cause | Fix |
|---|---|---|
| Backend fails to start with a schema-validation error | ddl-auto=validate and the database has no tables (or has drifted) |
Boot once with JPA_DDL_AUTO=update, then revert to validate |
401 immediately after signing in |
Clock skew, expired token, or JWT_SECRET changed between restarts |
Sign in again; keep JWT_SECRET stable across restarts |
403 on a page right after login |
The account's role lacks access to that route | Check the role; the SPA should route to /unauthorized rather than break |
| Frontend shows "Cannot reach the server" | Backend not running, wrong VITE_API_URL, or wrong dev proxy target |
Start the backend; check VITE_PROXY_TARGET |
| Docker: backend exits while Postgres starts | Postgres health check not yet passing | Compose already gates on service_healthy; inspect docker compose logs postgres |
WeakKeyException from jjwt on startup |
JWT_SECRET shorter than 32 bytes |
Use a longer secret (openssl rand -hex 32) |
| Login fails with the default admin | Bootstrap disabled, an ADMIN already exists, or prod + default password was refused |
Check the startup log for the AdminBootstrap warning |
| CORS error in the browser | Frontend and backend on different origins without a matching CORS_ALLOWED_ORIGINS entry |
Add the frontend origin, or serve both behind the same reverse proxy |
| Notification badge reports a refresh failure | The unread-count poll failed (usually an expired token) | Sign in again; the badge reports one failure per outage by design |
npm run build warns about chunk size |
A new large dependency was added outside the manual chunks | Extend build.rollupOptions.output.manualChunks in vite.config.js |
Backend API metadata declares Apache License 2.0. See https://www.apache.org/licenses/LICENSE-2.0.