Skip to main content

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) and add-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):

LayerCopy fromWhy this one
Everything frontendpages/hr/Employees.tsxFullest useEnterpriseGrid adoption: tabs, saved views, archive, bulk, import/export
Backend moduleJobRequisition* (entity → controller)Newest complete CRUD+grid module, includes field projection
Simple backend listCrmLead*The simpler service style, when you don't need projection
Tenant-scoped migrationV108__recognition_awards.sqlMost 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 shapeGET /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 it V<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_request
    USING (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 GRANT needed — V101 set default privileges for app_runtime. If you create a view, you must add security_invoker = true yourself 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_id
    FROM role r
    CROSS JOIN permission p
    WHERE 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 a tenant_id IS NULL blueprint — 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) — maps contains / equals / notEqual / startsWith / endsWith / notContains / blank / notBlank.
  • numberFilter(field, min, max, filterType) and dateTimeFilter(field, from, to, filterType).
  • Association columns join with root.join("assoc", JoinType.LEFT); computed names use cb.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:

StyleWhenCopy from
(a) Standard — compose specs, repository.findAll(spec, pageable).map(this::toDto)All sortable columns are direct entity attributesCrmLeadServiceImpl
(b) Criteria + Tuple projection — raw EntityManager queriesThe 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.javaHR_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.sh for 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: validate guarantees 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:

FlagEffect
searchable: truecolumn appears in the toolbar quick-search "Search In" options
defaultVisible: falsea "detail" column — hidden at first open, listed in View ▾, and not fetched until switched on
alwaysVisible: truepinned out of the View menu and always in the fields= projection
projectable: falseclient-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, and emptyState must be useMemo'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.)
  • toolbar goes in TabbedServerGrid's toolbarRight only — all tab panels stay mounted, so anything in per-tab props renders once per tab.
  • Tabs: EnterpriseGridTab[] with fixedFilterValue for status tabs, extraParams for orthogonal splits (e.g. { archived: true }), omitColumnFields to drop the now-redundant status column on fixed tabs, showCount: true + countQueryFn for "(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 changedCall
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 testbackend/src/test/.../service/impl/EquipmentRequestServiceImplTest.java, repositories mocked, AssertJ + @DisplayName sentences. (Controller tests are optional; service tests are the expectation.)
  • Tenancy classification — add the new table name to TENANT_OWNED_TABLES in backend/src/test/.../tenancy/TenantCoverageTest.java. This is not optional: mvn test fails on any unclassified entity, and a second check compares the classification against the real schema's tenant_id columns.
  • Tenant-isolation IT (recommended for sensitive data) — copy RecognitionTenantIsolationIT: proves cross-tenant reads fail at both the Hibernate @TenantId layer and the Postgres RLS layer. Note *IT tests run under Testcontainers and are not part of mvn test.
  • E2E spece2e/<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 with ALTCHA_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 matchOr else
Migration role_permission grantsrole-permissions.ts role arraysFeature works via API but is invisible in the UI
PermissionConstants.javapermissions.tsRoute/nav guard silently never matches
Controller @RequestParam namesAPI-layer params interface & buildParams outputFilters silently do nothing
Backend sortOrders switch casessortableFields set in the pageSort clicks 400 (or are silently dropped)
Backend DTO JSON shapeTS interfaces in api/types.ts / module api fileRuntime undefineds, no compile error
Entity fieldsMigration DDLBackend refuses to boot (ddl-auto: validate) — this one at least fails loudly
New tableTenantCoverageTest.TENANT_OWNED_TABLESmvn test fails
Filter param suffix conventions (*Search/*Min/*From…)both sidesReviewers 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.
  • useMemo on columns / rowActions / emptyState.
  • toolbartoolbarRight only.
  • Cross-tab mutation → refreshAllTabs() + count-key invalidation, never just activeApi().purgeInfiniteCache().
  • Any menu near the grid → Portal > MenuPositioner > MenuContent.
  • maxNumConditions: 1 on every column filter.
  • Money columns → type: 'numericValue' (not AG's rightAligned, 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 test green (includes the tenancy coverage gate).
  • cd frontend && npm run type-check && npm run lint && npm run test green.
  • 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 main deploys to production — ../git-workflow.md.