This guide provides a step-by-step walkthrough for building an application to schedule and send SMS appointment reminders using Node.js, Express, and the MessageBird API. We'll cover crucial aspects like database integration, error handling, security, and deployment considerations important for a production environment.
The final application will expose an API endpoint to accept appointment details, validate them, store them in a database, and schedule a corresponding SMS reminder via MessageBird to be sent out at a specified time before the appointment.
Project Overview and Goals
What We're Building: A Node.js backend service using the Express framework that:
- Provides an API endpoint to create new appointments.
- Validates incoming appointment data, including user phone numbers (using MessageBird Lookup).
- Persists appointment details in a database (PostgreSQL with Prisma).
- Schedules an SMS reminder using the MessageBird API to be sent a configurable duration (e.g., 3 hours) before the appointment time.
- Includes robust error handling, logging, and basic security measures.
Problem Solved: This system addresses the common business problem of customer no-shows for appointments. Sending timely SMS reminders significantly reduces missed appointments, saving businesses time and revenue.
Technologies Used:
- Node.js: JavaScript runtime environment for building the backend server.
- Express: Minimalist web framework for Node.js, used to create the API.
- MessageBird: Communications Platform as a Service (CPaaS) used for:
- SMS API: To schedule and send the reminder messages.
- Lookup API: To validate phone number format and type.
- PostgreSQL: Robust open-source relational database for storing appointment data.
- Prisma: Next-generation Node.js and TypeScript ORM for database access and migrations.
dotenv
: Module to load environment variables from a.env
file.moment-timezone
: Library for parsing, validating, manipulating, and displaying dates and times, crucially handling time zones. (Note: While powerful,moment-timezone
is in maintenance mode. For new projects, consider alternatives likedate-fns-tz
or using the built-inIntl
object for formatting, though timezone math can be more complex withIntl
.)express-validator
: Middleware for validating incoming request data.express-rate-limit
: Middleware for basic rate limiting to protect against abuse.winston
: A versatile logging library.
System Architecture:
+-------------+ +-----------------+ +----------------------+ +-----------------+
| User/Client | ----> | Node.js/Express | ----> | MessageBird Lookup API | ----> | Validation Result|
| (API Call) | | (API Endpoint) | +----------------------+ +-----------------+
+-------------+ +-----------------+
| ^
| | (Store Appointment)
v |
+----------+ | +-----------------------+ +-----------------+
| Prisma | | | MessageBird SMS API | ----> | User's Phone |
+----------+ | | (Schedule Reminder) | | (Receives SMS) |
| | +-----------------------+ +-----------------+
v |
+-------------+
| PostgreSQL |
| Database |
+-------------+
Note: This ASCII diagram illustrates the flow. For a published article, replacing this with an SVG or PNG image would provide better visual clarity.
Prerequisites:
- Node.js and npm (or yarn) installed. Download Node.js
- Access to a PostgreSQL database instance (local or cloud-based).
- A MessageBird account. Sign up for MessageBird
- Basic understanding of JavaScript, Node.js, REST APIs, and databases.
- A code editor (e.g., VS Code).
- Terminal/Command Line access.
Expected Outcome: A functional backend service ready for integration with a frontend or other services, capable of reliably scheduling SMS reminders for appointments.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Steps:
-
Create Project Directory:
mkdir node-messagebird-reminders cd node-messagebird-reminders
-
Initialize Node.js Project:
npm init -y
This creates a
package.json
file. -
Install Dependencies:
npm install express messagebird dotenv moment-timezone express-validator express-rate-limit winston helmet @prisma/client
(Added
helmet
and@prisma/client
) -
Install Development Dependencies (Prisma):
npm install prisma --save-dev
-
Initialize Prisma:
npx prisma init --datasource-provider postgresql
This creates:
- A
prisma
directory with aschema.prisma
file (for defining your database schema). - A
.env
file (for environment variables, including the database connection string).
- A
-
Configure
.env
: Open the newly created.env
file and update theDATABASE_URL
with your actual PostgreSQL connection string. Add other necessary variables. Use quotes only if the value contains spaces or special characters that might be misinterpreted.# .env Example # Database Connection DATABASE_URL=""postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public"" # MessageBird Credentials MESSAGEBIRD_API_KEY=YOUR_MESSAGEBIRD_API_KEY MESSAGEBIRD_ORIGINATOR=BeautyBird # Or your registered number/alphanumeric sender ID MESSAGEBIRD_LOOKUP_COUNTRY_CODE=US # Optional: Default country code for Lookup # Application Settings REMINDER_HOURS_BEFORE=3 # How many hours before the appointment to send the reminder SERVER_PORT=8080 LOG_LEVEL=info NODE_ENV=development # Set to 'production' in production environment
DATABASE_URL
: ReplaceUSER
,PASSWORD
,HOST
,PORT
, andDATABASE
with your PostgreSQL credentials. Quoted because it contains special characters.MESSAGEBIRD_API_KEY
: Your live API key from the MessageBird Dashboard.MESSAGEBIRD_ORIGINATOR
: The sender ID for your SMS messages (can be an alphanumeric string like ""BeautyBird"" or a purchased virtual number like+12025550134
). Check MessageBird's documentation for country-specific restrictions.MESSAGEBIRD_LOOKUP_COUNTRY_CODE
: Optional. Helps the Lookup API parse local phone number formats if the user doesn't provide a country code. Use the relevant ISO 3166-1 alpha-2 country code (e.g.,US
,GB
,NL
).REMINDER_HOURS_BEFORE
: Defines when the reminder is sent relative to the appointment.SERVER_PORT
: Port the Express server will listen on.LOG_LEVEL
: Controls the verbosity of logs (error
,warn
,info
,http
,verbose
,debug
,silly
).NODE_ENV
: Important for controlling behavior (e.g., error details).
-
Create Project Structure: Organize your code for better maintainability.
mkdir src mkdir src/config src/routes src/controllers src/services src/utils src/middleware touch src/server.js src/app.js touch src/config/logger.js src/config/prisma.js touch src/routes/appointmentRoutes.js touch src/controllers/appointmentController.js touch src/services/messagebirdService.js src/services/appointmentService.js touch src/utils/errorHandler.js touch src/middleware/validationMiddleware.js
src/config
: Configuration files (logging, Prisma client).src/routes
: Express route definitions.src/controllers
: Request handling logic.src/services
: Business logic and third-party integrations.src/utils
: Utility functions (error handling, date helpers).src/middleware
: Custom Express middleware (validation).src/app.js
: Express application setup (middleware, routes).src/server.js
: Entry point, initializes services, starts the HTTP server.
-
.gitignore
: Create a.gitignore
file in the root directory to avoid committing sensitive information and unnecessary files:# .gitignore node_modules .env dist npm-debug.log* yarn-debug.log* yarn-error.log* prisma/migrations/*.sql # Optional: if you don't want to commit generated SQL
This structured setup provides a solid foundation for building the application.
2. Creating a Database Schema and Data Layer
We'll use Prisma to define our database schema and interact with PostgreSQL.
Steps:
-
Define 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 for the appointment customerName String // Name of the customer customerNumber String // Customer's phone number (E.164 format preferred after lookup) treatment String // Description of the service/appointment type appointmentAt DateTime // The date and time of the appointment (in UTC) reminderSentAt DateTime? // Timestamp when the reminder SMS was successfully scheduled/sent messagebirdId String? // Optional: Store the MessageBird message ID for tracking createdAt DateTime @default(now()) // Timestamp when the record was created updatedAt DateTime @updatedAt // Timestamp when the record was last updated @@index([appointmentAt]) // Index for efficient querying by appointment time }
- We define fields for customer details, appointment specifics, and timing.
appointmentAt
should ideally be stored in UTC to avoid time zone ambiguity in the database. We'll handle conversions in the application layer.reminderSentAt
andmessagebirdId
help track the reminder status.- Indexes improve query performance.
-
Apply Schema to Database (Migration): Run the Prisma migration command to create the
Appointment
table in your PostgreSQL database.npx prisma migrate dev --name init_appointment
This command does three things:
- Creates a new SQL migration file in
prisma/migrations
. - Applies the migration to the database, creating the
Appointment
table. - Generates the Prisma Client based on your schema (
@prisma/client
).
- Creates a new SQL migration file in
-
Create Prisma Client Instance: Set up a reusable Prisma client instance.
// src/config/prisma.js const { PrismaClient } = require('@prisma/client'); // Recommended: Add logging based on environment const prismaOptions = {}; if (process.env.NODE_ENV !== 'production') { prismaOptions.log = ['query', 'info', 'warn', 'error']; } else { prismaOptions.log = ['warn', 'error']; // Log less in production } const prisma = new PrismaClient(prismaOptions); module.exports = prisma;
Now we have a database table and a way to interact with it using the Prisma Client.
3. Implementing Core Functionality & Services
Let's build the services responsible for handling appointment logic and interacting with MessageBird.
Steps:
-
MessageBird Service: Create a service to encapsulate MessageBird API interactions.
// src/services/messagebirdService.js const MessageBird = require('messagebird'); const moment = require('moment-timezone'); const logger = require('../config/logger'); let messagebird; // Will be initialized on app startup const initializeMessageBird = () => { if (!process.env.MESSAGEBIRD_API_KEY) { logger.error(""MESSAGEBIRD_API_KEY environment variable not set.""); throw new Error(""MessageBird API key is required for the application to function.""); } try { messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY); logger.info(""MessageBird SDK initialized successfully.""); } catch (error) { logger.error(""Failed to initialize MessageBird SDK:"", { error }); throw new Error(""MessageBird SDK initialization failed.""); // Propagate error to stop startup if needed } }; const isInitialized = () => { if (!messagebird) { logger.error(""MessageBird Service accessed before initialization.""); return false; } return true; }; /** * Validates a phone number using MessageBird Lookup. * @param {string} phoneNumber - The phone number to validate. * @param {string} [countryCode=process.env.MESSAGEBIRD_LOOKUP_COUNTRY_CODE] - Optional ISO country code. * @returns {Promise<object>} - The lookup result containing number details (e.g., phoneNumber, type). * @throws {Error} If SDK not initialized, lookup fails or number is invalid/not mobile. */ const validatePhoneNumber = (phoneNumber, countryCode = process.env.MESSAGEBIRD_LOOKUP_COUNTRY_CODE) => { return new Promise((resolve, reject) => { if (!isInitialized()) { return reject(new Error(""MessageBird Service not ready."")); } messagebird.lookup.read(phoneNumber, countryCode, (err, response) => { if (err) { // Specific error code 21 means unknown format if (err.errors && err.errors[0].code === 21) { logger.warn(`Lookup failed for ${phoneNumber}: Invalid format.`); return reject(new Error(""Invalid phone number format."")); } // Other API errors logger.error(`MessageBird Lookup API error: ${err.message}`, { code: err.statusCode, errors: err.errors }); return reject(new Error(""Failed to validate phone number via MessageBird Lookup."")); } if (response.type !== 'mobile') { logger.warn(`Lookup successful for ${phoneNumber}, but type is not mobile: ${response.type}`); return reject(new Error(""Please provide a mobile phone number for SMS reminders."")); } logger.info(`Phone number ${phoneNumber} validated successfully. E.164 format: ${response.phoneNumber}`); // Return the full response, includes normalized number: response.phoneNumber resolve(response); }); }); }; /** * Schedules an SMS reminder using MessageBird. * @param {string} recipientNumber - The validated phone number (E.164 format preferred). * @param {string} messageBody - The content of the SMS. * @param {Date|string} scheduledDateTime - The Date object or ISO string for when to send the message. * @returns {Promise<object>} - The MessageBird API response for the scheduled message. * @throws {Error} If SDK not initialized or scheduling fails. */ const scheduleSmsReminder = (recipientNumber, messageBody, scheduledDateTime) => { return new Promise((resolve, reject) => { if (!isInitialized()) { return reject(new Error(""MessageBird Service not ready."")); } const originator = process.env.MESSAGEBIRD_ORIGINATOR; if (!originator) { logger.warn(""MESSAGEBIRD_ORIGINATOR not set in .env, SMS might fail or use MessageBird default.""); // Decide if this is critical: // return reject(new Error(""MessageBird Originator is not configured. Cannot send SMS."")); } // Ensure the scheduledDateTime is in the correct ISO 8601 format for the API const scheduleTimestamp = moment(scheduledDateTime).toISOString(); const params = { originator: originator || 'MessageBird', // Fallback originator if not set/critical recipients: [recipientNumber], body: messageBody, scheduledDatetime: scheduleTimestamp }; messagebird.messages.create(params, (err, response) => { if (err) { logger.error(`Failed to schedule SMS via MessageBird: ${err.message}`, { code: err.statusCode, errors: err.errors, recipient: recipientNumber }); return reject(new Error(""Failed to schedule SMS reminder via MessageBird."")); } logger.info(`SMS reminder scheduled successfully for ${recipientNumber}. Message ID: ${response.id}`); // Return the full response which includes the message ID: response.id resolve(response); }); }); }; module.exports = { initializeMessageBird, validatePhoneNumber, scheduleSmsReminder, };
initializeMessageBird
is now designed to be called once at startup (fromserver.js
). It throws an error if the API key is missing or initialization fails.- Added
isInitialized
check to service methods. - Error logging improved to include MessageBird error codes/details.
-
Appointment Service: Create a service for appointment business logic.
// src/services/appointmentService.js const prisma = require('../config/prisma'); const moment = require('moment-timezone'); const messagebirdService = require('./messagebirdService'); const logger = require('../config/logger'); const { AppError } = require('../utils/errorHandler'); // Use AppError for operational errors /** * Creates an appointment, validates the number, stores it, and schedules a reminder. * @param {object} appointmentData - Data for the new appointment. * @param {string} appointmentData.customerName * @param {string} appointmentData.customerNumberInput - Raw phone number input. * @param {string} appointmentData.treatment * @param {string} appointmentData.appointmentDate - e.g., ""YYYY-MM-DD"" * @param {string} appointmentData.appointmentTime - e.g., ""HH:mm"" * @param {string} appointmentData.timezone - e.g., ""America/New_York"", ""Europe/London"" * @returns {Promise<object>} - The created appointment object from the database. * @throws {AppError|Error} If validation, database operation, or scheduling fails. */ const createAndScheduleReminder = async ({ customerName, customerNumberInput, treatment, appointmentDate, appointmentTime, timezone, // Crucial for correct time handling }) => { // 1. Validate and combine date/time using the provided timezone const appointmentDateTimeStr = `${appointmentDate} ${appointmentTime}`; const appointmentMoment = moment.tz(appointmentDateTimeStr, ""YYYY-MM-DD HH:mm"", true, timezone); // `true` for strict parsing if (!appointmentMoment.isValid()) { // Use AppError for client-facing validation errors throw new AppError(""Invalid date, time, or timezone provided."", 400); } // Convert to UTC for storage const appointmentAtUTC = appointmentMoment.utc().toDate(); // 2. Check if appointment time is sufficiently in the future const reminderHours = parseInt(process.env.REMINDER_HOURS_BEFORE || '3', 10); // Add a buffer (e.g., 10 mins) to ensure schedule time is valid and account for processing delays const earliestPossibleAppointment = moment().add(reminderHours, 'hours').add(10, 'minutes'); if (appointmentMoment.isBefore(earliestPossibleAppointment)) { throw new AppError(`Appointment must be at least ${reminderHours} hours and 10 minutes in the future.`, 400); } // 3. Validate phone number using MessageBird Lookup let validatedNumber; try { // Use the raw input number for lookup const lookupResult = await messagebirdService.validatePhoneNumber(customerNumberInput); validatedNumber = lookupResult.phoneNumber; // Use the normalized E.164 number returned by MessageBird } catch (error) { logger.warn(`Phone number validation failed for input ${customerNumberInput}: ${error.message}`); // Propagate as an AppError to the client throw new AppError(`Phone number validation failed: ${error.message}`, 400); } // 4. Create appointment record in the database let newAppointment; try { newAppointment = await prisma.appointment.create({ data: { customerName, customerNumber: validatedNumber, // Store normalized number treatment, appointmentAt: appointmentAtUTC, // Store in UTC // reminderSentAt and messagebirdId will be updated after scheduling attempt }, }); logger.info(`Appointment created successfully in DB with ID: ${newAppointment.id}`); } catch (dbError) { logger.error(""Database error creating appointment:"", { error: dbError }); // This is likely a server error, not a client error throw new Error(""Failed to save appointment to the database.""); // Let global handler make it 500 } // 5. Calculate reminder time and schedule SMS const reminderMoment = appointmentMoment.clone().subtract(reminderHours, 'hours'); const reminderDateTime = reminderMoment.toDate(); // Use Date object for scheduling function // Construct a clear reminder message const localAppointmentTime = appointmentMoment.format('h:mm A'); // e.g., 2:30 PM const localAppointmentDate = appointmentMoment.format('MMM D, YYYY'); // e.g., Dec 15, 2025 const reminderBody = `Hi ${customerName}, reminder for your ${treatment} appointment on ${localAppointmentDate} at ${localAppointmentTime}. See you soon!`; try { const scheduleResponse = await messagebirdService.scheduleSmsReminder( validatedNumber, reminderBody, reminderDateTime ); // 6. Update appointment record with scheduling info (best effort) // Use updateMany for potentially better performance if needed, but update is fine here. await prisma.appointment.update({ where: { id: newAppointment.id }, data: { reminderSentAt: new Date(), // Record when scheduling call was successful messagebirdId: scheduleResponse.id, // Store MessageBird message ID }, }); logger.info(`Successfully scheduled reminder for appointment ${newAppointment.id}. MessageBird ID: ${scheduleResponse.id}`); } catch (scheduleError) { // CRITICAL: Scheduling failed AFTER DB write. logger.error(`Failed to schedule reminder for appointment ${newAppointment.id} (DB record exists!): ${scheduleError.message}`, { error: scheduleError }); // For this guide: We log the error and return the created appointment, but the reminder is NOT scheduled. // A production system needs a robust retry/queue mechanism here. // We DON'T throw an error back to the client here, as the appointment *was* created. // The response should indicate success in creation but failure in scheduling. // We'll handle this indication in the controller based on whether messagebirdId was set. newAppointment.schedulingError = `Failed to schedule SMS reminder: ${scheduleError.message}`; // Add temporary property for controller } // Return the created appointment data (potentially with scheduling error info) return newAppointment; }; module.exports = { createAndScheduleReminder, // Add other functions here later if needed (e.g., getAppointment, listAppointments) };
- Removed the
initializeMessageBird
call (moved toserver.js
). - Uses
AppError
for user-correctable errors (bad date, bad number, time too soon) to return 400 status. - Improved error handling for the case where DB write succeeds but scheduling fails: Logs the critical error but doesn't throw, instead attaches error info to the returned object for the controller to handle gracefully.
- Uses strict parsing for
moment.tz
. - Improved reminder message content.
- Stores normalized E.164 number from Lookup result.
- Removed the
4. Building the API Layer (Routes and Controllers)
Now, let's expose the appointment creation functionality via an Express API endpoint.
Steps:
-
Validation Middleware: Define validation rules for the incoming API request body.
// src/middleware/validationMiddleware.js const { body, validationResult } = require('express-validator'); const moment = require('moment-timezone'); const logger = require('../config/logger'); // Keep logger import if needed elsewhere const validateAppointment = [ // Validate customerName: must be non-empty string body('customerName') .trim() .notEmpty().withMessage('Customer name is required.') .isString().withMessage('Customer name must be a string.') .isLength({ min: 1, max: 100 }).withMessage('Customer name must be between 1 and 100 characters.'), // Validate customerNumberInput: must be non-empty string (basic check, MessageBird does thorough validation) body('customerNumberInput') .trim() .notEmpty().withMessage('Customer phone number is required.'), // Further validation is primarily handled by MessageBird Lookup service call // Validate treatment: must be non-empty string body('treatment') .trim() .notEmpty().withMessage('Treatment description is required.') .isString().withMessage('Treatment must be a string.') .isLength({ min: 1, max: 200 }).withMessage('Treatment must be between 1 and 200 characters.'), // Validate appointmentDate: must be a valid date in YYYY-MM-DD format and not obviously in the past body('appointmentDate') .isDate({ format: 'YYYY-MM-DD', strictMode: true }).withMessage('Appointment date must be in YYYY-MM-DD format.') .custom((value) => { // Basic check: ensure date is not clearly in the past (more detailed check in service) if (moment(value, 'YYYY-MM-DD').isBefore(moment(), 'day')) { throw new Error('Appointment date cannot be in the past.'); } return true; }), // Validate appointmentTime: must be a valid time in HH:mm format (24-hour) body('appointmentTime') .matches(/^([01]\d|2[0-3]):([0-5]\d)$/, 'g').withMessage('Appointment time must be in HH:mm format (24-hour).'), // Validate timezone: must be a valid IANA timezone string recognized by moment-timezone body('timezone') .trim() .notEmpty().withMessage('Timezone is required.') .custom((value) => { if (!moment.tz.zone(value)) { throw new Error('Invalid timezone provided. Use IANA format (e.g., America/New_York).'); } return true; // Indicates validation success }).withMessage('Invalid timezone provided. Use IANA format (e.g., America/New_York).'), // Add message to custom validator // Middleware function to handle validation results (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { // Intentionally removed noisy validation logging here for cleaner production logs // logger.warn(""Validation errors:"", { errors: errors.array() }); return res.status(400).json({ errors: errors.array() }); } next(); // Proceed to the controller if validation passes }, ]; module.exports = { validateAppointment, };
- Removed the commented-out
logger.warn
line. - Added basic length limits and a past date check.
- Ensured custom timezone validator has an error message attached correctly.
- Removed the commented-out
-
Appointment Controller: Handle the incoming request and call the service.
// src/controllers/appointmentController.js const appointmentService = require('../services/appointmentService'); const logger = require('../config/logger'); const moment = require('moment-timezone'); // Needed for response formatting const createAppointment = async (req, res, next) => { try { // Data already validated by middleware const appointmentData = req.body; logger.info(""Processing request to create appointment:"", { customer: appointmentData.customerName, date: appointmentData.appointmentDate }); const newAppointment = await appointmentService.createAndScheduleReminder(appointmentData); // Check if scheduling failed (indicated by the service adding a property) if (newAppointment.schedulingError) { // Logged already in service, return success for creation but include warning logger.warn(`Appointment ${newAppointment.id} created, but reminder scheduling failed.`); res.status(201).json({ message: ""Appointment created successfully, BUT failed to schedule SMS reminder. Please check logs or retry scheduling manually."", appointment: { id: newAppointment.id, customerName: newAppointment.customerName, treatment: newAppointment.treatment, // Show appointment time in the originally provided timezone appointmentAtLocal: moment(newAppointment.appointmentAt).tz(appointmentData.timezone).format('YYYY-MM-DD HH:mm Z'), reminderScheduled: false, schedulingError: newAppointment.schedulingError // Include error detail } }); } else { // Both creation and scheduling succeeded logger.info(`Successfully created appointment ${newAppointment.id} and scheduled reminder.`); res.status(201).json({ message: ""Appointment created and reminder scheduled successfully."", appointment: { id: newAppointment.id, customerName: newAppointment.customerName, treatment: newAppointment.treatment, appointmentAtLocal: moment(newAppointment.appointmentAt).tz(appointmentData.timezone).format('YYYY-MM-DD HH:mm Z'), reminderScheduled: true, messagebirdId: newAppointment.messagebirdId } }); } } catch (error) { // Pass errors (including AppError from service) to the global error handler next(error); } }; module.exports = { createAppointment, };
- Handles the
schedulingError
property potentially added by the service. If present, it returns a 201 but includes a warning message and details about the scheduling failure. - Formats the
appointmentAt
back to the client's specified timezone for the response. - Uses
next(error)
to pass all errors (operationalAppError
or unexpectedError
) to the global handler.
- Handles the
-
Appointment Routes: Define the API route.
// src/routes/appointmentRoutes.js const express = require('express'); const appointmentController = require('../controllers/appointmentController'); const { validateAppointment } = require('../middleware/validationMiddleware'); const router = express.Router(); // POST /api/appointments - Create a new appointment and schedule reminder router.post('/', validateAppointment, appointmentController.createAppointment); // Add other routes here later (e.g., GET /api/appointments/:id) module.exports = router;
- Imports the controller and validation middleware.
- Defines a POST route at
/
(which will be mounted under/api/appointments
inapp.js
) that first runs the validation middleware, then the controller function.