Bỏ qua

FE Integration Guide — 3 New Features

Audience: Frontend team
API base URL: http://localhost:3000 (gateway) — replace with your env variable
Auth: All Admin endpoints require a valid Bearer JWT with role ADMIN. Include Authorization: Bearer <token> in every request.


Table of Contents

  1. Admin System Stats & Reports
  2. Admin Dashboard Overview
  3. Admin User Management
  4. Realtime Field Availability — SSE
  5. Polling Fallback with ETag
  6. 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 null and errors.<service> is a non-null error string. Render what you have and surface a warning banner.

Integration Notes

  • growthTrend dates are YYYY-MM-DD strings — pass directly to chart libraries (e.g. Chart.js, Recharts).
  • cancellationRate is a decimal (e.g. 0.1081 = 10.81%). Multiply by 100 for display.
  • totalRevenue is 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 found
  • 403 — caller is not ADMIN
  • 400 — 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

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

  1. First request — no If-None-Match header:

``` GET /bookings/fields/uuid/schedule-grid?date=2026-05-25

Response: 200 OK ETag: "a3f1b2c4..." Body: { ... slot data ... } ```

  1. 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 ```

  1. If the schedule changed: Response: 200 OK ETag: "d9e8f7a6..." ← new ETag Body: { ... updated slot data ... }

Note: The gateway does not currently handle 304 responses on the proxy layer — the ETag and If-None-Match headers are pass-through. The browser's fetch API does not automatically handle 304; 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);