Notification Center Service
The notification-center service (port 3000) is the HTTP intake gateway for notifications. It
validates and persists requests, then publishes events to RabbitMQ for notification-worker to
deliver across channels (FCM, Email, SMS, ZNS). This page documents the endpoints currently
implemented in apps/notification-center/src.
Interactive Swagger UI: http://localhost:3000/api/docs.
Endpoints
| Endpoint | Domain | Auth |
|---|---|---|
POST /otp | OTP | None |
POST /request_booking | Booking request | x-client-api-key |
POST /rental-car/request-booking | Rental car | None |
POST /rental-car/booking | Rental car | None |
POST /trip/request | Trip | x-client-api-key |
POST /trip/trip_traveller | Trip | x-client-api-key |
POST /expense/review | Expense | None |
POST /expense/reject | Expense | None |
POST /firebase-admin/devices/register | Device tokens | Authorization |
DELETE /firebase-admin/device-token/:id | Device tokens | Authorization |
GET /firebase-admin/notifications | In-app notifications | Authorization |
GET /firebase-admin/notifications/unread-count | In-app notifications | Authorization |
POST /firebase-admin/notifications/read | In-app notifications | Authorization |
DELETE /firebase-admin/notifications/:id | In-app notifications | Authorization |
POST /graphql/v1 | Email (GraphQL) | None |
GET /health · /health/live · /health/ready | Health | None |
Authentication
| Scheme | Header | Applies to | On failure |
|---|---|---|---|
| Signature | x-client-api-key: <token> | POST /request_booking, POST /trip/request, POST /trip/trip_traveller | 401 Invalid Token |
| User token | Authorization: <user-token> | all /firebase-admin/* routes | 401 Unauthorized |
| None | — | /otp, /rental-car/*, /expense/*, /graphql/v1, /health/* | — |
The signature token is verified through a Kubernetes token review; the /firebase-admin/* token
is resolved against the GraphQL profile service.
OTP
POST /otp
Sends an OTP through ZNS (Zalo) or SMS. When secret is provided, the OTP is verified against an
external OAuth2 endpoint before sending.
Auth: None · HTTP Status: 200
{
"channel": "ZNS",
"otp": "123456",
"country_calling_code": "84",
"national_phone_number": "909123456",
"secret": "optional_secret"
}
| Field | Type | Required | Description |
|---|---|---|---|
channel | "ZNS" or "SMS" | Yes | Delivery channel. |
otp | string | Yes | OTP code to send. |
country_calling_code | string | Yes | Country calling code, e.g. "84" for Vietnam. |
national_phone_number | string | Yes | Local phone number without the country prefix. |
secret | string | No | If present, verifies the OTP through an external OAuth2 endpoint. |
Behavior
channel == "SMS"emits anotp_smsevent to RabbitMQ.channel == "ZNS"withoutsecretemits anotp_zmsevent to RabbitMQ.channel == "ZNS"withsecretverifies the OTP via OAuth2, then runs the new/callback flow (no queue emit).
Response
200 OK - null body
Booking request
POST /request_booking
Handles corporate booking-request lifecycle events (approval, rejection, cancellation, pending, remind) and notifies the involved users.
Auth: x-client-api-key header
{
"action": "APPROVED",
"requestor_id": 42,
"booking_request_id": "REQ-001",
"user_ids": [42, 43],
"product_type": "flight",
"remaining_days": 3
}
| Field | Type | Required | Description |
|---|---|---|---|
action | enum | Yes | APPROVED, REJECTED, CANCELED, PENDING, or REMIND. |
requestor_id | number | Yes | User ID of the person who made the request. |
booking_request_id | string | Yes | Booking request ID. |
user_ids | number[] | Yes | List of user IDs to notify. |
product_type | enum | No | flight or hotel. |
remaining_days | number | No | Days remaining before the trip (used by REMIND). |
Rental car
POST /rental-car/request-booking
Handles rental-car booking-request events (requested / completed) and dispatches ZNS + email notifications.
Auth: None
{
"action": "REQUESTED",
"booking_request_id": "BRQ-001",
"product_id": "P-100",
"user_ids": [42],
"requestor_id": 42,
"product_type": "transportation"
}
| Field | Type | Required | Description |
|---|---|---|---|
action | enum | Yes | REQUESTED, COMPLETED, or REJECTED. Only REQUESTED/COMPLETED are processed. |
booking_request_id | string | Yes | Rental-car booking request ID. |
product_id | string | Yes | Product ID. |
user_ids | number[] | Yes | Users to notify. |
requestor_id | number | No | User ID of the requestor. |
product_type | enum | No | flight, hotel, or transportation. |
POST /rental-car/booking
Handles rental-car booking events (vendor/user updates, confirmation, acceptance) and dispatches ZNS + email notifications.
Auth: None
{
"action": "CONFIRMED",
"booking_id": "BK-001",
"product_id": "P-100",
"user_ids": [42],
"trip_id": "T-100",
"requestor_id": 42,
"product_type": "transportation"
}
| Field | Type | Required | Description |
|---|---|---|---|
action | enum | Yes | UPDATED_BY_VENDOR, UPDATED_BY_USER, CONFIRMED, or ACCEPTED. |
booking_id | string | Yes | Rental-car booking ID. |
product_id | string | Yes | Product ID. |
user_ids | number[] | Yes | Users to notify. |
trip_id | string | No | Trip ID. |
requestor_id | number | No | User ID of the requestor. |
product_type | enum | No | flight, hotel, or transportation. |
Trip
Each endpoint resolves the recipients' email/locale/name from user_ids, then sends an email
(MJML template, per-recipient locale) and a mobile push (FCM) per recipient.
POST /trip/request
Handles trip-proposal lifecycle events. The action field selects the notification sent to the
users in user_ids.
Auth: x-client-api-key header
{
"action": "CREATE",
"requestor_id": 42,
"booking_request_id": "REQ-001",
"user_ids": [10, 11],
"actor_id": 7,
"reason": "Budget exceeded",
"remaining_days": 2
}
| Field | Type | Required | Description |
|---|---|---|---|
action | enum | Yes | CREATE, APPROVE, REJECT, CANCEL, or REMIND. |
requestor_id | number | Yes | User ID of the person who submitted the proposal (trip owner). |
booking_request_id | string | Yes | Trip approval-request ID. |
user_ids | number[] | Yes | Users to notify (see per-action audience below). |
actor_id | number | No | User who performed the action — the approver for APPROVE/REJECT, the canceller for CANCEL. |
reason | string | No | Rejection note shown in the REJECT email (falls back to the request's note). |
remaining_days | number | No | Days remaining before the trip begins (used by REMIND). |
Actions
| Action | Audience (user_ids) | Email template (event) | Push key | ZNS template (event) |
|---|---|---|---|---|
CREATE | Approvers | trip-request-created (email_trip_request_created) | TRIP.REQUEST.CREATED | Trip.RequestCreated / 595732 (zns_trip_request_created) |
APPROVE | Trip owner + travellers | trip-request-approved (email_trip_request_approved) | TRIP.REQUEST.APPROVED | Trip.RequestApproved / 595832 (zns_trip_request_approved) |
REJECT | Trip owner + travellers | trip-request-rejected (email_trip_request_rejected) | TRIP.REQUEST.REJECTED | Trip.RequestRejected / 595852 (zns_trip_request_rejected) |
CANCEL | Travellers + approvers + owner | trip-request-cancelled (email_trip_request_cancelled) | TRIP.REQUEST.CANCELLED | Trip.RequestCancelled / 595876 (zns_trip_request_cancelled) |
REMIND | Approvers | trip-proposal (email_trip_proposal) | TRIP.PROPOSAL.REMINDER_DAYS / REMINDER_TOMORROW | Trip.Proposal / 560623 (zns_trip_proposal) |
CREATErenders the full proposal summary (destinations, travelers, period, flights/hotels, transportation, approver levels) with a View proposal details link to the approval screen, and resolves the requestor's department name from the corporate service when available.APPROVEincludes a Make booking link;REJECTshows the approver's note (reason);CANCELis informational (no CTA).
POST /trip/trip_traveller
Notifies travellers that they have been added to a trip ("You have been added to a trip" email
with a Plan trip link, plus push TRIP.TRAVELLER.ADDED).
Auth: x-client-api-key header
{
"action": "CREATE",
"requestor_id": 42,
"trip_id": "T-100",
"user_ids": [10, 11]
}
| Field | Type | Required | Description |
|---|---|---|---|
action | enum | Yes | CREATE (traveller invited to a trip). |
requestor_id | number | Yes | User ID of the trip owner who added the travellers. |
trip_id | string | Yes | Trip ID; used to fetch trip details and build the deep link. |
user_ids | number[] | Yes | Invited traveller user IDs (one email + push per ID). |
Delivers email trip-traveller-added (event email_trip_traveller_added) and push
TRIP.TRAVELLER.ADDED.
Response (trip)
Both endpoints always return 201 Created with a JSON envelope — check the success flag:
{
"success": true,
"message": "Trip request [create] processed successfully",
"booking_request_id": "REQ-001",
"notified_count": 2
}
Expense
Each endpoint resolves the recipients' email/locale/name from user_ids, then sends an email
(MJML template) and a mobile push (FCM) per recipient. See the worker delivery details below
each table.
POST /expense/review
Notify approvers that a traveler has submitted expenses for approval.
Auth: None
{
"user_ids": [42, 43],
"trip_id": "T-100",
"trip_name": "Q3 Sales Tour",
"traveler_name": "John Doe",
"traveler_id": 7,
"start_date": 1464096368,
"end_date": 1464614768,
"total_amount": 1250,
"expense_count": 4,
"review_url": "https://app.xperise.com/trip/expense?id=T-100&action=review"
}
| Field | Type | Required | Description |
|---|---|---|---|
user_ids | number[] | Yes | Approver user IDs (one email + push per ID). |
trip_id | string | Yes | Trip reference; used in the deep link and dedup key. |
trip_name | string | Yes | Trip name shown in the notification. |
traveler_name | string | Yes | Traveler who submitted the expenses. |
traveler_id | number | No | Traveler's user ID (added to the FCM data payload). |
start_date / end_date | number | No | Trip start/end as Unix timestamps in seconds (e.g. 1464096368). Rendered as a localized date range with formatPeriod. |
total_amount | number | No | Total submitted amount. Formatted with formatCurrency (VND, e.g. đ 1.250) before display. |
expense_count | number | No | Number of expense requests submitted. |
review_url | string | No | Deep link to the approval screen (falls back to FRONT_END_URL). |
Delivers email expense-review-required (event email_expense_review_required) and push
EXPENSE.REVIEW_REQUIRED.
POST /expense/reject
Notify the traveler that expenses were rejected and require revision.
Auth: None
{
"user_ids": [7],
"trip_id": "T-100",
"trip_name": "Q3 Sales Tour",
"traveler_name": "John Doe",
"rejected_amount": 320,
"rejected_expense_count": 2,
"rejection_reason": "Missing receipts for taxi fares.",
"update_url": "https://app.xperise.com/trip/expense?id=T-100&action=revise"
}
| Field | Type | Required | Description |
|---|---|---|---|
user_ids | number[] | Yes | Traveler user IDs (one email + push per ID). |
trip_id | string | Yes | Trip reference. |
trip_name | string | Yes | Trip name shown in the notification. |
traveler_name | string | No | Falls back to the resolved profile name when omitted. |
start_date / end_date | number | No | Trip start/end as Unix timestamps in seconds (e.g. 1464096368). Rendered as a localized date range with formatPeriod. |
rejected_amount | number | No | Total rejected amount. Formatted with formatCurrency (VND, e.g. đ 320) before display. |
rejected_expense_count | number | No | Number of rejected expenses. |
rejection_reason | string | No | Reason shown in the email's rejection box. |
update_url | string | No | Deep link to the update/resubmit screen (falls back to FRONT_END_URL). |
Delivers email expense-revision-required (event email_expense_revision_required) and push
EXPENSE.REVISION_REQUIRED.
Response (expense)
Both endpoints always return 201 Created with a JSON envelope — check the success flag:
{
"success": true,
"message": "Expense review notifications dispatched successfully",
"trip_id": "T-100",
"recipients": 2
}
Email service (GraphQL)
POST /graphql/v1
Code-first GraphQL API for transactional emails. Every operation is a query returning an
Email envelope (from, to, subject, lang). Operations marked Handled also emit a
SendEmailDto to the EMAIL_NOTIFICATION_queue, where notification-worker renders the MJML
template and sends the email (AWS SES). The remaining operations only build and return the
envelope — no email is sent yet; wiring them up is pending their worker templates.
Auth: None · Introspection enabled, playground disabled.
query {
activate_account(
code: "123456"
confirmation_url: "https://app.xperise.com/activate?code=123456"
email: "user@example.com"
lang: "en"
user_name: "John Doe"
) {
from
to
subject
lang
}
}
Handled operations (email is sent)
| Operation | Domain | RabbitMQ event | MJML template | Subject |
|---|---|---|---|---|
activate_account | Account | email_activate_account | activate-account | Activate Your Ultravel Account |
reset_password | Account | email_reset_password | reset-password | Reset Your Ultravel Password |
otp | Account | email_otp | otp | Your Verification Code |
corporate_staff | Account | email_corporate_staff_activation | corporate-staff-activation | Welcome to <company> – Ultravel Business Travel |
check_out | Booking | email_check_out | check-out | Thank You for Your Stay |
Operations not handled yet (envelope only — no email sent)
| Operation | Domain | Subject returned |
|---|---|---|
membership_email | Account | Congratulations! You've Reached <rank> Status |
approve_corporate | Account | Your Corporate Account Has Been Approved |
user_booking | Booking | Your Booking is Confirmed! |
guest_booking | Booking | Your Booking Details |
property_booking | Booking | New Booking Confirmation |
ta_supplier_booking | Booking | New Booking Confirmation |
ta_cancel | Booking | Your Booking Has Been Cancelled |
ultravel_booking | Booking | New Booking Confirmation |
amend_booking | Booking | Your Booking Has Been Updated |
internal_transfer_sender | Booking | Your Booking Has Been Transferred |
internal_transfer_recipient | Booking | A Booking Has Been Transferred to You |
p2p_buyer | Booking | Your Booking is Confirmed! |
p2p_seller | Booking | Your Booking Has Been Sold |
remind | Booking | Upcoming Check-In Reminder |
user_flight_booking | Flight | Your Flight is Booked! |
ultravel_flight_booking | Flight | New Flight Booking Confirmation |
booking_request | Flight | New / Approved / Rejected / Sold-out Booking Request (by status) |
flight_booking_request | Flight | New / Approved / Rejected / Sold-out Booking Request (by status) |
user_payment_confirmation | Payment | Payment Confirmation |
ultravel_payment_confirmation | Payment | Payment Confirmation |
top_up | Payment | Wallet Top-Up Successful |
payout | Payment | Payout Request Notification |
corporate_statement | Payment | Your Monthly Corporate Statement |
trip | Trip | New / Approved / Rejected Trip Request (by status) |
trip_partner | Trip | Trip Invitation |
deposit_crypto_token | Crypto | Token Deposit Confirmation |
deposit_nft | Crypto | NFT Deposit Confirmation |
withdraw_nft | Crypto | NFT Withdrawal Confirmation |
hotel_booking_pdf and flight_booking_pdf are scaffolds that return an empty string — PDF
generation is not implemented yet.
Device tokens & in-app notifications
All /firebase-admin/* routes require the Authorization header (user token). The user is
resolved from the GraphQL profile service; for devices/register, the body user_id must match
the token's user.
POST /firebase-admin/devices/register
Register (or update) a device's FCM token.
{
"user_id": 123,
"device_token": "fGhI5jKlM6n...",
"platform": "android",
"is_active": true,
"locale": "en"
}
| Field | Type | Required | Description |
|---|---|---|---|
user_id | number | Yes | Platform user ID (must match the token). |
device_token | string | Yes | FCM device token. |
platform | enum | Yes | android, ios, or web. |
is_active | boolean | No | Defaults to true. |
locale | string | No | Device locale (ISO 639-1). Defaults to en. |
DELETE /firebase-admin/device-token/:id
Deactivate a device token by its numeric ID. Returns 204 No Content.
GET /firebase-admin/notifications
List the authenticated user's notifications (paginated).
| Query param | Type | Required | Description |
|---|---|---|---|
since | ISO date-time | No | Only notifications updated after this time. |
category | enum | No | Trip, Flight, Hotel, Ride, or Expense. |
page | number | No | Page number (min 1, default 1). |
limit | number | No | Page size (1–100, default 20). |
GET /firebase-admin/notifications/unread-count
Returns { success, message, data: { count } }.
POST /firebase-admin/notifications/read
Mark notifications as read.
{
"notification_ids": [1, 2, 3]
}
DELETE /firebase-admin/notifications/:id
Delete a single notification owned by the authenticated user.
Health
| Endpoint | Purpose |
|---|---|
GET /health | Simple { status: "ok", timestamp } check. |
GET /health/live | Kubernetes liveness probe (heap check). |
GET /health/ready | Kubernetes readiness probe (outbound connectivity). |