Building Production-Ready Scheduled SMS Reminders with Node.js, Fastify, and MessageBird
This guide provides a complete walkthrough for building a robust SMS appointment reminder system using Node.js, the Fastify framework, and the MessageBird API. We'll cover everything from project setup and core logic to database integration, error handling, security, and deployment.
Missed appointments cost businesses time and money. Simple SMS reminders significantly reduce no-shows by providing timely nudges. This application enables users to book appointments via a web form, automatically scheduling an SMS reminder to be sent via MessageBird a set time before the appointment.
Key Features:
- User-friendly appointment booking form.
- Phone number validation using MessageBird Lookup.
- Automated SMS reminder scheduling via MessageBird API.
- Persistent storage of appointments using PostgreSQL and Prisma.
- Robust error handling and logging.
- Basic security measures (rate limiting).
- Production-ready setup with considerations for deployment.
Technology Stack:
- Node.js: Runtime environment.
- Fastify: High-performance web framework.
- MessageBird: SMS API for sending messages and Lookup API for validation.
- PostgreSQL: Relational database for storing appointments.
- Prisma: Modern ORM for database interaction.
dayjs
: Library for date/time manipulation.dotenv
: For managing environment variables.@fastify/env
: For validating environment variables.@fastify/sensible
: Provides sensible defaults and HTTP errors for Fastify.@fastify/rate-limit
: Basic rate limiting for security.
System Architecture:
+-------------+ +-----------------+ +----------------------+ +-----------------+ +-----------------+
| User via | ----> | Fastify API | ----> | MessageBird Lookup | ----> | Fastify API | ----> | PostgreSQL DB |
| Browser | | (POST /book) | | (Validate Number) | | (Store Booking) | | (Prisma) |
+-------------+ +-----------------+ +----------------------+ +-----------------+ +-----------------+
| | |
| | |
| +--------------------------------------------------------+
| |
| v
| +----------------------+
| | MessageBird SMS API |
| | (Schedule Reminder) |
| +----------------------+
| |
| v (at scheduled time)
+-----------------------------------------+ SMS to User Phone
Prerequisites:
- Node.js (v18 or later recommended). Check installation:
node -v
. Install from nodejs.org. - npm or yarn package manager (comes with Node.js). Check installation:
npm -v
. - A MessageBird account. Sign up at messagebird.com.
- Access to a PostgreSQL database. You can run one locally using Docker or use a cloud provider.
- Basic familiarity with Node.js, APIs, and databases.
Final Outcome:
By the end of this guide, you will have a functional Fastify application capable of accepting appointment bookings, validating phone numbers, scheduling SMS reminders via MessageBird, and storing appointment data securely in a database. You'll also have a solid foundation for adding more features and deploying to production.
1. Project Setup
Let's initialize the project, install dependencies, and set up the basic structure.
-
Create Project Directory:
mkdir fastify-messagebird-reminders cd fastify-messagebird-reminders
-
Initialize Node.js Project:
npm init -y
-
Install Core Dependencies:
npm install fastify messagebird dotenv @fastify/env @fastify/sensible dayjs pino-pretty prisma @prisma/client
fastify
: The web framework.messagebird
: Official Node.js SDK for the MessageBird API.dotenv
: Loads environment variables from a.env
file.@fastify/env
: Validates required environment variables on startup.@fastify/sensible
: Adds useful utilities like HTTP errors (fastify.httpErrors
).dayjs
: Modern library for date/time manipulation.pino-pretty
: Improves readability of Fastify's logs during development.prisma
: Prisma CLI (needed for generate/migrate).@prisma/client
: The Prisma database client.
-
Install Development Dependencies:
npm install --save-dev @types/node nodemon
@types/node
: TypeScript definitions for Node.js (useful for editor intellisense).nodemon
: Automatically restarts the server during development when files change.
-
Configure
package.json
Scripts: Openpackage.json
and add/modify thescripts
section:// package.json "scripts": { "start": "node src/server.js", "dev": "nodemon --watch src --exec 'node -r pino-pretty src/server.js'", "prisma:generate": "prisma generate", "prisma:migrate:dev": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy" },
npm start
: Runs the application for production.npm run dev
: Runs the application in development mode with auto-reloading and pretty logs. (The-r pino-pretty
flag preloads the module to format logs, often simpler withnodemon
than piping).- Prisma scripts for convenience.
-
Enable ES Module Support (Important!): Since we are using
import
/export
syntax in.js
files, Node.js needs to be explicitly told to treat these files as ES Modules. Open yourpackage.json
and add the following top-level key:// package.json (add this line, usually near the top) "type": "module",
Alternatively, you could rename all
.js
files using ES Module syntax to.mjs
, but setting"type": "module"
is generally simpler for a whole project. -
Create Project Structure: Organize the code for better maintainability.
mkdir src mkdir src/config mkdir src/routes mkdir src/services mkdir src/db mkdir prisma # Prisma init will create this, but good to be aware touch src/server.js touch src/config/envSchema.js touch src/config/messagebirdClient.js touch src/routes/bookingRoutes.js touch src/db/prismaPlugin.js touch src/db/appointmentService.js touch .env touch .env.example touch .gitignore
-
Configure
.gitignore
: Prevent committing sensitive files and unnecessary folders.# .gitignore node_modules .env dist npm-debug.log* yarn-debug.log* yarn-error.log* prisma/dev.db* # Ignore SQLite dev databases if used
-
Set up Environment Variables: Define the structure for required environment variables.
-
.env.example
(for documentation and reference):# .env.example # Server Configuration PORT=3000 HOST=0.0.0.0 # MessageBird API Configuration # Get from MessageBird Dashboard > Developers > API access MESSAGEBIRD_API_KEY=YOUR_LIVE_OR_TEST_API_KEY # Default sender ID for SMS. Check MessageBird docs for country restrictions. # Can be an alphanumeric string (e.g., "MyApp") or a purchased number. MESSAGEBIRD_ORIGINATOR=BeautyBird # Default country code for phone number lookup if user doesn't provide one (ISO 3166-1 alpha-2) # Example: US, GB, NL DEFAULT_COUNTRY_CODE=US # Database Configuration (using Prisma standard format) # Example for PostgreSQL DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
-
.env
(create this file locally, do not commit it): Copy the contents of.env.example
into.env
and fill in your actual credentials and database URL.
-
-
Validate Environment Variables (
@fastify/env
): Define the schema for expected environment variables.// src/config/envSchema.js const envSchema = { type: 'object', required: [ 'PORT', 'HOST', 'MESSAGEBIRD_API_KEY', 'MESSAGEBIRD_ORIGINATOR', 'DEFAULT_COUNTRY_CODE', 'DATABASE_URL', ], properties: { PORT: { type: 'string', default: '3000' }, HOST: { type: 'string', default: '0.0.0.0' }, MESSAGEBIRD_API_KEY: { type: 'string' }, MESSAGEBIRD_ORIGINATOR: { type: 'string' }, DEFAULT_COUNTRY_CODE: { type: 'string', minLength: 2, maxLength: 2 }, DATABASE_URL: { type: 'string' }, }, }; export default envSchema;
Why this approach?
@fastify/env
ensures the application fails fast if required configuration is missing, preventing runtime errors later. -
Basic Fastify Server Setup: Initialize the Fastify instance and register essential plugins.
// src/server.js import Fastify from 'fastify'; import sensible from '@fastify/sensible'; import fastifyEnv from '@fastify/env'; import envSchema from './config/envSchema.js'; import bookingRoutes from './routes/bookingRoutes.js'; import prismaPlugin from './db/prismaPlugin.js'; import { initializeMessageBirdClient } from './config/messagebirdClient.js'; // Import initializer // Determine logger options based on environment const isProduction = process.env.NODE_ENV === 'production'; const loggerConfig = isProduction ? true // Default production logger : { transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, }; const fastify = Fastify({ logger: loggerConfig }); async function buildServer() { // Register @fastify/env to validate environment variables // This MUST be registered before accessing fastify.config await fastify.register(fastifyEnv, { dotenv: true, // Load .env file schema: envSchema, confKey: 'config', // Access variables via fastify.config }); // Initialize MessageBird Client *after* config is loaded try { initializeMessageBirdClient(fastify.config.MESSAGEBIRD_API_KEY); fastify.log.info('MessageBird client initialized successfully.'); } catch (err) { fastify.log.error('Failed to initialize MessageBird client:', err); throw err; // Prevent server start if MB client fails } // Register sensible plugin for utility decorators (e.g., fastify.httpErrors) await fastify.register(sensible); // Register Prisma plugin await fastify.register(prismaPlugin); // Register API routes await fastify.register(bookingRoutes, { prefix: '/api/v1' }); // Simple health check route fastify.get('/health', async (request, reply) => { // Optional: Add checks for DB connection, MessageBird reachability etc. try { await fastify.prisma.$queryRaw`SELECT 1`; // Basic DB check // Potential check for MessageBird balance or status? (API call) return { status: 'ok', timestamp: new Date().toISOString(), checks: { database: 'ok' } }; } catch (error) { fastify.log.error('Health check failed:', error); reply.code(503); // Service Unavailable return { status: 'error', timestamp: new Date().toISOString(), message: 'Dependency check failed', error: error.message }; } }); return fastify; } async function start() { let server; try { server = await buildServer(); await server.listen({ port: server.config.PORT, host: server.config.HOST, }); // Note: Fastify logger automatically logs listening address, no need for manual log here unless adding extra info. } catch (err) { // Use the logger if available, otherwise console.error if (server && server.log) { server.log.error({ err }, 'Error starting server'); } else { console.error('Error starting server:', err); } process.exit(1); } } start();
2. Database Setup with PostgreSQL and Prisma
We'll use Prisma to manage our database schema and interactions.
-
Install Prisma (if not already done):
# Already installed in previous steps # npm install prisma @prisma/client --save-dev
-
Initialize Prisma: This command creates a
prisma
directory with aschema.prisma
file and configures the.env
file for the database connection string.npx prisma init --datasource-provider postgresql
Ensure your
DATABASE_URL
in.env
is correctly pointing to your PostgreSQL instance. -
Define the Database Schema: Open
prisma/schema.prisma
and define the model for storing appointments.// prisma/schema.prisma generator client { provider = ""prisma-client-js"" } datasource db { provider = ""postgresql"" url = env(""DATABASE_URL"") } model Appointment { id String @id @default(cuid()) // Unique ID using CUID name String phoneNumber String // Store the normalized phone number (E.164 format) treatment String appointmentAt DateTime // Time of the actual appointment (STORED IN UTC) reminderAt DateTime // Time the reminder should be sent (STORED IN UTC) messageBirdId String? // Optional: Store the MessageBird message ID createdAt DateTime @default(now()) // Timestamp for creation updatedAt DateTime @updatedAt // Timestamp for last update @@index([appointmentAt]) // Index for querying by appointment time @@index([phoneNumber]) // Index if searching by phone number is common }
Why
DateTime
in UTC? Storing all timestamps in UTC avoids ambiguity and makes time zone conversions predictable. We'll handle calculations in UTC. WhymessageBirdId
? Storing the ID returned by MessageBird can be useful for tracking message status later if needed. -
Create the Initial Migration: This command compares your schema to the database and generates the SQL needed to create the
Appointment
table.npx prisma migrate dev --name init_appointment
Prisma will ask you to confirm; review the generated SQL and proceed. This creates the table in your database and generates the Prisma Client based on your schema.
-
Create Prisma Plugin for Fastify: Integrate the Prisma Client instance into the Fastify application context.
// src/db/prismaPlugin.js import fp from 'fastify-plugin'; import { PrismaClient } from '@prisma/client'; async function prismaPlugin(fastify, options) { // Determine log levels based on Fastify logger existence/level let prismaLogLevels = ['warn', 'error']; if (fastify.log && (fastify.log.level === 'debug' || fastify.log.level === 'trace')) { prismaLogLevels = ['query', 'info', 'warn', 'error']; } else if (fastify.log) { prismaLogLevels = ['info', 'warn', 'error']; } const prisma = new PrismaClient({ log: prismaLogLevels, }); try { await prisma.$connect(); fastify.log.info('Prisma client connected successfully.'); } catch (err) { fastify.log.error({ err }, 'Prisma client connection failed'); // Throwing here ensures the server won't start without a DB connection throw new Error('Failed to connect to database'); } // Decorate Fastify instance with prisma client fastify.decorate('prisma', prisma); // Add hook to disconnect prisma client gracefully on server close fastify.addHook('onClose', async (instance) => { await instance.prisma.$disconnect(); instance.log.info('Prisma client disconnected.'); }); } // Use fastify-plugin to ensure Prisma is available globally // without encapsulation issues for routes registered after this. export default fp(prismaPlugin);
Why
fastify-plugin
? It prevents Fastify's encapsulation, makingfastify.prisma
available in all routes registered after this plugin. -
Create Appointment Service: (Recommended for separation of concerns) Encapsulate database interaction logic.
// src/db/appointmentService.js import { Prisma } from '@prisma/client'; // Import Prisma types if needed for detailed error handling /** * Creates a new appointment record in the database. * @param {object} prisma - The Prisma client instance (from fastify.prisma). * @param {object} logger - The Fastify logger instance (from fastify.log). * @param {object} appointmentData - Data for the new appointment. * @param {string} appointmentData.name - Customer name. * @param {string} appointmentData.phoneNumber - Normalized phone number. * @param {string} appointmentData.treatment - Type of treatment/service. * @param {Date} appointmentData.appointmentAt - Appointment date/time (UTC). * @param {Date} appointmentData.reminderAt - Reminder send date/time (UTC). * @param {string} [appointmentData.messageBirdId] - Optional MessageBird message ID. * @returns {Promise<object>} The created appointment record. * @throws {Error} If saving fails (logged internally). */ export async function createAppointment(prisma, logger, appointmentData) { try { const appointment = await prisma.appointment.create({ data: appointmentData, }); logger.info({ appointmentId: appointment.id }, 'Appointment created in database'); return appointment; } catch (error) { // Log specific Prisma errors if helpful if (error instanceof Prisma.PrismaClientKnownRequestError) { logger.error({ code: error.code, meta: error.meta, ...appointmentData }, 'Prisma error creating appointment'); } else { logger.error({ error, ...appointmentData }, 'Generic error creating appointment in DB'); } // Re-throw a user-friendly or generic error for the route handler throw new Error('Failed to save appointment details to the database.'); } } // Add other functions like findAppointmentById, listAppointments etc. as needed
3. Core Reminder Logic Implementation
Now, let's implement the logic within the booking route handler.
-
Initialize MessageBird Client: Create a dedicated file to initialize the SDK using the API key from environment variables.
// src/config/messagebirdClient.js import { messagebird } from 'messagebird'; let mbClient = null; /** * Initializes the MessageBird client instance (Singleton). * This should be called once during application startup. * @param {string} apiKey - Your MessageBird API key. * @throws {Error} If API key is not provided. */ export function initializeMessageBirdClient(apiKey) { if (!apiKey) { throw new Error('MessageBird API key is required for initialization.'); } if (mbClient) { console.warn('MessageBird client already initialized.'); // Use logger if available, but this runs early return; } mbClient = messagebird(apiKey); // Logging success/failure is better handled where this function is called (e.g., server.js) // as the logger might not be configured yet when this module is loaded. } /** * Gets the initialized MessageBird client instance. * @throws {Error} If client is not initialized. * @returns {object} The MessageBird client. */ export function getMessageBirdClient() { if (!mbClient) { // This error indicates a programming mistake - initialization should happen on startup. throw new Error('MessageBird client has not been initialized. Call initializeMessageBirdClient during server setup.'); } return mbClient; }
Why a separate file/functions? This promotes modularity and allows easy initialization (in
server.js
after loading config) and retrieval wherever needed, enforcing the API key requirement and using a singleton pattern. -
Implement the Booking Route Handler: This involves validation, phone number lookup, date calculation, scheduling the SMS, and saving to the database.
// src/routes/bookingRoutes.js import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; // Required for UTC operations import isSameOrAfter from 'dayjs/plugin/isSameOrAfter.js'; // Required for comparison import { getMessageBirdClient } from '../config/messagebirdClient.js'; // Get the initialized client import { createAppointment } from '../db/appointmentService.js'; // Extend dayjs with necessary plugins dayjs.extend(utc); dayjs.extend(isSameOrAfter); // Define the validation schema for the request body using Fastify's schema format const bookingBodySchema = { type: 'object', required: ['name', 'phoneNumber', 'treatment', 'appointmentDate', 'appointmentTime'], properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, // Add reasonable max length phoneNumber: { type: 'string', minLength: 5, maxLength: 20 }, // Basic length check treatment: { type: 'string', minLength: 3, maxLength: 100 }, appointmentDate: { type: 'string', format: 'date' }, // Enforces YYYY-MM-DD appointmentTime: { type: 'string', // Regex for HH:MM (24hr), stricter than just format:'time' pattern: '^([01]?[0-9]|2[0-3]):[0-5][0-9]$' }, }, additionalProperties: false, // Disallow extra fields in the request body }; async function bookingRoutes(fastify, options) { // MessageBird client is already initialized in server.js // We just need to get it here. const messagebird = getMessageBirdClient(); const config = fastify.config; // Access validated environment variables const logger = fastify.log; // Use Fastify's logger instance const prisma = fastify.prisma; // Access Prisma client fastify.post('/book', { schema: { body: bookingBodySchema } }, async (request, reply) => { // `request.body` is already validated against the schema by Fastify const { name, phoneNumber, treatment, appointmentDate, appointmentTime } = request.body; // --- 1. Validate Appointment Date/Time Logic --- const reminderBufferHours = 3; // Send reminder 3 hours before const minimumLeadTimeMinutes = 5; // Allow booking up to 3h 5m before appt. // Combine date and time strings. const dateTimeString = `${appointmentDate} ${appointmentTime}`; // Parse the combined string. **CRITICAL: Time Zone Assumption** // `dayjs(dateTimeString)` parses using the SERVER's local timezone. // This assumes the user intended the time relative to the server's zone. // For global apps, **COLLECT USER TIMEZONE** and use `dayjs-timezone`. const appointmentDateTimeLocal = dayjs(dateTimeString); if (!appointmentDateTimeLocal.isValid()) { // This check is somewhat redundant due to schema validation, but good practice. throw fastify.httpErrors.badRequest('Invalid date or time format provided. Use YYYY-MM-DD and HH:MM.'); } // Convert the presumed-local time to UTC for all calculations and storage. const appointmentDateTimeUTC = appointmentDateTimeLocal.utc(); // Calculate the earliest allowed booking time in UTC. // Current time (UTC) + buffer + lead time const earliestPossibleBookingUTC = dayjs.utc().add(reminderBufferHours, 'hour').add(minimumLeadTimeMinutes, 'minute'); // Check if the requested appointment time is too soon. if (appointmentDateTimeUTC.isBefore(earliestPossibleBookingUTC)) { logger.warn({ requestedUTC: appointmentDateTimeUTC.toISOString(), earliestUTC: earliestPossibleBookingUTC.toISOString() }, 'Booking attempt too soon'); throw fastify.httpErrors.badRequest( `Appointment must be scheduled at least ${reminderBufferHours} hours and ${minimumLeadTimeMinutes} minutes from now.` ); } // Calculate reminder time in UTC by subtracting the buffer from the appointment time. const reminderDateTimeUTC = appointmentDateTimeUTC.subtract(reminderBufferHours, 'hour'); // --- 2. Validate Phone Number via MessageBird Lookup --- let normalizedPhoneNumber; try { logger.info({ phoneNumber, country: config.DEFAULT_COUNTRY_CODE }, 'Attempting MessageBird Lookup'); // Note on Promise Wrapper: This is needed if the SDK version uses callbacks. // Newer versions might support async/await directly (e.g., `await messagebird.lookup.read(...)`). // Check the documentation for the specific `messagebird` package version you installed. const lookupResponse = await new Promise((resolve, reject) => { messagebird.lookup.read(phoneNumber, config.DEFAULT_COUNTRY_CODE, (err, response) => { if (err) { // Handle specific MessageBird errors for better user feedback if (err.errors && err.errors[0].code === 21) { // Code 21: number invalid / not found logger.warn({ err, phoneNumber }, 'MessageBird Lookup: Invalid number format'); // Use return reject() to exit the promise execution path return reject(fastify.httpErrors.badRequest(`The phone number '${phoneNumber}' appears invalid. Please check the format and include a country code if necessary.`)); } // Log other potentially transient or configuration errors logger.error({ err, phoneNumber }, 'MessageBird Lookup API error'); return reject(fastify.httpErrors.internalServerError('Could not validate the phone number at this time. Please try again later.')); } // Successfully got a response resolve(response); }); }); // Check if the number type is suitable for SMS (usually 'mobile') if (lookupResponse.type !== 'mobile') { // You might allow 'fixed-line-sms' depending on MessageBird capabilities and your needs. logger.warn({ number: phoneNumber, type: lookupResponse.type }, 'Non-mobile number provided for SMS reminder'); throw fastify.httpErrors.badRequest( `Phone number type '${lookupResponse.type}' is not suitable for SMS reminders. Please provide a valid mobile number.` ); } // Use the E.164 formatted number returned by MessageBird for consistency normalizedPhoneNumber = lookupResponse.phoneNumber; logger.info({ original: phoneNumber, normalized: normalizedPhoneNumber, type: lookupResponse.type }, 'Phone number validated via MessageBird Lookup'); } catch (error) { // If it's already a Fastify HttpError, rethrow it directly. if (error.statusCode) throw error; // Otherwise, log the unexpected error and throw a generic internal server error. logger.error({ error: error.message, stack: error.stack, phoneNumber }, 'Unexpected error during phone number lookup'); throw fastify.httpErrors.internalServerError('An unexpected error occurred during phone number validation.'); } // --- 3. Schedule SMS Reminder via MessageBird --- let messageBirdResponse; try { // Construct the reminder message body // Use local format for user-facing time, but UTC for scheduling const reminderBody = `Hi ${name}, this is a reminder for your ${treatment} appointment at ${appointmentDateTimeLocal.format('h:mm A')} today with ${config.MESSAGEBIRD_ORIGINATOR}. See you soon!`; // MessageBird requires scheduled time in ISO 8601 format (UTC is recommended). // dayjs().utc().format() produces the correct format like "2023-10-27T10:30:00Z". const scheduledTimestampISO = reminderDateTimeUTC.toISOString(); logger.info({ recipient: normalizedPhoneNumber, scheduledAt: scheduledTimestampISO }, 'Attempting to schedule MessageBird SMS'); // Note on Promise Wrapper: Similar to Lookup, check SDK docs for direct async/await support. messageBirdResponse = await new Promise((resolve, reject) => { messagebird.messages.create({ originator: config.MESSAGEBIRD_ORIGINATOR, recipients: [normalizedPhoneNumber], scheduledDatetime: scheduledTimestampISO, body: reminderBody, // reference: `appt_${appointmentId_placeholder}` // Optional: Add your own reference ID if needed // type: 'sms' // Default is sms, but can be explicit }, (err, response) => { if (err) { // Log detailed MessageBird API errors logger.error({ err, recipient: normalizedPhoneNumber, originator: config.MESSAGEBIRD_ORIGINATOR }, 'MessageBird Messages API error during scheduling'); // Check for common specific errors if possible (e.g., originator issues) if (err.statusCode === 422) { // Unprocessable Entity - often originator issues return reject(fastify.httpErrors.badRequest('Failed to schedule SMS. There might be an issue with the sender ID configuration for this destination.')); } return reject(fastify.httpErrors.internalServerError('Failed to schedule the reminder SMS due to an API error. Please try booking again later.')); } // Successfully scheduled resolve(response); }); }); // Log success with the returned MessageBird message ID logger.info({ msgId: messageBirdResponse?.id, scheduledAt: scheduledTimestampISO, recipient: normalizedPhoneNumber, status: messageBirdResponse?.recipients?.items[0]?.status }, 'SMS reminder scheduled successfully via MessageBird'); } catch (error) { // If it's already an HttpError, rethrow it. if (error.statusCode) throw error; // Log unexpected errors during scheduling. logger.error({ error: error.message, stack: error.stack, recipient: normalizedPhoneNumber }, 'Unexpected error scheduling reminder SMS'); throw fastify.httpErrors.internalServerError('An unexpected error occurred while scheduling the reminder SMS.'); } // --- 4. Store Appointment in Database --- let savedAppointment; try { const appointmentData = { name, phoneNumber: normalizedPhoneNumber, // Store the validated E.164 number treatment, appointmentAt: appointmentDateTimeUTC.toDate(), // Convert dayjs UTC object to JS Date for Prisma reminderAt: reminderDateTimeUTC.toDate(), // Convert dayjs UTC object to JS Date for Prisma messageBirdId: messageBirdResponse?.id, // Store the MessageBird message ID (if available) }; // Use the dedicated service function to create the appointment savedAppointment = await createAppointment(prisma, logger, appointmentData); // Success log is now inside createAppointment service } catch (dbError) { logger.error({ error: dbError.message, stack: dbError.stack }, 'Database error saving appointment after scheduling SMS'); // CRITICAL: If DB save fails after SMS is scheduled, we have an inconsistency. // Potential Compensating Action: Try to cancel the scheduled MessageBird message. // This adds complexity (requires storing message ID earlier, handling cancel errors). // For simplicity here, we log the error and return a server error. // In a real-world scenario, consider a background job queue or more robust error handling. throw fastify.httpErrors.internalServerError('Appointment booked and reminder scheduled, but failed to save details. Please contact support.'); } // --- 5. Send Success Response --- reply.code(201); // 201 Created return { message: 'Appointment booked and reminder scheduled successfully.', appointmentId: savedAppointment.id, appointmentAt: savedAppointment.appointmentAt, // Return UTC time reminderScheduledAt: savedAppointment.reminderAt, // Return UTC time messageBirdStatus: messageBirdResponse?.recipients?.items[0]?.status ?? 'unknown', // Provide status if available }; }); } export default bookingRoutes;