Skip to main content

Admin & Messaging API

RBAC administration, audit, per-user UI state, demo data, and the three realtime features (announcements, notifications, chat) plus the STOMP WebSocket they ride on. Conventions: conventions.md. RBAC concepts and data model: ../security/authentication.md.

Tenants — /api/admin/tenants

TenantAdminController.java

The platform operator's registry of companies. Gated on the Platform permission category, which no tenant role holds — a tenant's own System Administrator, granted every permission in their own world, is granted nothing here.

MethodPathPermissionPurpose
GET/api/admin/tenantsplatform.tenants.viewList (paged, with the usual grid filter params)
GET/api/admin/tenants/{id}platform.tenants.viewOne tenant
POST/api/admin/tenantsplatform.tenants.manageProvision a company and its first administrator
PUT/api/admin/tenants/{id}platform.tenants.manageUpdate name / timezone / admin email (the slug is fixed)
PATCH/api/admin/tenants/{id}/suspendplatform.tenants.manageLock the company out
PATCH/api/admin/tenants/{id}/reactivateplatform.tenants.manageLet it back in
POST/api/admin/tenants/{id}/repairplatform.tenants.manageFinish a provisioning run that did not complete

POST returns 201 with the tenant, the administrator's username, and their temporary password. Only a hash is stored, so this response is the only readable copy the API ever produces. The same password is also emailed to adminEmail (backend/email.md), but on a best-effort basis — a failed send does not fail the 201, which is why the operator still gets a copy to pass on. That account can do nothing until the password is changed (see auth). A duplicate slug, username or email is a 409. Suspending tenant 1 is a 409: it owns every pre-multi-tenancy row, so suspending it would lock the deployment out of itself.

Provisioning seeds the company's roles (cloned from the blueprints), payroll settings, company profile, CRM settings and picklists, leave types, deduction types, de minimis types, a starting department and positions, and two years of Philippine holidays. The tenant is created suspended and activated only once that finishes, so a failure leaves it visibly incomplete rather than half-working; repair re-runs the same steps, each of which skips what already exists.

Users — /api/users

UserController.java

