Build a new module page — the full-stack recipe
Goal: you've been asked to add a new list page — "like Employees" — for some new entity. This doc is the step-by-step recipe: database migration → backend → permissions → frontend enterprise grid with drawers, custom actions, and custom filters → tests. Follow it top to bottom and you can ship the whole vertical slice without asking anyone, and it will look and behave exactly like every other page in the app.
How this doc relates to its neighbors:
- employees-walkthrough.md explains how the existing Employees page works, layer by layer. Read it first if any step below feels unfamiliar.
- ../frontend/ag-grid.md is the grid subsystem reference; this doc tells you which parts to use and in what order.
- Two Claude Code skills scaffold big chunks of this recipe:
ag-grid-server-table(a full server-side table page) andadd-import-export(CSV import/export for an existing page). Use them — but read this doc anyway, so you can review what they generate.
Reference implementations (open these side-by-side with each phase):
| Layer | Copy from | Why this one |
|---|---|---|
| Everything frontend | pages/hr/Employees.tsx | Fullest useEnterpriseGrid adoption: tabs, saved views, archive, bulk, import/export |
| Backend module | JobRequisition* (entity → controller) | Newest complete CRUD+grid module, includes field projection |
| Simple backend list | CrmLead* | The simpler service style, when you don't need projection |
| Tenant-scoped migration | V108__recognition_awards.sql | Most recent new-tables migration with the RLS blocks |
Phase 0 — Write the contract before any code
Decide, on paper, before touching anything (this is the repo's established pattern — backend fully first, then frontend against a frozen contract):
- Entity + table name (snake_case singular, e.g.
equipment_request). - Columns the grid shows, and for each: type (text/number/date/enum), filterable? sortable? shown by default or a "detail" column?
- Tabs — is there a status-like field worth splitting into tabs?
- Permissions — usually
domain.thing.view/.create/.edit/.delete(category.subcategory.action), and which roles get each. Sometimes an existing permission fits: V108 (Recognition) added no new permissions and reused payroll ones. Prefer reuse when the audience is the same people. - Row actions — View / Edit / Archive / Restore / domain-specific ones.
- API shape —
GET /api/<things>(paged list),GET /{id},POST,PUT /{id},PATCH /{id}/status|archive|restore,DELETE /{id}— keep to this vocabulary; every existing module uses it.
Phase 1 — Database migration
Full conventions: ../backend/entities-and-migrations.md §5. The short version:
-
Take the next free V number. Check
backend/src/main/resources/db/migration/right before merging — parallel branches collide on numbers. Name itV<N>__short_snake_case.sql. Never edit an applied migration (Flyway checksums); never reuse a gap (V26 is deliberately unused). -
Write the migration before the entity — Hibernate runs
ddl-auto: validate, so the backend refuses to boot if entity and schema disagree. Schema first, entity to match. -
Every new table is tenant-scoped (this app is shared-DB multi-tenant). That means three things in the SQL:
CREATE TABLE equipment_request (request_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,tenant_id INTEGER NOT NULL REFERENCES tenant (tenant_id),-- ...your columns...-- uniques must include tenant_id, or tenant A's names block tenant B's:CONSTRAINT uq_equipment_request_tenant_name UNIQUE (tenant_id, name));-- The RLS block — copy verbatim per table (pattern from V101, latest copies in V108):ALTER TABLE equipment_request ENABLE ROW LEVEL SECURITY;CREATE POLICY tenant_isolation ON equipment_requestUSING (current_setting('app.bypass_rls', true) = 'on'OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)WITH CHECK (current_setting('app.bypass_rls', true) = 'on'OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);No
GRANTneeded — V101 set default privileges forapp_runtime. If you create a view, you must addsecurity_invoker = trueyourself or it bypasses RLS. -
Seed the permissions in the same migration (if new ones are needed). The canonical pattern (from V89):
INSERT INTO permission (permission_name, description, category)VALUES ('hr.equipment.view', 'View equipment requests.', 'HR')ON CONFLICT (permission_name) DO NOTHING;INSERT INTO role_permission (role_id, permission_id)SELECT r.role_id, p.permission_idFROM role rCROSS JOIN permission pWHERE p.permission_name = 'hr.equipment.view'AND r.role_name IN ('HR Administrator', 'System Administrator')ON CONFLICT DO NOTHING;Do not add
AND r.tenant_id = .... Filtering by role name only is deliberate: since V104, every tenant has its own copy of each role plus atenant_id IS NULLblueprint — the name-only grant reaches all of them, which is exactly what you want.
Phase 2 — Backend module
Package-by-package, copying the JobRequisition module. All paths under
backend/src/main/java/com/motorph/payroll/.
2.1 Entity — model/
@Entity
@Table(name = "equipment_request")
@Getter @Setter @NoArgsConstructor
public class EquipmentRequest extends TenantOwned {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "request_id")
private Integer requestId;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "department_id", nullable = false)
private Department department;
private String status = "Draft"; // defaults as field initializers
@Column(name = "is_archived", nullable = false)
private Boolean isArchived = false; // soft delete, never DELETE
}
House rules: Lombok @Getter @Setter @NoArgsConstructor (never @Data or
@Builder); every @ManyToOne is LAZY; extends TenantOwned is
mandatory for tenant tables — it makes Hibernate stamp tenant_id on insert
and add WHERE tenant_id = ? to every query automatically. You never touch
tenant_id in code.
2.2 Repository — repository/
public interface EquipmentRequestRepository
extends JpaRepository<EquipmentRequest, Integer>, JpaSpecificationExecutor<EquipmentRequest> {
@EntityGraph(attributePaths = {"department"})
Optional<EquipmentRequest> findWithDetailsByRequestId(Integer requestId);
}
2.3 Specifications — repository/specification/
One final utility class, one static factory method per filterable column,
Specification.unrestricted() when the filter wasn't supplied (never null).
Copy JobRequisitionSpecifications's private helpers wholesale — they are the
backend half of AG Grid's filter menus:
textPredicate(field, lower, type, cb)— mapscontains/equals/notEqual/startsWith/endsWith/notContains/blank/notBlank.numberFilter(field, min, max, filterType)anddateTimeFilter(field, from, to, filterType).- Association columns join with
root.join("assoc", JoinType.LEFT); computed names usecb.concat(...).
2.4 Filter record + service — dto/ + service/impl/
Bundle every filter param into one record (EquipmentRequestFilter),
mirroring the frontend naming convention exactly (see Phase 5.3):
<col>Search/<col>SearchType for text, <col>Min/<col>Max/<col>FilterType
for numbers, <col>From/<col>To/<col>FilterType for dates.
Two service styles exist — pick by need:
| Style | When | Copy from |
|---|---|---|
(a) Standard — compose specs, repository.findAll(spec, pageable).map(this::toDto) | All sortable columns are direct entity attributes | CrmLeadServiceImpl |
(b) Criteria + Tuple projection — raw EntityManager queries | The grid projects columns (fields= param) and/or sorts on joined/computed columns (e.g. a joined departmentName, a concatenated person name) | JobRequisitionServiceImpl |
If you use (b), keep its three invariants: one private filterSpec(filter)
shared by list()/listProjected()/countMatching() (filters can never
drift apart); one sortOrders(sort, root, cb) switch mapping each grid colId
to its expression (shared by both list paths); and hoist each join into a
local variable used by both SELECT and ORDER BY (each root.join() call mints
a new join — resolving per use-site duplicates the join in the SQL).
Class-level @Service @RequiredArgsConstructor @Transactional(readOnly = true),
with each write method annotated @Transactional.
DTO conventions: response DTOs are plain Lombok POJOs
(@Getter @Setter @NoArgsConstructor @AllArgsConstructor), the PK field is
named id in the DTO regardless of the entity's PK name, and the mapping
is a hand-written private XDto toDto(X e) in the service impl — MapStruct
exists in the repo but only three legacy mappers use it; new modules don't.
2.5 Controller — controller/
@RestController
@RequestMapping("/api/equipment-requests")
@RequiredArgsConstructor
@Tag(name = "Equipment Requests")
public class EquipmentRequestController {
private final EquipmentRequestService service;
@GetMapping
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_EQUIPMENT_VIEW + "')")
public ResponseEntity<PageResponseDto<?>> list(
Pageable pageable,
@RequestParam(required = false) String statusSearch,
@RequestParam(required = false) String statusSearchType,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdAtFrom,
// ...one @RequestParam per filter param...
@RequestParam(required = false) Boolean archived,
@RequestParam(required = false) String fields) {
// no fields → full DTOs; fields → projected Map rows (style (b) only)
}
}
Rules: every endpoint gets its own @PreAuthorize (no class-level
blanket); need OR-of-two-permissions? Use a
private static final String VIEW_AUTHORITIES = "hasAnyAuthority('…','…')"
constant like RecognitionAwardController. Mutating endpoints take
@Valid @RequestBody request DTOs with Jakarta annotations (@NotNull,
@NotBlank, @Min) — validation errors, unknown sort properties, bad params
etc. are already translated to the standard error shape by
GlobalControllerAdvice; you write no try/catch. Current user, when
needed: @AuthenticationPrincipal AuthenticatedUser currentUser.
Wrap pages with PageResponseDto.of(page) — its
content/totalElements/totalPages/currentPage/pageSize shape is
exactly what the frontend datasource expects.
Phase 3 — Permission wiring (three files, by hand, same PR)
There is no automated sync between backend and frontend permissions — this is the repo's most recurring gotcha. Adding a permission touches:
-
backend/.../constants/PermissionConstants.java—HR_EQUIPMENT_VIEW = "hr.equipment.view"under the right section banner. -
frontend/src/constants/permissions.ts— same constant name, same string, same banner. -
frontend/src/constants/role-permissions.ts— add the permission to exactly the roles your migration granted it to. This static map is what shows/hides nav items and buttons; miss it and the feature is invisible in the UI even though the API works (the troubleshooting.md case study is precisely this failure).
Keep the three in lockstep with the migration's role_permission INSERTs.
Phase 4 — Prove the API before writing any frontend
The repo's standing pattern: freeze the contract by verifying it directly.
-
docker compose up -d --build(or./dev.shfor the host loop), open Swagger at http://localhost:8081/swagger-ui.html. - As a permitted demo user (e.g.
hr_demo— passwords are in ../reference/demo-users.md): list, get, create, update, status-change; try a text filter param, a number range, a sort on a joined column. - As a non-permitted user (
emp_demo): confirm the clean 403. - Boot check: if the backend starts at all, entity↔schema agree
(
ddl-auto: validateguarantees it).
Phase 5 — Frontend
Four files, in dependency order: API layer → hooks → drawers → page. Then routing + nav.
5.1 API layer — frontend/src/api/equipment-requests.ts
One typed function per endpoint, all through the shared apiClient (JWT and
401-refresh are automatic). The list params interface mirrors the controller's
@RequestParam list field-for-field — this is a hand-kept contract. If the
backend has field projection, expose the same endpoint twice like
api/employees.ts does: listX returning full DTOs and listXRows returning
the projected row type (requires fields).
5.2 Hooks — frontend/src/hooks/api/useEquipmentRequests.ts
export const EQUIPMENT_QUERY_KEY = ['equipment-requests'];
export const useEquipmentRequest = (id: number | null) =>
useQuery({ queryKey: ['equipment-requests', id], queryFn: () => getEquipmentRequest(id as number), enabled: id !== null });
export const useCreateEquipmentRequest = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: EquipmentRequestPayload) => createEquipmentRequest(payload),
onSuccess: () => { void queryClient.invalidateQueries({ queryKey: EQUIPMENT_QUERY_KEY }); },
});
};
Conventions: one exported root query key; hierarchical literal keys
(['x', id], ['x', 'all']); every mutation invalidates the root key; no
toasts in hooks — toasts belong to the calling component.
Note what this invalidation does and doesn't do: it refreshes React Query consumers (detail drawers, card views). It does not refresh the grid — the grid is AG Grid's infinite row model, refreshed separately (5.7).
5.3 Columns + filters — the EnterpriseGridColumn[] array
Each column is the single source of truth for its colDef, View-menu entry,
search-target option, and backend fields= projection token:
const columns = useMemo<EnterpriseGridColumn<EquipmentRowDto>[]>(() => [
{ field: 'id', headerName: 'ID',
colDef: { width: 120, pinned: 'left', sortable: true,
filter: 'agNumberColumnFilter', filterParams: { maxNumConditions: 1 } } },
{ field: 'name', headerName: 'Name', searchable: true,
colDef: { flex: 1, sortable: true, filter: 'agTextColumnFilter',
filterParams: { maxNumConditions: 1 } } },
{ field: 'status', headerName: 'Status',
colDef: { width: 140, sortable: true, filter: 'agTextColumnFilter',
filterParams: { filterOptions: ['equals', 'notEqual'], defaultOption: 'equals', maxNumConditions: 1 },
cellRenderer: StatusCell } },
{ field: 'requestedAt', headerName: 'Requested', defaultVisible: false,
colDef: { width: 150, sortable: true, filter: 'agDateColumnFilter',
filterParams: { filterOptions: ['equals', 'inRange', 'lessThan', 'greaterThan'],
maxNumConditions: 1, browserDatePicker: true },
valueFormatter: p => formatDate(p.value as string) } },
], []);
The flags that matter:
| Flag | Effect |
|---|---|
searchable: true | column appears in the toolbar quick-search "Search In" options |
defaultVisible: false | a "detail" column — hidden at first open, listed in View ▾, and not fetched until switched on |
alwaysVisible: true | pinned out of the View menu and always in the fields= projection |
projectable: false | client-only/computed column, never sent as a projection token |
Custom filters, the house way. There is no agSetColumnFilter anywhere in
this codebase — an enum/status column is a text filter restricted to the
operations the backend supports via filterParams.filterOptions (as in the
status column above). Always set maxNumConditions: 1: the param extractors
only read the first condition. Fixed-value filtering that defines a whole view
belongs in a tab (fixedFilterValue) or extraParams, not a filter.
cellRenderer components are declared at module scope, never inline.
5.4 buildParams — AG Grid filter model → your API params
A factory that closes over the tab context, using the extractors from
filterUtils.ts
(extractTextFilter, extractNumberRange, extractDateRange):
function makeBuildParams(fixedStatus?: string, fields?: string, archived?: boolean) {
return (fm: FilterModel, sortModel: SortModelItem[], page: number, size: number): ListEquipmentParams => {
const nameF = extractTextFilter(fm.name);
const dateR = extractDateRange(fm.requestedAt);
const statusF = extractTextFilter(fm.status);
return {
page, size,
sort: sortModel.length ? sortModel.map(m => `${m.colId},${m.sort}`) : undefined,
fields, archived,
nameSearch: nameF?.value || undefined,
nameSearchType: nameF?.type || undefined,
requestedAtFrom: dateR.from, requestedAtTo: dateR.to, requestedAtFilterType: dateR.filterType,
status: fixedStatus ?? statusF?.value?.toUpperCase(),
};
};
}
Conventions: sort colIds are sent raw (the backend's sortOrders switch
maps them — don't rewrite to dot-notation); empty values become undefined so
axios drops them; the param names are the contract with Phase 2.4's filter
record — same suffixes on both sides.
5.5 The page — useEnterpriseGrid + TabbedServerGrid
Skeleton of the page component (the full annotated original is
Employees.tsx):
import '@/ui/ag-grid/setup'; // mandatory once per page — registers AG Grid modules
const GRID_STORAGE_KEY = 'motorph-equipment-view-options';
export default function EquipmentRequests() {
const { hasPermission } = useAuth();
const canCreate = hasPermission(PermissionConstants.HR_EQUIPMENT_CREATE);
const canEdit = hasPermission(PermissionConstants.HR_EQUIPMENT_EDIT);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); // null = create
const [viewingId, setViewingId] = useState<number | null>(null); // null = closed
const { toolbar, tabs, activeTab, onTabChange, activeApi, refreshAllTabs, activeSelectionCount } =
useEnterpriseGrid<EquipmentRowDto, ListEquipmentParams>({
storageKey: GRID_STORAGE_KEY,
columns,
defaultSearchField: 'name',
buildParams: ({ fixedFilterValue, fields, extraParams }) =>
makeBuildParams(fixedFilterValue, fields, extraParams?.archived as boolean | undefined),
fetchPage: listEquipmentRows,
getRowId: row => String(row.id),
sortableFields: new Set(['id', 'name', 'status', 'requestedAt']),
tabs: GRID_TABS,
countQueryFn: ({ fixedFilterValue, extraParams }) =>
listEquipment(makeBuildParams(fixedFilterValue, undefined, extraParams?.archived as boolean | undefined)({}, [], 0, 1))
.then(r => r.totalElements),
pageSize: 25,
rowActions,
onRowAction: handleRowAction,
onRowDoubleClicked: row => { setViewingId(row.id); },
selection: { canSelect: canEdit },
savedViewEntityKey: 'equipment-requests',
emptyState,
primaryAction: { label: 'New Request', hidden: !canCreate, onClick: () => { setEditingId(null); setFormOpen(true); } },
invalidateOnRefresh: [[GRID_STORAGE_KEY, 'count']],
});
return (
<Box>
{/* heading row */}
<BulkActionBar count={activeSelectionCount} onClear={() => { activeApi()?.deselectAll(); }}>
{/* bulk buttons — see 5.6 */}
</BulkActionBar>
<TabbedServerGrid<EquipmentRowDto, ListEquipmentParams>
tabs={tabs} defaultTab="all" variant="enclosed"
onTabChange={onTabChange}
toolbarRight={toolbar} {/* toolbar goes HERE and only here */}
/>
{/* drawers, mounted unconditionally — see 5.7 */}
</Box>
);
}
Non-negotiables baked into the subsystem (ag-grid.md has the why):
columns,rowActions, andemptyStatemust beuseMemo'd — they are memo dependencies inside the hook. (buildParams,fetchPage,getRowId, and the event handlers are ref-wrapped internally, so inline arrows are fine for those.)toolbargoes inTabbedServerGrid'stoolbarRightonly — all tab panels stay mounted, so anything in per-tab props renders once per tab.- Tabs:
EnterpriseGridTab[]withfixedFilterValuefor status tabs,extraParamsfor orthogonal splits (e.g.{ archived: true }),omitColumnFieldsto drop the now-redundant status column on fixed tabs,showCount: true+countQueryFnfor "(N)" tab counts.
5.6 Custom row actions and bulk actions
Row actions are data, the handler is a dispatch:
const rowActions = useMemo<GridRowAction<EquipmentRowDto>[]>(() => [
{ value: 'view', label: 'View', icon: <LuEye /> },
{ value: 'history', label: 'Activity History', icon: <LuHistory /> },
{ value: 'edit', label: 'Edit', icon: <LuPencil />, hidden: () => !canEdit, separator: true },
{ value: 'approve', label: row => row.status === 'PENDING' ? 'Approve' : 'Re-approve',
color: () => 'green.600', hidden: row => !canEdit || row.status === 'CLOSED' },
], [canEdit]);
const handleRowAction = (value: string, row: EquipmentRowDto) => {
if (value === 'view') { setViewingId(row.id); }
else if (value === 'edit') { setEditingId(row.id); setFormOpen(true); }
else if (value === 'history') { setHistoryRow(row); }
else if (value === 'approve') { void handleApprove(row); }
};
label/icon/color accept (row) => … for per-row variants;
hidden: (row) => … combines permission checks and row state; separator
draws a divider above the item.
A custom bulk action reads the selection from the grid API, fans out the existing single-row mutation, and refreshes:
const handleBulkApprove = async () => {
const selected = activeApi()?.getSelectedRows() ?? [];
if (!selected.length) return;
setBulkLoading(true);
try {
await Promise.all(selected.map(row => approveMutation.mutateAsync(row.id)));
toaster.create({ title: `${selected.length} request(s) approved`, type: 'success' });
activeApi()?.deselectAll();
refreshEverything();
} catch (error) {
toaster.create({ title: getApiErrorMessage(error, 'Some approvals failed'), type: 'error' });
} finally { setBulkLoading(false); }
};
Multi-choice bulk actions render a Chakra menu inside BulkActionBar — and
like every menu in a grid context, it needs
<Portal><MenuPositioner><MenuContent> or it renders clipped.
5.7 Drawers — form, detail, activity history
Three drawers, all mounted unconditionally at the bottom of the page, open-state driven by the ids from 5.5:
<EquipmentFormDrawer open={formOpen} requestId={editingId}
onClose={() => { setFormOpen(false); }} onSuccess={refreshEverything} />
<EquipmentDetailDrawer requestId={viewingId} onClose={() => { setViewingId(null); }} />
<ActivityHistoryDrawer
resourcePath={historyRow ? `/api/equipment-requests/${historyRow.id}` : null}
title={historyRow?.name ?? ''}
onClose={() => { setHistoryRow(null); }}
/>
Form drawer (copy
EmployeeFormDrawer.tsx):
props are exactly { open, xId: number | null, onClose, onSuccess } — null
id means create mode. Zod schema + defaults at module scope; useForm with
zodResolver; in edit mode fetch the full record with
useEquipmentRequest(open ? requestId : null) (the grid row only carries
projected columns); a reset-effect maps record → form values when it arrives.
Chakra composition is Drawer.Root → Portal → Drawer.Backdrop → Drawer.Positioner → Drawer.Content with the <form id="…" noValidate> in
Drawer.Body and the submit button in Drawer.Footer linked via
form="…". Mutations toast on success/error in the drawer, then call
onSuccess() + onClose(). Forms deep-dive:
../frontend/forms.md.
Detail drawer (copy EmployeeDetailDrawer.tsx): no open prop — openness
derives from requestId !== null. Fetch with the same hook, Spinner while
loading, then sections of label/value pairs. No footer.
Activity history is free: ActivityHistoryDrawer is generic — pass the
row's REST resource path and a title. (It requires the viewer to hold
system.admin.logs.view; it degrades to a permission notice otherwise.)
5.8 The refresh contract (get this wrong and tabs go stale)
Three layers of cache, three rules:
| What changed | Call |
|---|---|
| Row moved between tabs (create, status change, archive/restore) | refreshAllTabs() and invalidate the tab-count key |
| Row edited in place (no tab implications) | activeApi()?.purgeInfiniteCache() |
| Detail/card data (React Query) | already handled — the mutation hooks invalidate the root query key |
The idiom, verbatim from Employees:
const refreshEverything = () => {
refreshAllTabs();
void queryClient.invalidateQueries({ queryKey: [GRID_STORAGE_KEY, 'count'] });
};
5.9 Route + nav entry
Three touches (../frontend/routing.md §"How to add a page"):
// constants/routes.ts
EQUIPMENT_REQUESTS: '/equipment-requests',
// App.tsx — lazy import at the top, route inside <Route element={<AppLayout />}>
const EquipmentRequests = lazy(() => import('./pages/hr/EquipmentRequests'));
<Route element={<RequirePermission permission={PermissionConstants.HR_EQUIPMENT_VIEW} />}>
<Route path={ROUTES.EQUIPMENT_REQUESTS} element={page(EquipmentRequests)} />
</Route>
// ui/layout/nav-config.ts — inside the right NavGroup
{ icon: LuWrench, label: 'Equipment', to: ROUTES.EQUIPMENT_REQUESTS,
permission: PermissionConstants.HR_EQUIPMENT_VIEW, roleContext: [HR, SYSADMIN] },
roleContext matters: nav visibility is permission AND active-role — a
sysadmin who has switched into the Employee role shouldn't see admin nav.
Remember RequirePermission and nav gating are UX only; the backend
@PreAuthorize is the real gate.
5.10 Import/export (optional)
onExport/onExportPdf wire to downloadCsv/downloadTablePdf from
@/ui/ag-grid/csv|pdf; onImport opens the config-driven
ImportModal. The
add-import-export skill
scaffolds all of it for an existing page.
Phase 6 — Tests
- Service test —
backend/src/test/.../service/impl/EquipmentRequestServiceImplTest.java, repositories mocked, AssertJ +@DisplayNamesentences. (Controller tests are optional; service tests are the expectation.) - Tenancy classification — add the new table name to
TENANT_OWNED_TABLESinbackend/src/test/.../tenancy/TenantCoverageTest.java. This is not optional:mvn testfails on any unclassified entity, and a second check compares the classification against the real schema'stenant_idcolumns. - Tenant-isolation IT (recommended for sensitive data) — copy
RecognitionTenantIsolationIT: proves cross-tenant reads fail at both the Hibernate@TenantIdlayer and the Postgres RLS layer. Note*ITtests run under Testcontainers and are not part ofmvn test. - E2E spec —
e2e/<Domain>/equipment-requests.spec.ts, following the helper-factory pattern (e2e/helpers/): serial mode for mutation specs, a header docblock documenting grid type, every column's filter/sort capability, and the exact seed baseline + its migration. Read e2e/README.md first; run against the local stack withALTCHA_ENABLED=false, and never concurrently with another run.
The consistency checklist
Everything in this app that must be kept in sync by hand. Run down this list before opening the PR — each row is a real drift bug someone has hit:
| Must match | ↔ | Or else |
|---|---|---|
Migration role_permission grants | role-permissions.ts role arrays | Feature works via API but is invisible in the UI |
PermissionConstants.java | permissions.ts | Route/nav guard silently never matches |
Controller @RequestParam names | API-layer params interface & buildParams output | Filters silently do nothing |
Backend sortOrders switch cases | sortableFields set in the page | Sort clicks 400 (or are silently dropped) |
| Backend DTO JSON shape | TS interfaces in api/types.ts / module api file | Runtime undefineds, no compile error |
| Entity fields | Migration DDL | Backend refuses to boot (ddl-auto: validate) — this one at least fails loudly |
| New table | TenantCoverageTest.TENANT_OWNED_TABLES | mvn test fails |
Filter param suffix conventions (*Search/*Min/*From…) | both sides | Reviewers can't pattern-match; extractors misfire |
And the recurring frontend traps, one line each:
import '@/ui/ag-grid/setup'at the top of every grid page.useMemooncolumns/rowActions/emptyState.toolbar→toolbarRightonly.- Cross-tab mutation →
refreshAllTabs()+ count-key invalidation, never justactiveApi().purgeInfiniteCache(). - Any menu near the grid →
Portal > MenuPositioner > MenuContent. maxNumConditions: 1on every column filter.- Money columns →
type: 'numericValue'(not AG'srightAligned, which breaks the header icon). - Drawer form fetches the full record by id — never trust the projected grid row to have every field.
PR checklist for a new module
One PR, one coherent module, sections of the PR template filled honestly:
- Migration takes the next free V number as of merge time (re-check — parallel branches collide).
-
cd backend && mvn testgreen (includes the tenancy coverage gate). -
cd frontend && npm run type-check && npm run lint && npm run testgreen. - API verified as two personas (permitted + 403) — say so in the Testing section.
- E2E spec added and run against the local stack.
- Database Changes section filled in: V number, tenant scoping confirmed.
- Remember: merging to
maindeploys to production — ../git-workflow.md.