Skip to main content

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​

EndpointDomainAuth
POST /otpOTPNone
POST /request_bookingBooking requestx-client-api-key
POST /rental-car/request-bookingRental carNone
POST /rental-car/bookingRental carNone
POST /trip/requestTripx-client-api-key
POST /trip/trip_travellerTripx-client-api-key
POST /expense/reviewExpenseNone
POST /expense/rejectExpenseNone
POST /firebase-admin/devices/registerDevice tokensAuthorization
DELETE /firebase-admin/device-token/:idDevice tokensAuthorization
GET /firebase-admin/notificationsIn-app notificationsAuthorization
GET /firebase-admin/notifications/unread-countIn-app notificationsAuthorization
POST /firebase-admin/notifications/readIn-app notificationsAuthorization
DELETE /firebase-admin/notifications/:idIn-app notificationsAuthorization
POST /graphql/v1Email (GraphQL)None
GET /health · /health/live · /health/readyHealthNone

Authentication​

SchemeHeaderApplies toOn failure
Signaturex-client-api-key: <token>POST /request_booking, POST /trip/request, POST /trip/trip_traveller401 Invalid Token
User tokenAuthorization: <user-token>all /firebase-admin/* routes401 Unauthorized
None—/otp, /rental-car/*, /expense/*, /graphql/v1, /health/*—
note

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

Request body
{
"channel": "ZNS",
"otp": "123456",
"country_calling_code": "84",
"national_phone_number": "909123456",
"secret": "optional_secret"
}
FieldTypeRequiredDescription
channel"ZNS" or "SMS"YesDelivery channel.
otpstringYesOTP code to send.
country_calling_codestringYesCountry calling code, e.g. "84" for Vietnam.
national_phone_numberstringYesLocal phone number without the country prefix.
secretstringNoIf present, verifies the OTP through an external OAuth2 endpoint.

Behavior​

  • channel == "SMS" emits an otp_sms event to RabbitMQ.
  • channel == "ZNS" without secret emits an otp_zms event to RabbitMQ.
  • channel == "ZNS" with secret verifies 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

Request body
{
"action": "APPROVED",
"requestor_id": 42,
"booking_request_id": "REQ-001",
"user_ids": [42, 43],
"product_type": "flight",
"remaining_days": 3
}
FieldTypeRequiredDescription
actionenumYesAPPROVED, REJECTED, CANCELED, PENDING, or REMIND.
requestor_idnumberYesUser ID of the person who made the request.
booking_request_idstringYesBooking request ID.
user_idsnumber[]YesList of user IDs to notify.
product_typeenumNoflight or hotel.
remaining_daysnumberNoDays 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

Request body
{
"action": "REQUESTED",
"booking_request_id": "BRQ-001",
"product_id": "P-100",
"user_ids": [42],
"requestor_id": 42,
"product_type": "transportation"
}
FieldTypeRequiredDescription
actionenumYesREQUESTED, COMPLETED, or REJECTED. Only REQUESTED/COMPLETED are processed.
booking_request_idstringYesRental-car booking request ID.
product_idstringYesProduct ID.
user_idsnumber[]YesUsers to notify.
requestor_idnumberNoUser ID of the requestor.
product_typeenumNoflight, hotel, or transportation.

POST /rental-car/booking​

Handles rental-car booking events (vendor/user updates, confirmation, acceptance) and dispatches ZNS + email notifications.

Auth: None

Request body
{
"action": "CONFIRMED",
"booking_id": "BK-001",
"product_id": "P-100",
"user_ids": [42],
"trip_id": "T-100",
"requestor_id": 42,
"product_type": "transportation"
}
FieldTypeRequiredDescription
actionenumYesUPDATED_BY_VENDOR, UPDATED_BY_USER, CONFIRMED, or ACCEPTED.
booking_idstringYesRental-car booking ID.
product_idstringYesProduct ID.
user_idsnumber[]YesUsers to notify.
trip_idstringNoTrip ID.
requestor_idnumberNoUser ID of the requestor.
product_typeenumNoflight, 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

Request body
{
"action": "CREATE",
"requestor_id": 42,
"booking_request_id": "REQ-001",
"user_ids": [10, 11],
"actor_id": 7,
"reason": "Budget exceeded",
"remaining_days": 2
}
FieldTypeRequiredDescription
actionenumYesCREATE, APPROVE, REJECT, CANCEL, or REMIND.
requestor_idnumberYesUser ID of the person who submitted the proposal (trip owner).
booking_request_idstringYesTrip approval-request ID.
user_idsnumber[]YesUsers to notify (see per-action audience below).
actor_idnumberNoUser who performed the action — the approver for APPROVE/REJECT, the canceller for CANCEL.
reasonstringNoRejection note shown in the REJECT email (falls back to the request's note).
remaining_daysnumberNoDays remaining before the trip begins (used by REMIND).

Actions​

ActionAudience (user_ids)Email template (event)Push keyZNS template (event)
CREATEApproverstrip-request-created (email_trip_request_created)TRIP.REQUEST.CREATEDTrip.RequestCreated / 595732 (zns_trip_request_created)
APPROVETrip owner + travellerstrip-request-approved (email_trip_request_approved)TRIP.REQUEST.APPROVEDTrip.RequestApproved / 595832 (zns_trip_request_approved)
REJECTTrip owner + travellerstrip-request-rejected (email_trip_request_rejected)TRIP.REQUEST.REJECTEDTrip.RequestRejected / 595852 (zns_trip_request_rejected)
CANCELTravellers + approvers + ownertrip-request-cancelled (email_trip_request_cancelled)TRIP.REQUEST.CANCELLEDTrip.RequestCancelled / 595876 (zns_trip_request_cancelled)
REMINDApproverstrip-proposal (email_trip_proposal)TRIP.PROPOSAL.REMINDER_DAYS / REMINDER_TOMORROWTrip.Proposal / 560623 (zns_trip_proposal)
  • CREATE renders 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.
  • APPROVE includes a Make booking link; REJECT shows the approver's note (reason); CANCEL is 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

Request body
{
"action": "CREATE",
"requestor_id": 42,
"trip_id": "T-100",
"user_ids": [10, 11]
}
FieldTypeRequiredDescription
actionenumYesCREATE (traveller invited to a trip).
requestor_idnumberYesUser ID of the trip owner who added the travellers.
trip_idstringYesTrip ID; used to fetch trip details and build the deep link.
user_idsnumber[]YesInvited 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
{
"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

Request body
{
"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"
}
FieldTypeRequiredDescription
user_idsnumber[]YesApprover user IDs (one email + push per ID).
trip_idstringYesTrip reference; used in the deep link and dedup key.
trip_namestringYesTrip name shown in the notification.
traveler_namestringYesTraveler who submitted the expenses.
traveler_idnumberNoTraveler's user ID (added to the FCM data payload).
start_date / end_datenumberNoTrip start/end as Unix timestamps in seconds (e.g. 1464096368). Rendered as a localized date range with formatPeriod.
total_amountnumberNoTotal submitted amount. Formatted with formatCurrency (VND, e.g. đ 1.250) before display.
expense_countnumberNoNumber of expense requests submitted.
review_urlstringNoDeep 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

Request body
{
"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"
}
FieldTypeRequiredDescription
user_idsnumber[]YesTraveler user IDs (one email + push per ID).
trip_idstringYesTrip reference.
trip_namestringYesTrip name shown in the notification.
traveler_namestringNoFalls back to the resolved profile name when omitted.
start_date / end_datenumberNoTrip start/end as Unix timestamps in seconds (e.g. 1464096368). Rendered as a localized date range with formatPeriod.
rejected_amountnumberNoTotal rejected amount. Formatted with formatCurrency (VND, e.g. đ 320) before display.
rejected_expense_countnumberNoNumber of rejected expenses.
rejection_reasonstringNoReason shown in the email's rejection box.
update_urlstringNoDeep 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
{
"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.

Example — activate_account
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)​

OperationDomainRabbitMQ eventMJML templateSubject
activate_accountAccountemail_activate_accountactivate-accountActivate Your Ultravel Account
reset_passwordAccountemail_reset_passwordreset-passwordReset Your Ultravel Password
otpAccountemail_otpotpYour Verification Code
corporate_staffAccountemail_corporate_staff_activationcorporate-staff-activationWelcome to <company> – Ultravel Business Travel
check_outBookingemail_check_outcheck-outThank You for Your Stay

Operations not handled yet (envelope only — no email sent)​

OperationDomainSubject returned
membership_emailAccountCongratulations! You've Reached <rank> Status
approve_corporateAccountYour Corporate Account Has Been Approved
user_bookingBookingYour Booking is Confirmed!
guest_bookingBookingYour Booking Details
property_bookingBookingNew Booking Confirmation
ta_supplier_bookingBookingNew Booking Confirmation
ta_cancelBookingYour Booking Has Been Cancelled
ultravel_bookingBookingNew Booking Confirmation
amend_bookingBookingYour Booking Has Been Updated
internal_transfer_senderBookingYour Booking Has Been Transferred
internal_transfer_recipientBookingA Booking Has Been Transferred to You
p2p_buyerBookingYour Booking is Confirmed!
p2p_sellerBookingYour Booking Has Been Sold
remindBookingUpcoming Check-In Reminder
user_flight_bookingFlightYour Flight is Booked!
ultravel_flight_bookingFlightNew Flight Booking Confirmation
booking_requestFlightNew / Approved / Rejected / Sold-out Booking Request (by status)
flight_booking_requestFlightNew / Approved / Rejected / Sold-out Booking Request (by status)
user_payment_confirmationPaymentPayment Confirmation
ultravel_payment_confirmationPaymentPayment Confirmation
top_upPaymentWallet Top-Up Successful
payoutPaymentPayout Request Notification
corporate_statementPaymentYour Monthly Corporate Statement
tripTripNew / Approved / Rejected Trip Request (by status)
trip_partnerTripTrip Invitation
deposit_crypto_tokenCryptoToken Deposit Confirmation
deposit_nftCryptoNFT Deposit Confirmation
withdraw_nftCryptoNFT Withdrawal Confirmation
note

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.

Request body
{
"user_id": 123,
"device_token": "fGhI5jKlM6n...",
"platform": "android",
"is_active": true,
"locale": "en"
}
FieldTypeRequiredDescription
user_idnumberYesPlatform user ID (must match the token).
device_tokenstringYesFCM device token.
platformenumYesandroid, ios, or web.
is_activebooleanNoDefaults to true.
localestringNoDevice 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 paramTypeRequiredDescription
sinceISO date-timeNoOnly notifications updated after this time.
categoryenumNoTrip, Flight, Hotel, Ride, or Expense.
pagenumberNoPage number (min 1, default 1).
limitnumberNoPage 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.

Request body
{
"notification_ids": [1, 2, 3]
}

DELETE /firebase-admin/notifications/:id​

Delete a single notification owned by the authenticated user.


Health​

EndpointPurpose
GET /healthSimple { status: "ok", timestamp } check.
GET /health/liveKubernetes liveness probe (heap check).
GET /health/readyKubernetes readiness probe (outbound connectivity).