MethodPathPermissionPurpose
GET/api/users · /{id}system.admin.users.viewList / one user (scoped to the caller's tenant)
POST/api/userssystem.admin.users.createCreate (links a user to an employee + roles)
PUT/api/users/{id}system.admin.users.editUpdate
DELETE/api/users/{id}system.admin.users.deleteDelete

Roles — /api/roles and Permissions — /api/permissions

RoleController.java, PermissionController.java

MethodPathPermissionPurpose
GET/api/roles · /{id}system.admin.roles.viewList / one role
POST/api/rolessystem.admin.roles.createCreate
PUT/api/roles/{id}system.admin.roles.editUpdate
DELETE/api/roles/{id}system.admin.roles.deleteDelete
PUT/api/roles/{id}/permissionssystem.admin.role.permissions.manageReplace a role's permission set
GET/api/permissions · /{id}system.admin.permissions.viewList / one permission
POST/api/permissionssystem.admin.permissions.createCreate
PUT/api/permissions/{id}system.admin.permissions.editUpdate
DELETE/api/permissions/{id}system.admin.permissions.deleteDelete

Permission names are what @PreAuthorize checks — creating a permission row here does nothing until a controller references it (conventions.md).

Audit logs — /api/audit-logs

AuditLogController.java: GET (paged/filtered), permission system.admin.logs.view. Rows are written by the backend for security-relevant actions (logins, refresh-reuse detection, payroll cancellations…). New entries are also broadcast live to WebSocket topic /topic/audit-logs, which the admin UI tails.

Saved views — /api/saved-views

SavedViewController.java — named grid configurations for the enterprise grid (../frontend/ag-grid.md). Personal data: every endpoint is scoped to the caller's own userId, so authentication alone suffices — no permission gate.

MethodPathPurpose
GET/api/saved-views?entityKey=Views the caller saved for one grid
POST/api/saved-viewsSave a view (name + serialized grid state)
DELETE/api/saved-views/{id}Delete own view

User nav preference — /api/user-nav-preference

UserNavPreferenceController.java — per-user sidebar personalization; authenticated only, same reasoning as saved views. GET (204 when unset), PUT (save), DELETE (reset to default).

Demo data — /api/system-admin/demo-data

DemoDataController.java — all three gated by system.admin.demo.data.manage; drives the seeder described in ../architecture.md §6:

MethodPathPurpose
GET/api/system-admin/demo-data/statusRow counts / whether demo data exists
POST/api/system-admin/demo-data/generateGenerate transactional demo data (timesheets, leave, payroll runs…)
POST/api/system-admin/demo-data/resetWipe and regenerate

Announcements — /api/announcements

AnnouncementController.java

MethodPathPermissionPurpose
GET/api/announcements · /{id}any authenticated userPaged list / one announcement (everyone reads them)
POST · PUT /{id} · DELETE /{id}/api/announcements...hr.announcements.manage or system.admin.announcements.manageAuthor / edit / remove
PATCH/api/announcements/{id}/archive · /restoresame twoSoft-delete / restore

Notifications — /api/notifications

NotificationController.java — all three require employee.notifications.view:

MethodPathPurpose
GET/api/notifications/meOwn notifications
PATCH/api/notifications/{id}/readMark one read
PATCH/api/notifications/me/read-allMark all read

New notifications are also pushed over the WebSocket to the per-user queue /user/queue/notifications — the bell icon updates without polling.

Chat — /api/conversations + the STOMP WebSocket

ConversationController.java — all endpoints require employee.messages.view:

MethodPathPurpose
GET/api/conversations/meOwn conversation list
GET/api/conversations/{id}One conversation (participants only)
GET/api/conversations/{id}/messagesMessage history (paged)
GET/api/conversations/{id}/messages/cursor?before=&limit=Cursor-paged history (infinite scroll)
POST/api/conversations/messagesSend: {"recipientEmployeeId": 10007, "messageContent": "..."}

Sending is HTTP; delivery is WebSocket — the recipient (and the sender's other tabs) receive the message on their user queue.

The WebSocket contract

Verified in WebSocketConfig.java and WebSocketAuthChannelInterceptor.java; overview in ../backend/scheduling-and-websockets.md.

  • Handshake: /ws (SockJS). In dev-compose that's proxied at http://localhost:5173/ws and direct at http://localhost:8081/ws.
  • Authentication: the STOMP CONNECT frame must carry Authorization: Bearer <accessToken> as a native header — the interceptor validates the JWT and rejects the connection otherwise. (The HTTP handshake itself is not where auth happens.)
  • Prefixes: subscribe on /topic/** (broadcast) and /queue/** (per-user, via the /user prefix); send application frames to /app/**.
DestinationDirectionWhat flows
/user/queue/messagessubscribeNew ChatMessageDtos for you (as recipient or echo as sender)
/user/queue/conversationssubscribeConversation-level updates (unread badges)
/user/queue/notificationssubscribeNew notification DTOs
/topic/audit-logssubscribeLive audit-log rows (admin UI)
/app/conversation/{id}/typingsendTyping indicator (TypingController.java)
/topic/conversation/{id}/typingsubscribeTyping indicator fan-out to the conversation

Minimal client (matches the frontend's usage)

The real implementations are frontend/src/hooks/useMessagingWebSocket.ts, useNotificationsWebSocket.ts, and useAuditLogWebSocket.ts (same directory):

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const client = new Client({
webSocketFactory: () => new SockJS(`${API_BASE_URL}/ws`),
connectHeaders: { Authorization: `Bearer ${accessToken}` },
reconnectDelay: 5000,
onConnect: () => {
client.subscribe('/user/queue/messages', (frame) => {
const msg = JSON.parse(frame.body);
// append to the conversation…
});
client.subscribe('/user/queue/notifications', (frame) => { /* bell badge */ });
},
});
client.activate();

// publishing (e.g. typing indicator):
client.publish({
destination: `/app/conversation/${conversationId}/typing`,
body: JSON.stringify({ typing: true }),
});

Note the token in connectHeaders is captured at connect time; after a token refresh the next reconnect uses whatever the hook reads from the store.