How /employees Works — A Walkthrough for Java Devs
Audience: you know Spring/JPA well, but this is your first lap around this repo's
React frontend, JWT auth, and the AG Grid server-side grid pattern. This doc traces one
real feature — the Employees list page at http://localhost:5173/employees — from the
moment it was specified in the migration plan down to the SQL that runs when you type
into a filter box.
Every file path below is real and clickable from the repo root. Read this with the files open; it's meant to be a guided tour, not a replacement for the code.
1. The big picture
┌─────────────────────────┐ HTTPS / JSON ┌──────────────────────────┐
│ React 19 + TypeScript │ ─────────────────────────────▶ │ Spring Boot 4 (Java) │
│ Vite dev server :5173 │ ◀───────────────────────────── │ Tomcat :8080 │
│ │ Authorization: Bearer │ │
│ AG Grid (Community) │ │ Spring Security (JWT) │
│ React Query │ │ Spring Data JPA │
│ Redux (auth state only) │ │ PostgreSQL + Flyway │
└─────────────────────────┘ └──────────────────────────┘
If you've only ever built Spring MVC apps with server-rendered Thymeleaf pages, the
mental shift is: the browser is a separate single-page app that talks to your Spring
Boot app purely as a JSON API. There's no server-side rendering, no sessions, no
HttpSession. Every page is a React component; every piece of data on screen got there
via an axios HTTP call to a @RestController.
2. How this feature was specified
This repo doesn't use Jira tickets — it uses a single living spec file,
docs/archive/PLAN.md, broken into numbered Phases, each a checklist of
concrete deliverables with - [x] boxes. A phase isn't "done" until every box is
checked and there's a one-line note describing how it was verified (curl output,
tsc -b clean, manual smoke test against a real demo account).
The Employees feature was two phases — backend first, frontend second — because the plan deliberately finishes a vertical slice of API before touching any UI:
Phase 3 — Employee Management Module (Backend)
- [x] Entities: Employee, Position, Department
- [x] DTOs: EmployeeDto (+ nested PositionSummaryDto), EmployeeCreateRequest, ...
- [x] EmployeeMapper (MapStruct)
- [x] EmployeeRepository extends JpaRepository, JpaSpecificationExecutor
- [x] EmployeeSpecifications — search (name + employee number...), status,
departmentId, positionId filters, plus fetchPositionAndDepartment() fetch-join
guarded against count queries; null filters use Specification.unrestricted()
- [x] EmployeeService / LookupService — list (paged+filtered), getById, create, ...
- [x] EmployeeController + LookupController:
- [x] GET /api/employees (paged/sorted/filtered) — hr.employees.view
- [x] GET /api/employees/{id} — hr.employees.view
- [x] POST /api/employees — hr.employees.create
- [x] PUT /api/employees/{id} — hr.employees.edit
- [x] PATCH /api/employees/{id}/status — hr.employees.delete
- [x] Swagger UI shows all endpoints; verified via curl with hr_demo: list/get/create/
update/status, search by name and employee number, status/department/position
filters, validation errors (400), not-found (404), and emp_demo correctly
receives 403 on /api/employees
Phase 5 — Employee Management Module (Frontend)
- [x] src/api/employees.ts, src/api/lookups.ts, src/hooks/api/useEmployees.ts
- [x] src/pages/Employees.tsx — AG Grid Infinite Row Model, server-side datasource
(page/size/sort/search/status), columns incl. status badge + actions
- [x] Toolbar — debounced search, status filter, "Add Employee" button (gated on
hr.employees.create), CSV export
- [x] EmployeeFormDrawer.tsx — create/edit form (React Hook Form + Zod)
- [x] EmployeeDetailDrawer.tsx — read-only detail view
- [x] Mutations invalidate employees query to refresh grid
(verified: tsc -b clean, eslint clean, smoke-tested /api/positions and
/api/employees against the running backend as hr_demo...)
The pattern to notice, since you'll repeat it for any future module:
- Write the entities/DTOs/repository/service/controller for one resource — fully, including security annotations — before any frontend exists.
- Verify the API directly (curl or Swagger UI) using two demo accounts with different
permission sets (
hr_demo= HR Administrator,emp_demo= plain Employee) to prove both the "happy path" and the 403 path work. - Only then build the frontend page against the now-frozen API contract.
- Each phase's "done" checkbox is backed by an actual verification step, not just "I wrote the code."
The grid filtering on this page later grew beyond what Phase 5 originally shipped (see
the column-filter work in docs/archive/daily/, e.g.
2026-06-28.md) — same pattern: small dated note describing
what changed and how it was tested, rather than editing PLAN.md retroactively.
3. Backend: from HTTP request to SQL
3.1 The entity
backend/src/main/java/com/motorph/payroll/model/Employee.java
Nothing exotic here if you know JPA — a plain @Entity mapped to the employee table,
identity-generated PK (employeeNumber), and a lazy @ManyToOne to Position:
@Entity
@Table(name = "employee")
public class Employee {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_number")
private Integer employeeNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "position_id", nullable = false)
private Position position;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
// ...salary, contact, government-ID fields
}
isDeleted is a soft-delete flag — there's no DELETE FROM employee anywhere in this
flow; "deactivating" an employee is a status update, and isDeleted is filtered out
of every query (see notDeleted() below). This is a design decision worth internalizing
before you go looking for cascade-delete logic that doesn't exist.
3.2 The repository — JpaSpecificationExecutor
public interface EmployeeRepository
extends JpaRepository<Employee, Integer>, JpaSpecificationExecutor<Employee> {
@EntityGraph(attributePaths = {"position", "position.department"})
Optional<Employee> findWithPositionByEmployeeNumber(Integer employeeNumber);
boolean existsByPosition_PositionId(Integer positionId);
}
Two things to flag if you've only used derived query methods (findByLastName, etc.):
JpaSpecificationExecutor<Employee>addsfindAll(Specification<Employee> spec, Pageable pageable)to the repo for free. This is what lets the grid's "10+ optional filters that can be combined in any order" requirement be satisfied without writing 10! query method permutations.@EntityGraphis Spring Data's annotation-driven way of saying "JOIN FETCH these associations in this one query" — used here so viewing a single employee doesn't trigger lazy-loading exceptions or N+1 queries forposition/department.
3.3 The Specifications — dynamic WHERE clauses
This is the heart of "how Spring Data works behind the scenes" for this page. A
Specification<T> is just a functional interface:
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
— i.e., a lambda that, given the JPA Criteria API's Root (the table being queried),
builds one Predicate (one SQL condition fragment). Spring Data JPA translates whatever
Predicate your lambda returns into an actual WHERE clause at query time, via
Hibernate's Criteria → SQL translation — there's no string concatenation, no risk of SQL
injection, and it's fully type-safe against the entity's field names.
Each filter the grid supports is its own tiny Specification factory method:
public static Specification<Employee> status(String status) {
if (!StringUtils.hasText(status)) return Specification.unrestricted();
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Employee> departmentId(Integer departmentId) {
if (departmentId == null) return Specification.unrestricted();
return (root, query, cb) ->
cb.equal(root.get("position").get("department").get("departmentId"), departmentId);
}
Specification.unrestricted() is a Spring Data JPA 4.x API — it returns a no-op
specification ("WHERE 1=1", roughly) when a filter wasn't supplied. (Older Spring Data
code used to pass null into .and(...) for this; that stopped being legal in 4.x,
which is why you'll see unrestricted() everywhere here instead of null.)
Text filters (last name, first name, position, department) all funnel through one helper that maps AG Grid's filter vocabulary onto Criteria API predicates:
private static Predicate textPredicate(Expression<String> field, String lower, String type, CriteriaBuilder cb) {
if (BLANK.equals(type)) return cb.or(cb.isNull(field), cb.equal(field, ""));
if (NOT_BLANK.equals(type)) return cb.and(cb.isNotNull(field), cb.notEqual(field, ""));
if (!StringUtils.hasText(lower)) return cb.conjunction();
return switch (type == null ? "contains" : type) {
case "equals" -> cb.equal(field, lower);
case "startsWith" -> cb.like(field, lower + "%");
case "notContains" -> cb.notLike(field, "%" + lower + "%");
default -> cb.like(field, "%" + lower + "%"); // "contains"
};
}
This is the backend half of AG Grid's "Contains / Equals / Starts With / Blank / Not
blank" filter menu — every option the column filter UI offers maps 1:1 to a case here.
Finally, all the individual specs get composed with .and(...) in the service
layer (next section) — this is plain functional composition, the same idea as chaining
Predicate.and() in the java.util.function package, just specialized for JPA queries.
One more subtlety worth knowing about: the fetch-join guard.
public static Specification<Employee> fetchPositionAndDepartment() {
return (root, query, cb) -> {
if (Long.class != query.getResultType()) {
root.fetch("position", JoinType.LEFT).fetch("department", JoinType.LEFT);
query.distinct(true);
}
return cb.conjunction();
};
}
When findAll(spec, pageable) runs, Spring Data actually issues two queries under
the hood: one SELECT ... LIMIT/OFFSET for the page of rows, and one
SELECT COUNT(*) (with query.getResultType() == Long.class) to compute
totalElements for the Page<T> object. A fetch() join is only legal on the
row-selecting query — applying it to the count query throws at runtime — so this spec
checks the result type and skips the fetch-join when Hibernate is building the count
query. This is the kind of detail that's invisible until you hit the exception once.
3.4 The service layer
@Override
public Page<EmployeeDto> list(Pageable pageable, EmployeeListFilter filter) {
Specification<Employee> spec = Specification
.where(EmployeeSpecifications.notDeleted())
.and(EmployeeSpecifications.lastNameSearch(filter.lastNameSearch(), filter.lastNameSearchType()))
.and(EmployeeSpecifications.firstNameSearch(filter.firstNameSearch(), filter.firstNameSearchType()))
.and(EmployeeSpecifications.status(filter.status()))
.and(EmployeeSpecifications.departmentId(filter.departmentId()))
// ...one .and() per filter...
.and(EmployeeSpecifications.fetchPositionAndDepartment());
Page<Employee> page = employeeRepository.findAll(spec, pageable);
// Employees don't carry a username column — it lives on the User entity.
// One extra IN-query, then merged into the DTOs in memory (avoids N+1).
Set<Integer> empNums = page.stream().map(Employee::getEmployeeNumber).collect(Collectors.toSet());
Map<Integer, String> usernameByEmpId = userRepository.findAllByEmployeeIdIn(empNums)
.stream().collect(Collectors.toMap(User::getEmployeeId, User::getUsername, (a, b) -> a));
return page.map(emp -> {
EmployeeDto dto = employeeMapper.toDto(emp);
dto.setUsername(usernameByEmpId.get(emp.getEmployeeNumber()));
return dto;
});
}
The class is annotated @Transactional(readOnly = true) at class level (mutating
methods like create/update override it with their own @Transactional) — standard
Spring Data transaction boundary, one per service method, exactly like you'd expect.
Pageable is a parameter the controller builds for you (see next section) — page,
size, and sort query params get bound into it automatically by Spring Web.
3.5 The mapper
EmployeeMapper.java
is a MapStruct interface — at compile time, MapStruct
generates a plain Java implementation class (EmployeeMapperImpl) that does
field-by-field assignment. No reflection at runtime, unlike ModelMapper/Dozer:
@Mapper(componentModel = "spring")
public interface EmployeeMapper {
@Mapping(target = "position", source = "position")
EmployeeDto toDto(Employee employee);
}
3.6 The controller — REST + method security
@GetMapping
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_EMPLOYEES_VIEW + "')")
public ResponseEntity<PageResponseDto<EmployeeDto>> list(
Pageable pageable,
@RequestParam(required = false) String lastNameSearch,
@RequestParam(required = false) String lastNameSearchType,
// ...14 more optional @RequestParams, one per filter...
) {
return ResponseEntity.ok(PageResponseDto.of(
employeeService.list(pageable, new EmployeeListFilter(/* ... */))));
}
Every endpoint on this controller carries its own @PreAuthorize — there's no
class-level blanket rule, so each verb has its own permission string:
| Endpoint | Permission required |
|---|---|
GET /api/employees | hr.employees.view |
GET /api/employees/{id} | hr.employees.view |
POST /api/employees | hr.employees.create |
PUT /api/employees/{id} | hr.employees.edit |
PATCH /api/employees/{id}/status | hr.employees.delete |
GET /api/employees/me, PATCH /api/employees/me | employee.profile.view |
PageResponseDto.of(Page<T>) is a small static factory that flattens Spring Data's
Page<T> into a plain JSON-friendly shape (content, totalElements, totalPages,
currentPage, pageSize) — see
PageResponseDto.java.
This is the exact shape the frontend's AG Grid datasource expects (more on that below).
4. Permissions — how hr.employees.view actually gets checked
Full reference: docs/security/authentication.md and
docs/security/request-pipeline.md. Summary for this page:
4.1 Data model
role ──────────────────── parent_role_id (self-FK; child inherits parent's permissions)
│
└── role_permission ── permission (64 total, "category.subcategory.action")
users ─── role_id (primary role)
│
└── user_role (junction table) ── role_id (additional roles a user can hold)
Four seeded roles, each a superset of the one before it:
| Role | Inherits from | Adds |
|---|---|---|
| Employee | — | 11 self-service permissions |
| HR Administrator | Employee | +14 HR permissions, incl. hr.employees.view/create/edit/delete |
| Payroll Administrator | Employee | +17 payroll permissions |
| System Administrator | Employee | all 64 |
hr_demo logs in as an HR Administrator and therefore holds hr.employees.view →
sees the page. emp_demo only holds the Employee role's 11 permissions → gets a clean
403 from the same endpoint, which is exactly what Phase 3's verification step
checked for.
4.2 Login → JWT issuance
AuthController
(public, no token needed) →
UserDetailsServiceImpl
loads the user, walks the role hierarchy, and flattens it into a Set<GrantedAuthority>
→ password verified by DaoAuthenticationProvider (Argon2id, with legacy
BCrypt hashes transparently upgraded on login) →
JwtTokenManager
signs a JWT (HMAC256, 15-minute expiry, paired with a rotating 14-day refresh
token — see ../security/authentication.md)
embedding username, userId, employeeId, roles[]. The response body returns the token and the user's full flattened
permissions[] array — the frontend never has to compute role inheritance itself.
4.3 Every subsequent request
SecurityConfiguration.java
sets up a stateless filter chain (SessionCreationPolicy.STATELESS — no
HttpSession, ever) and registers a custom filter before Spring's default
username/password filter:
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
JwtAuthenticationFilter
runs on every request: pulls Authorization: Bearer <token>, validates the
signature/issuer/expiry, reloads the user from the DB (so a deactivated account is
rejected even with a still-valid token), and populates
SecurityContextHolder with a UsernamePasswordAuthenticationToken carrying the
authorities. If the header is missing/invalid, the filter does nothing — the security
context stays empty, and Spring later returns 401.
@EnableMethodSecurity on SecurityConfiguration is what makes @PreAuthorize on
EmployeeController actually get evaluated — Spring wraps the controller bean in a
proxy that checks hasAuthority(...) against the SecurityContext's authorities
before the method body runs. No match → Spring throws AccessDeniedException →
translated to 403.
4.4 Frontend mirrors the same permission strings
frontend/src/constants/permissions.ts
is a hand-kept TypeScript mirror of the backend's
PermissionConstants.java
— same string values ('hr.employees.view', etc.) on both sides. This is not
generated from the backend automatically; if a new permission is added in Java, it has
to be added by hand in TS too, or the frontend route guard will silently never match it.
There are two enforcement points on the frontend, and neither replaces the
backend check — they exist purely for UX (don't show a button the user can't use; the
real gate is always the @PreAuthorize on the server):
// App.tsx — route-level: redirect away from /employees entirely if not permitted
<Route element={<RequirePermission permission={PermissionConstants.HR_EMPLOYEES_VIEW} />}>
<Route path={ROUTES.EMPLOYEES} element={page(Employees)} />
</Route>
// Employees.tsx — element-level: hide the "Add Employee" button
const canCreate = hasPermission(PermissionConstants.HR_EMPLOYEES_CREATE);
{canCreate && <Button onClick={...}>Add Employee</Button>}
hasPermission (from useAuth.ts) just does a
Set.has() check against the permissions[] array that came back in the login
response — no network call, purely client-side, purely cosmetic.
5. Frontend: from a page load to a JSON request
5.1 Routing — does this user even get to see the page?
App.tsx wraps every authenticated route in
<ProtectedRoute> (redirects to /login if no JWT) and the /employees route
specifically in <RequirePermission permission="hr.employees.view">
(require-permission.tsx):
export const RequirePermission = ({ permission }) => {
const { hasPermission } = useAuth();
const permissions = Array.isArray(permission) ? permission : [permission];
if (!permissions.some(hasPermission)) return <Navigate to={ROUTES.DASHBOARD} replace />;
return <Outlet />;
};
Think of this as the React equivalent of a Spring Security @PreAuthorize — except it
only controls whether the component renders, not whether data is fetchable. The real
authorization still happens server-side on every API call.
5.2 Axios client — the JWT gets attached automatically
api/client.ts is a single shared axios
instance every API call goes through. A request interceptor reads the token out of
the Redux store and attaches it to every outgoing call — equivalent to a Java
HttpClient's request filter / ClientHttpRequestInterceptor:
apiClient.interceptors.request.use(config => {
const { token } = store.getState().auth;
if (token) config.headers.set('Authorization', `Bearer ${token}`);
return config;
});
apiClient.interceptors.response.use(
response => response,
async error => {
if (axios.isAxiosError(error) && error.response?.status === 401 && !config._retried) {
config._retried = true;
await refreshAccessToken(); // single shared in-flight refresh (rotating token)
return apiClient(config); // retry the original request once
}
// refresh failed or already retried → logout, boot to /login
store.dispatch(logout());
return Promise.reject(...);
}
);
(Simplified — the real interceptor in
client.ts also skips the auth endpoints
themselves and serializes concurrent refreshes through one shared promise; see
../frontend/api-layer.md.)
You write the call site (employees.ts, below) without ever touching headers — same
pattern as a Spring RestTemplate/WebClient interceptor doing auth for you.
5.3 The typed API layer
api/employees.ts — this is the TypeScript
equivalent of a thin REST client/Feign interface. One function per backend endpoint,
typed request/response shapes:
export const listEmployees = async (params: ListEmployeesParams): Promise<PageResponse<EmployeeDto>> => {
const { data } = await apiClient.get<PageResponse<EmployeeDto>>('/api/employees', { params });
return data;
};
ListEmployeesParams (TS interface) lines up field-for-field with the controller's
@RequestParam list, and EmployeeDto/PageResponse<T> (in
api/types.ts) mirror the backend's
EmployeeDto/PageResponseDto<T> JSON shape by hand — there's no codegen from the
OpenAPI spec in this repo, so keeping these two in sync across a backend change is a
manual step.
5.4 React Query — caching and mutation invalidation
hooks/api/useEmployees.ts wraps the
raw API functions in TanStack Query hooks:
export const useCreateEmployee = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: EmployeeRequest) => createEmployee(payload),
onSuccess: () => { void queryClient.invalidateQueries({ queryKey: EMPLOYEES_QUERY_KEY }); },
});
};
React Query here plays a role similar to a second-level cache + write-through
invalidation: after a successful POST, any component subscribed to the 'employees'
query key is told to refetch. (The list grid itself doesn't use useQuery for its rows
— see below — but the form drawers and detail views do.)
5.5 AG Grid's infinite row model — the actual mechanism behind "server-side paging/filtering/sorting"
This is the piece with no direct Spring analogue, so it's worth slowing down on.
ServerDataGrid.tsx wraps AG Grid
configured with rowModelType="infinite". Instead of holding all rows in memory, AG
Grid asks you for each "block" of rows as the user scrolls/sorts/filters, via a
datasource.getRows(params) callback you provide:
const datasource: IDatasource = {
getRows: params => {
const size = params.endRow - params.startRow;
const page = Math.floor(params.startRow / size);
const sortModel = params.sortModel.filter(m => sortableFieldsRef.current.has(m.colId));
const apiParams = buildParamsRef.current(params.filterModel, sortModel, page, size);
fetchPageRef.current(apiParams).then(res => {
params.successCallback(res.content, res.totalElements); // hand rows back to AG Grid
}).catch(() => { params.failCallback(); });
},
};
buildParams and fetchPage are injected as props — for the Employees page, fetchPage
is listEmployees from api/employees.ts, and buildParams lives in
pages/hr/Employees.tsx:
function makeBuildParams(fixedStatus?: EmployeeStatus) {
return (fm, sortModel, page, size): ListEmployeesParams => {
const lastNameF = extractTextFilter(fm.lastName); // AG Grid's filter model →
const eidR = extractNumberRange(fm.employeeNumber); // backend's flat query-param shape
return {
page, size,
sort: sortModel.map(m => `${m.colId},${m.sort}`),
lastNameSearch: lastNameF?.value, lastNameSearchType: lastNameF?.type,
employeeIdFilterType: eidR.filterType, employeeIdMin: eidR.min, employeeIdMax: eidR.max,
status: fixedStatus ?? statusF?.value?.toUpperCase(),
// ...
};
};
}
So the full round trip when you type into the "Last Name" column filter is:
- AG Grid's built-in filter UI fires
onFilterChanged. - AG Grid calls
datasource.getRows()with its internalfilterModel(AG Grid's own shape:{ lastName: { filterType: 'text', type: 'contains', filter: 'cruz' } }). extractTextFilter()(filterUtils.ts) normalizes that into{ value: 'cruz', type: 'contains' }.buildParamsturns it intolastNameSearch=cruz&lastNameSearchType=contains.listEmployees()firesGET /api/employees?lastNameSearch=cruz&lastNameSearchType=contains&page=0&size=20.- The Spring controller binds those to
@RequestParams, builds anEmployeeListFilter, passes it toEmployeeServiceImpl.list(), which builds theSpecificationchain covered in §3.3–3.4 and runs it against Postgres. - The JSON response (
PageResponseDto) comes back;params.successCallback(res.content, res.totalElements)hands the rows to AG Grid, which renders them and updates the row count for its virtual scrollbar.
cacheBlockSize (defaults to the page size, 20) controls how many rows AG Grid asks for
per getRows() call — i.e., it is the page size sent to Spring's Pageable.
5.6 Tying it together — Employees.tsx
pages/hr/Employees.tsx is the actual page
component. It doesn't manage rows itself — it configures <ServerDataGrid> (via a
<TabbedServerGrid> wrapper, since this page has All/Regular/Probationary/Inactive
tabs, each its own grid instance with a fixedStatus baked into buildParams) and
wires up the toolbar buttons to permission checks and React Query mutations:
const canCreate = hasPermission(PermissionConstants.HR_EMPLOYEES_CREATE);
const updateStatusMutation = useUpdateEmployeeStatus();
// ...
{canCreate && <Button onClick={() => setFormOpen(true)}>Add Employee</Button>}
<TabbedServerGrid tabs={tabs} defaultTab="all" />
<EmployeeFormDrawer open={formOpen} onSuccess={refreshGrid} />
refreshGrid calls gridApi.purgeInfiniteCache() — tells AG Grid to throw away its
cached blocks and call getRows() again, which is how the grid picks up a row you just
created/edited via the drawer (this is the manual equivalent of React Query's
invalidateQueries, just AG Grid's own API instead).
6. Request/response contract cheat sheet
| Frontend sends | Backend expects / returns | |
|---|---|---|
| Auth | Authorization: Bearer <jwt> header (axios interceptor) | JwtAuthenticationFilter reads it |
| List query params | page, size, sort=lastName,asc, lastNameSearch, lastNameSearchType, status, departmentId, ... (flat query string, built by buildParams) | Bound to Pageable + 16 @RequestParams on EmployeeController#list |
| List response | PageResponse<EmployeeDto> (TS, in api/types.ts) | PageResponseDto<EmployeeDto> (Java) — content, totalElements, totalPages, currentPage, pageSize |
| Single employee | — | EmployeeDto — flat fields + nested position: PositionSummaryDto |
| Create/update | EmployeeRequest JSON body (React Hook Form + Zod-validated client-side) | EmployeeCreateRequest/EmployeeUpdateRequest (Jakarta @Valid-validated server-side — the server validation is the one that actually matters; client-side is UX only) |
7. Sequence diagram
End-to-end flow: logging in, then loading the Employees grid with a name filter applied.
If the user instead were emp_demo (no hr.employees.view), the
@PreAuthorize check in the CTRL step fails, Spring throws AccessDeniedException,
and the response is 403 Forbidden with no further DB queries — and on the frontend,
the <RequirePermission> guard would already have redirected them to the dashboard
before any request was even made.
8. Glossary — Spring concept → frontend equivalent
| Java/Spring world | This repo's frontend equivalent |
|---|---|
@RestController / @RequestMapping | a file in api/*.ts exporting one function per endpoint |
| DTO class | TypeScript interface in api/types.ts, kept manually in sync |
@PreAuthorize("hasAuthority(...)") | <RequirePermission permission="..."> (UX-only — real gate is still the backend) |
SecurityContextHolder | Redux state.auth (token, user, activeRole) |
HttpSession | none — stateless JWT, re-sent on every request via axios interceptor |
Bean validation (@Valid, Jakarta) | React Hook Form + Zod schema (client-side UX; server is authoritative) |
Specification<T> composition | buildParams() translating AG Grid's filter model into query params |
Hibernate first-level cache / @Transactional | React Query's cache + invalidateQueries |
| MapStruct mapper | none direct — DTO shapes are just asserted via TS types, no runtime mapping needed (JSON parses straight into the shape) |
application.yml / @Value | .env + import.meta.env.VITE_* (Vite env vars) |
9. Where to go next
- Build one yourself: build-a-module-page.md is the do-it-yourself companion to this walkthrough — the full recipe for shipping a new module page (migration → backend → permissions → enterprise grid with drawers, custom actions, and filters → tests) without supervision.
- Trace the same pattern for a module you haven't touched yet —
Department/Position(Phase 7 inPLAN.md) is the smallest possible version of this whole flow. - Read
docs/security/authentication.mdin full for the parts of the permission system this doc only summarized (role switching, the four-role inheritance tree in detail). - Read
docs/security/request-pipeline.mdfor security concerns beyond authorization (CORS config, password hashing, rate limiting if present). - Open
EmployeeSpecifications.javaside-by-side withfilterUtils.tsand match every AG Grid filter type (contains,equals,blank, ...) to its Criteria API case — that mapping is the single most reusable piece of knowledge for adding a filter to any other grid in this app.