FE Integration Guide — 3 New Features
Audience: Frontend team
API base URL:http://localhost:3000(gateway) — replace with your env variable
Auth: AllAdminendpoints require a valid Bearer JWT with roleADMIN. IncludeAuthorization: Bearer <token>in every request.
Table of Contents
- Admin System Stats & Reports
- Admin Dashboard Overview
- Admin User Management
- Realtime Field Availability — SSE
- Polling Fallback with ETag
- Error Handling Reference
1. Admin System Stats & Reports
Endpoint
GET /admin/stats
Auth: ADMIN role required
Cache: Downstream services cache internally (~10 min). If you need fresher data, pass an explicit date range.
Query Parameters
| Param | Type | Required | Default | Example |
|---|---|---|---|---|
startDate |
string | No | 30 days ago | 2026-04-01 |
endDate |
string | No | today | 2026-05-25 |
Response Shape
{
"users": {
"total": 245,
"byRole": {
"CUSTOMER": 204,
"FIELD_OWNER": 40,
"ADMIN": 1
},
"byStatus": {
"ACTIVE": 245
},
"newInPeriod": 4,
"growthTrend": [{ "date": "2026-05-09", "count": 4 }]
},
"fields": {
"total": 207,
"byStatus": {
"ACTIVE": 207,
"PENDING_APPROVAL": 0,
"INACTIVE": 0,
"MAINTENANCE": 0
},
"newInPeriod": 0,
"growthTrend": []
},
"bookings": {
"period": { "startDate": "2026-04-25", "endDate": "2026-05-25" },
"totalBookings": 37,
"totalRevenue": 494000,
"byStatus": {
"cancelled": 4,
"completed": 2,
"expired": 31
},
"cancellationRate": 0.1081,
"bookingTrend": [{ "date": "2026-05-15", "count": 15, "revenue": 294000 }],
"totalConfirmedAllTime": 1611
},
"errors": {
"users": null,
"fields": null,
"bookings": null
}
}
Partial failure: If one downstream service is down, its key is
nullanderrors.<service>is a non-null error string. Render what you have and surface a warning banner.
Integration Notes
growthTrenddates areYYYY-MM-DDstrings — pass directly to chart libraries (e.g. Chart.js, Recharts).cancellationRateis a decimal (e.g.0.1081= 10.81%). Multiply by 100 for display.totalRevenueis in Vietnamese Đồng (VND) — no decimal places needed. Format with.toLocaleString('vi-VN').
Example (TypeScript / Fetch)
const response = await fetch('/admin/stats?startDate=2026-04-01&endDate=2026-05-25', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await response.json();
// Revenue chart data
const revenueChart = data.bookings?.bookingTrend.map((d) => ({
x: d.date,
y: d.revenue,
}));
// User growth chart
const userGrowth = data.users?.growthTrend.map((d) => ({
x: d.date,
y: d.count,
}));
2. Admin Dashboard Overview
Endpoint
GET /admin/dashboard
Auth: ADMIN role required
Refresh strategy: Poll every 2 minutes (the data changes infrequently).
Response Shape
{
"pendingFieldApprovals": 3,
"today": {
"bookings": 5,
"revenue": 1050000,
"cancellationRate": 0.0
},
"users": {
"total": 245,
"byRole": { "CUSTOMER": 204, "FIELD_OWNER": 40, "ADMIN": 1 },
"byStatus": { "ACTIVE": 245 }
},
"errors": {
"pendingFields": null,
"todayBookings": null,
"userStats": null
}
}
Dashboard Card Mapping
| Card | Field path |
|---|---|
| Pending approvals badge | pendingFieldApprovals |
| Today bookings | today.bookings |
| Today revenue | today.revenue (VND) |
| Today cancellation rate | today.cancellationRate * 100 % |
| Total users | users.total |
| Users by role donut | users.byRole |
Example (React hook)
function useAdminDashboard() {
const [data, setData] = useState(null);
useEffect(() => {
const load = async () => {
const res = await fetch('/admin/dashboard', {
headers: { Authorization: `Bearer ${token}` },
});
setData(await res.json());
};
load();
const interval = setInterval(load, 2 * 60 * 1000); // poll every 2 min
return () => clearInterval(interval);
}, []);
return data;
}
3. Admin User Management
3.1 List Users (with pagination + filters)
GET /admin/users
Query Parameters:
| Param | Type | Required | Example |
|---|---|---|---|
page |
number | No | 1 |
limit |
number | No | 20 |
role |
ADMIN \| FIELD_OWNER \| CUSTOMER |
No | CUSTOMER |
status |
ACTIVE \| INACTIVE \| BANNED |
No | BANNED |
search |
string | No | nguyen |
Response:
{
"data": [
{
"id": "uuid",
"email": "user@example.com",
"name": "Nguyen Van A",
"phone": "0901234567",
"role": "CUSTOMER",
"status": "ACTIVE",
"createdAt": "2026-01-01T00:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 245,
"totalPages": 13
}
}
3.2 Get Single User
GET /admin/users/:id
Returns the same user object as above.
3.3 Update User Status
PATCH /admin/users/:id/status
Request Body:
{
"status": "BANNED",
"reason": "Repeated policy violations"
}
| Field | Type | Required | Values |
|---|---|---|---|
status |
string (UserStatus enum) | Yes | ACTIVE, INACTIVE, BANNED |
reason |
string | No | Free text, stored in metadata |
Response: Updated user object (HTTP 200).
Error cases:
404— user not found403— caller is not ADMIN400— invalid status value
Example (React)
async function banUser(userId: string, reason: string, token: string) {
const res = await fetch(`/admin/users/${userId}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: 'BANNED', reason }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
4. Realtime Field Availability — SSE
Use the SSE endpoint to get instant updates when bookings are created, confirmed, or cancelled for a specific field on a given date. No polling needed — the server pushes changes.
Endpoints
Per-date stream (recommended for booking calendar view)
GET /bookings/fields/:fieldId/availability/stream?date=YYYY-MM-DD&maxAge=300
All-dates stream (for field owner's live activity feed)
GET /bookings/fields/:fieldId/availability/stream/all?maxAge=300
Auth: Public (no token required) — same as the schedule-grid endpoint.
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
date |
string | Yes* | — | Date to watch (YYYY-MM-DD). *Not needed for /all |
maxAge |
number | No | 300 |
Connection lifetime in seconds (5 min default) |
Event Types
| Event name | Description |
|---|---|
availability_update |
A booking state changed for this field/date |
heartbeat |
Server keepalive (every 30 s) — safe to ignore |
availability_update Payload
{
"fieldId": "uuid-of-field",
"date": "2026-05-25",
"eventType": "BOOKING_CREATED",
"bookingId": "uuid-of-booking",
"ts": 1748140800000
}
| Field | Description |
|---|---|
fieldId |
Which field was affected |
date |
Date (YYYY-MM-DD) of the affected booking |
eventType |
BOOKING_CREATED, BOOKING_CONFIRMED, BOOKING_CANCELLED, etc. |
bookingId |
The booking that triggered the update |
ts |
Unix timestamp (ms) of the event |
Integration (Vanilla JS / React)
function connectAvailabilityStream(
fieldId: string,
date: string, // "YYYY-MM-DD"
onUpdate: () => void, // callback to re-fetch schedule-grid
) {
const url = `/bookings/fields/${fieldId}/availability/stream?date=${date}&maxAge=300`;
const es = new EventSource(url);
es.addEventListener('availability_update', (e) => {
const payload = JSON.parse(e.data);
console.log('Availability changed:', payload);
onUpdate(); // re-fetch schedule-grid to show latest slots
});
// Ignore heartbeat
es.addEventListener('heartbeat', () => {});
es.onerror = (err) => {
console.warn('SSE error — will retry automatically', err);
// EventSource reconnects automatically; no extra handling needed
};
return es; // caller must call es.close() on unmount
}
React hook example
function useAvailabilityStream(fieldId: string, date: string) {
const [lastEvent, setLastEvent] = useState<null | object>(null);
useEffect(() => {
if (!fieldId || !date) return;
const es = connectAvailabilityStream(fieldId, date, () => {
setLastEvent({ ts: Date.now() }); // trigger re-fetch in parent
});
return () => es.close(); // cleanup on unmount or date change
}, [fieldId, date]);
return lastEvent; // parent watches this to decide when to re-fetch
}
Reconnection strategy
EventSource reconnects automatically on network errors. The stream auto-closes after maxAge seconds — the browser will see readyState === 2 (CLOSED) and you can re-open it or let the user refresh.
For SPAs where the user stays on the booking page for a long time, re-open the stream when it closes:
function openStreamWithAutoReconnect(fieldId: string, date: string, onUpdate: () => void) {
let es: EventSource;
const open = () => {
es = connectAvailabilityStream(fieldId, date, onUpdate);
es.addEventListener('error', () => {
// If CLOSED (not just network hiccup), reopen after 5 s
if (es.readyState === EventSource.CLOSED) {
setTimeout(open, 5000);
}
});
};
open();
return () => es?.close();
}
5. Polling Fallback with ETag
If SSE is not feasible (e.g., limited proxy support), you can poll GET /bookings/fields/:fieldId/schedule-grid efficiently using HTTP ETag / If-None-Match — the server returns 304 Not Modified when nothing has changed, saving bandwidth.
How it works
- First request — no
If-None-Matchheader:
``` GET /bookings/fields/uuid/schedule-grid?date=2026-05-25
Response: 200 OK ETag: "a3f1b2c4..." Body: { ... slot data ... } ```
- Subsequent polls — send back the ETag:
``` GET /bookings/fields/uuid/schedule-grid?date=2026-05-25 If-None-Match: "a3f1b2c4..."
Response: 304 Not Modified ← no body, nothing changed ```
- If the schedule changed:
Response: 200 OK ETag: "d9e8f7a6..." ← new ETag Body: { ... updated slot data ... }
Note: The gateway does not currently handle
304responses on the proxy layer — theETagandIf-None-Matchheaders are pass-through. The browser'sfetchAPI does not automatically handle304; handle it manually as shown below.
Polling implementation
let currentETag: string | null = null;
let currentSlots: SlotData[] = [];
async function pollScheduleGrid(fieldId: string, date: string): Promise<SlotData[] | null> {
const headers: Record<string, string> = {};
if (currentETag) headers['If-None-Match'] = currentETag;
const res = await fetch(`/bookings/fields/${fieldId}/schedule-grid?date=${date}`, { headers });
if (res.status === 304) {
return null; // nothing changed — use currentSlots
}
currentETag = res.headers.get('ETag');
const data = await res.json();
currentSlots = data.slots;
return currentSlots;
}
// Poll every 10 seconds
setInterval(() => pollScheduleGrid(fieldId, date), 10_000);
Recommendation
| Scenario | Approach |
|---|---|
| Modern browser, no strict proxy | SSE (Section 4) — preferred |
| Strict proxy or load balancer | ETag polling (Section 5) every 10 s |
| Hybrid | SSE primary + ETag poll as fallback |
6. Error Handling Reference
HTTP Status Codes
| Status | Meaning | What to show |
|---|---|---|
200 |
Success | Normal render |
304 |
Not Modified (ETag match) | Keep current data silently |
400 |
Bad request (invalid params/body) | Show validation error from message |
401 |
Unauthorized — missing or invalid JWT | Redirect to login |
403 |
Forbidden — valid JWT but wrong role | Show "Access denied" screen |
404 |
Resource not found | Show "Not found" message |
409 |
Conflict (e.g. field already approved/rejected) | Show conflict message from message |
503 |
Gateway — downstream service unavailable | Show maintenance banner |
Error Response Shape
{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}
For GET /admin/stats and GET /admin/dashboard, partial failures return HTTP 200 with the failed service's data as null and the error string in the errors object. Always check errors before rendering charts.
const stats = await fetchAdminStats();
if (stats.errors.bookings) {
showWarning('Booking stats temporarily unavailable');
}
// still render users and fields stats if available
renderUserStats(stats.users);
renderFieldStats(stats.fields);