code examples
code examples
Building an Appointment Reminder System with Node.js, Express, and Sinch SMS
A guide on creating an appointment reminder application using Node.js, Express, Prisma, and the Sinch SMS API, covering setup, database, scheduling, UI, and deployment considerations.
This guide provides a step-by-step walkthrough for building a robust appointment reminder application using Node.js, Express, and the Sinch SMS API. We'll cover database persistence, error handling, security, and deployment considerations to create a system with a strong foundation for real-world use.
By the end of this tutorial, you will have a web application that enables users (e.g., administrators) to schedule appointments and automatically sends SMS reminders to recipients (e.g., patients, clients) at a specified time before their appointment. This solves the common business problem of no-shows and improves communication efficiency.
Disclaimer: While this guide is comprehensive and covers many production considerations, true production readiness involves further hardening, extensive testing, robust monitoring, logging aggregation, and security measures tailored to your specific environment and compliance needs.
Key Technologies:
- Node.js: A JavaScript runtime for building server-side applications.
- Express: A minimal and flexible Node.js web application framework.
- Sinch SMS API & Node SDK (
@sinch/sdk-core): Used for scheduling and sending SMS messages. We leverage its built-in scheduling (send_at) feature. - Prisma: A modern database toolkit for Node.js and TypeScript (used here for PostgreSQL, but adaptable). Simplifies database access and migrations.
- Luxon: A library for handling dates and times reliably, essential for scheduling logic.
- EJS: A simple templating language for rendering HTML views.
- dotenv: For managing environment variables securely.
System Architecture:
+-------------+ +------------------------+ +-----------------+ +-----------------+
| End User | ----> | Browser | ----> | Node.js/Express | ----> | PostgreSQL DB |
| (Admin) | | (Input Form) | | App | | (Prisma) |
+-------------+ +------------------------+ | - Routes | +-----------------+
| - Logic |
| - Sinch Client |
+--------+--------+
|
v
+-------------+ +---------------+
| Recipient | <--------------------------------------- | Sinch SMS API |
| (Patient) | (Receives SMS Reminder) +---------------+
+-------------+Prerequisites:
- Node.js and npm (or yarn) installed.
- A Sinch account with access to the SMS API. You'll need your Project ID, an API Access Key (ID and Secret), and a Sinch phone number. Your account's SMS Service Plan region (
usoreu) is also required. - A verified recipient phone number added to your Sinch account (required for testing, especially on trial accounts).
- Basic familiarity with Node.js, Express, and command-line operations.
- Access to a PostgreSQL database (local or cloud-hosted). You can adapt the Prisma setup for other databases if needed.
Final Outcome:
A web application running locally (and deployable) with:
- A web form to input appointment details (recipient name, phone number, doctor/service provider, date, time).
- Backend logic to validate input and schedule an SMS reminder via Sinch for 2 hours before the appointment.
- Database persistence for appointment data.
- Error handling, logging, and security measures.
- Instructions for testing, deployment, and potential enhancements.
1. Setting up the Project
Let's initialize our Node.js project, set up the directory structure, install dependencies, and configure environment variables.
1.1. Create Project Directory:
Open your terminal or command prompt and navigate to where you want to create your project.
mkdir sinch-appointment-scheduler
cd sinch-appointment-scheduler1.2. Initialize Node.js Project:
npm init -yThis creates a package.json file with default settings.
1.3. Create Directory Structure:
Create the necessary folders and files:
# On Linux/macOS
mkdir public public/css views prisma
touch .env .gitignore app.js routes.js public/css/style.css views/appointment_form.ejs views/success.ejs views/error.ejs prisma/schema.prisma
# On Windows (Command Prompt)
mkdir public public\css views prisma
echo. > .env
echo. > .gitignore
echo. > app.js
echo. > routes.js
echo. > public\css\style.css
echo. > views\appointment_form.ejs
echo. > views\success.ejs
echo. > views\error.ejs
echo. > prisma\schema.prisma
# On Windows (PowerShell)
mkdir public, public\css, views, prisma
New-Item .env, .gitignore, app.js, routes.js, public\css\style.css, views\appointment_form.ejs, views\success.ejs, views\error.ejs, prisma\schema.prisma -ItemType Filepublic/: Stores static assets like CSS.views/: Contains EJS template files for the UI.prisma/: Holds database schema and migration files..env: Stores environment variables (API keys, database URL, etc.). Never commit this file to Git..gitignore: Specifies intentionally untracked files that Git should ignore.app.js: The main entry point for our Express application.routes.js: Defines the application's routes and request handlers.schema.prisma: Defines our database models and connection.
1.4. Configure .gitignore:
Add the following to your .gitignore file to prevent sensitive information and unnecessary files from being committed:
# .gitignore
# Dependencies
node_modules/
# Environment variables
.env
# Prisma
prisma/migrations/*/*.sql
prisma/dev.db
prisma/dev.db-journal
# OS generated files
.DS_Store
Thumbs.db1.5. Install Dependencies:
Install the necessary npm packages:
npm install express @sinch/sdk-core dotenv ejs luxon @prisma/client connect-flash express-session helmet express-rate-limit
npm install --save-dev prisma nodemonexpress: The web framework.@sinch/sdk-core: The official Sinch Node.js SDK for interacting with their APIs.dotenv: Loads environment variables from the.envfile.ejs: The template engine for rendering views.luxon: For robust date/time manipulation.@prisma/client: The Prisma database client.connect-flash: Middleware for displaying flash messages (used for success/error feedback).express-session: Middleware for managing user sessions (needed byconnect-flash).helmet: Middleware for setting various security HTTP headers.express-rate-limit: Middleware for basic rate limiting.prisma(dev): The Prisma CLI for migrations and generation.nodemon(dev): Utility to automatically restart the server during development when files change.
1.6. Set up Prisma:
Initialize Prisma with PostgreSQL (you can change postgresql to mysql, sqlite, sqlserver, or mongodb if needed):
npx prisma init --datasource-provider postgresqlThis command does two things:
- Creates the
prismadirectory (if it doesn't exist) and theschema.prismafile. - Creates the
.envfile (if it doesn't exist) and adds aDATABASE_URLvariable placeholder.
1.7. Configure Environment Variables (.env):
Open the .env file and add the following variables. Replace the placeholder values with your actual Sinch credentials, phone number, region, and database connection URL.
# .env
# Sinch API Credentials
# Get these from your Sinch Customer Dashboard -> Access Keys
SINCH_PROJECT_ID='YOUR_project_id'
SINCH_KEY_ID='YOUR_key_id'
SINCH_KEY_SECRET='YOUR_key_secret'
# Sinch SMS Configuration
# A phone number purchased or verified on your Sinch account
SINCH_FROM_NUMBER='+1xxxxxxxxxx' # Use E.164 format
# The region your SMS service plan is configured for ('us' or 'eu').
# This is crucial for routing and billing, even if not directly passed to the SDK constructor.
SINCH_SMS_REGION='us'
# Application Configuration
SESSION_SECRET='a-very-strong-random-secret-key' # Change this to a long random string for production
PORT=3000
# Database Connection URL (Prisma)
# Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
# Example for local SQLite (simpler for quick testing, create prisma/dev.db): file:./prisma/dev.db
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/appointment_reminders?schema=public"
# Optional: Default Country Code for phone numbers if not provided with E.164
# Primarily useful if your app *only* serves one region and users might omit the code.
# The code below prioritizes E.164 format.
# DEFAULT_COUNTRY_CODE='+1' # Example for US/Canada-
How to get Sinch Credentials:
- Log in to the Sinch Customer Dashboard.
- Navigate to the "Access Keys" section in the left-hand menu.
- Note your
Project ID. - If you don't have an Access Key pair, click "Create Key". Copy the
Key IDandSecretimmediately and store them securely (like in this.envfile). The Secret is only shown once. - Find your Sinch phone number under your SMS Service Plan details.
- Determine your SMS region (
usoreu) based on your account setup. This must match theSINCH_SMS_REGIONvariable.
-
SESSION_SECRET: Make this a long, random, unpredictable string for security. -
DATABASE_URL: Update this with the correct connection string for your PostgreSQL database (or chosen alternative). Ensure the database (appointment_remindersin the example) exists.
1.8. Configure package.json Scripts:
Add the following scripts to your package.json for easier development and database management:
// package.json (add or modify the "scripts" section)
{
// ... other properties
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"prisma:migrate:dev": "npx prisma migrate dev", // Renamed for clarity
"prisma:migrate:deploy": "npx prisma migrate deploy", // For production deployments
"prisma:generate": "npx prisma generate",
"test": "echo \"Error: no test specified - Setup tests using Jest/Mocha/Supertest\" && exit 1" // Placeholder
},
// ... other properties
}npm start: Runs the application using Node.npm run dev: Runs the application usingnodemonfor auto-restarts.npm run prisma:migrate:dev: Applies database schema changes during development (prompts for migration name).npm run prisma:migrate:deploy: Applies pending migrations in production/CI environments (non-interactive).npm run prisma:generate: Generates the Prisma Client based on your schema.
2. Creating the Database Schema and Data Layer
We need a way to store appointment information persistently. We'll use Prisma for this.
2.1. Define the Database Schema (prisma/schema.prisma):
Open prisma/schema.prisma and define the model for our appointments:
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql"" // Or your chosen provider
url = env(""DATABASE_URL"")
}
model Appointment {
id Int @id @default(autoincrement())
patientName String
doctorName String
patientPhone String // Store in E.164 format (e.g., +12223334444)
appointmentTime DateTime // Store in UTC
reminderSentAt DateTime? // Timestamp when the reminder was successfully scheduled/sent via Sinch
// Optional: Add a status field for more complex retry/failure handling
// status String @default(""pending_schedule"") // e.g., pending_schedule, scheduled, schedule_failed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([appointmentTime]) // Index for querying appointments by time
}- We define an
Appointmentmodel with fields for patient/doctor names, patient phone number, the appointment time (crucially stored in UTC), and timestamps. reminderSentAttracks if/when the reminder was processed by our app and sent to Sinch.- Indices (
@@index) improve query performance.
2.2. Apply the Database Migration:
Run the development migration command. Prisma will create the necessary SQL, apply it to your database, and generate the Prisma Client.
npm run prisma:migrate:devFollow the prompts. Prisma will ask for a name for this migration (e.g., ""init""). This creates the Appointment table in your database.
2.3. Generate Prisma Client:
Although migrate dev usually runs generate, it's good practice to ensure the client is up-to-date, especially after schema changes without migration.
npm run prisma:generateThis generates the typed database client in node_modules/@prisma/client.
3. Implementing Core Functionality (Routing and Logic)
Now, let's build the Express application logic in app.js and routes.js.
3.1. Set up the Main Application (app.js):
This file initializes Express, sets up middleware, configures the view engine, mounts the routes, starts the server, and structures the app for potential testing.
// app.js
require('dotenv').config(); // Load .env variables early
const express = require('express');
const path = require('path');
const session = require('express-session');
const flash = require('connect-flash');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const routes = require('./routes'); // Import our routes
const app = express();
const PORT = process.env.PORT || 3000;
// --- Security Middleware ---
app.use(helmet()); // Set various security HTTP headers
// Basic rate limiting to prevent abuse
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again after 15 minutes'
});
app.use(limiter); // Apply to all requests
// --- Standard Middleware ---
// Serve static files (CSS, JS, images) from the 'public' directory
app.use(express.static(path.join(__dirname, 'public')));
// Parse URL-encoded request bodies (form submissions)
app.use(express.urlencoded({ extended: false }));
// Parse JSON request bodies (optional, if you expect JSON input)
// app.use(express.json());
// Session middleware configuration
// Required for connect-flash
if (!process.env.SESSION_SECRET) {
console.error("FATAL ERROR: SESSION_SECRET is not set in the environment variables.");
process.exit(1);
}
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
// Use secure cookies in production (requires HTTPS)
secure: process.env.NODE_ENV === 'production',
httpOnly: true, // Prevent client-side JS access
maxAge: 60 * 60 * 1000 // Example: 1 hour expiry
}
}));
// Flash messages middleware
// Must be after session middleware
app.use(flash());
// Make flash messages available in templates
app.use((req, res, next) => {
res.locals.success_msg = req.flash('success_msg');
res.locals.error_msg = req.flash('error_msg');
next();
});
// --- View Engine Setup ---
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); // Tell Express where to find view files
// --- Routes ---
// Mount the routes defined in routes.js
app.use('/', routes);
// --- Error Handling ---
// Catch 404 and forward to error handler
app.use((req, res, next) => {
const error = new Error('Not Found');
error.status = 404;
next(error);
});
// Generic error handler
app.use((err, req, res, next) => {
// Log the full error stack in development, or just the message in production for security
console.error(process.env.NODE_ENV === 'development' ? err.stack : err.message);
res.status(err.status || 500);
res.render('error', { // Render the error page
message: err.message,
// Provide error object with stack trace only in development
error: process.env.NODE_ENV === 'development' ? err : {}
});
});
// --- Start Server (only if run directly) ---
// This structure allows importing 'app' for testing without starting the server
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server started on port ${PORT}. Access at http://localhost:${PORT}`);
});
}
// Export the app instance for testing or potential programmatic use
module.exports = app;- Middleware: Includes
helmetfor security headers,express-rate-limitfor basic abuse prevention, body parsers, session management, and flash messages. - Secure Cookies: The
securecookie flag is enabled in production (requires HTTPS).httpOnlyis also set. - View Engine: EJS is configured.
- Routing: Routes from
routes.jsare mounted. - Error Handling: Includes 404 handler and a generic error handler that logs appropriately based on environment and renders an
error.ejsview. - Server Start: The
app.listencall is conditional, allowingappto be exported for testing.
3.2. Define Application Routes (routes.js):
This file handles incoming requests, interacts with the database via Prisma, calls the Sinch service, and renders views.
// routes.js
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { DateTime } = require('luxon');
const { SinchClient } = require('@sinch/sdk-core');
const router = express.Router();
const prisma = new PrismaClient();
// --- Check for Sinch Configuration ---
// Ensure critical environment variables are loaded
const requiredEnvVars = ['SINCH_PROJECT_ID', 'SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_FROM_NUMBER', 'SINCH_SMS_REGION'];
const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName]);
if (missingEnvVars.length > 0) {
console.error(`FATAL ERROR: Missing required Sinch environment variables: ${missingEnvVars.join(', ')}`);
process.exit(1); // Exit if critical config is missing
}
// --- Initialize Sinch Client ---
const sinchClient = new SinchClient({
projectId: process.env.SINCH_PROJECT_ID,
keyId: process.env.SINCH_KEY_ID,
keySecret: process.env.SINCH_KEY_SECRET,
// Note: SMS region ('us' or 'eu') is crucial for your account's service plan/routing,
// but typically not needed in the SinchClient constructor for v1+ SMS API calls.
});
// --- Helper Function to Send Scheduled SMS ---
async function scheduleReminder(appointment) {
// Calculate reminder time (e.g., 2 hours before appointment)
// Ensure appointmentTime is a Luxon DateTime object in UTC
const appointmentDtUtc = DateTime.fromJSDate(appointment.appointmentTime, { zone: 'utc' });
const reminderDtUtc = appointmentDtUtc.minus({ hours: 2 });
// Format for Sinch API (ISO 8601 format, Z denotes UTC)
const sendAtIso = reminderDtUtc.toISO();
// Construct the message body
// Display appointment time in server's local time for the message - consider user timezone if needed
const localAppointmentTimeStr = appointmentDtUtc.setZone('local').toLocaleString(DateTime.DATETIME_SHORT);
const messageBody = `Reminder: Your appointment with ${appointment.doctorName} is scheduled for ${localAppointmentTimeStr}.`;
// Recipient number should already be in E.164 format from validation/storage
const recipientNumber = appointment.patientPhone;
console.log(`Scheduling SMS to ${recipientNumber} for ${sendAtIso}. Message: "${messageBody}"`);
try {
const response = await sinchClient.sms.batches.send({
sendSMSRequestBody: {
to: [recipientNumber],
from: process.env.SINCH_FROM_NUMBER,
body: messageBody,
send_at: sendAtIso, // The crucial scheduling parameter
// Optional: delivery_report: 'summary' or 'full'
}
});
console.log('Sinch API Send Response:', JSON.stringify(response));
// Update the appointment record in the database after successful scheduling
await prisma.appointment.update({
where: { id: appointment.id },
data: { reminderSentAt: new Date() } // Record scheduling time
// Optional: Update status field if using one: data: { reminderSentAt: new Date(), status: 'scheduled' }
});
return { success: true, batchId: response.id };
} catch (error) {
let errorMessage = error.message;
if (error.response && error.response.data) {
// Extract more specific Sinch error details if available
errorMessage = `Sinch API Error: ${JSON.stringify(error.response.data)}`;
}
console.error('Error scheduling SMS via Sinch:', errorMessage);
// Consider more specific error handling based on Sinch error codes if needed
return { success: false, error: errorMessage };
}
}
// --- Route Definitions ---
// GET / : Display the appointment scheduling form
router.get('/', (req, res) => {
res.render('appointment_form', {
formData: {} // Pass empty object initially
});
});
// POST /schedule : Handle form submission, validate, save, and schedule reminder
router.post('/schedule', async (req, res) => {
const { patientName, doctorName, patientPhone, appointmentDate, appointmentTime } = req.body;
// --- Basic Input Validation ---
if (!patientName || !doctorName || !patientPhone || !appointmentDate || !appointmentTime) {
req.flash('error_msg', 'Please fill in all fields.');
// Re-render form with errors and previous data
return res.render('appointment_form', { formData: req.body });
}
// --- Date/Time Processing with Luxon ---
let appointmentDt;
try {
// Combine date and time.
// WARNING: Assumes input is in the server's local time zone.
// See Section 7 for robust timezone handling (recommend storing UTC).
appointmentDt = DateTime.fromISO(`${appointmentDate}T${appointmentTime}`, { zone: 'local' });
if (!appointmentDt.isValid) {
throw new Error(`Invalid date/time format: ${appointmentDt.invalidReason}`);
}
} catch (err) {
console.error("Date/Time Parsing Error:", err);
req.flash('error_msg', 'Invalid date or time format provided.');
return res.render('appointment_form', { formData: req.body });
}
const appointmentDtUtc = appointmentDt.toUTC(); // Convert to UTC for storage and scheduling
const reminderDtUtc = appointmentDtUtc.minus({ hours: 2 }); // Calculate reminder time
const nowUtc = DateTime.now().toUTC();
// --- Validation: Ensure reminder time is in the future ---
// Allow a small buffer (e.g., 5 minutes) to avoid race conditions with API call
if (reminderDtUtc < nowUtc.plus({ minutes: 5 })) {
req.flash('error_msg', 'Appointment must be far enough in the future to schedule a reminder (at least ~2 hours + 5 mins from now).');
return res.render('appointment_form', { formData: req.body });
}
// --- Phone Number Formatting (Robust E.164 target) ---
const trimmedPhone = patientPhone.trim();
let formattedPhone;
if (trimmedPhone.startsWith('+')) {
// Input claims to be E.164. Keep the leading +, remove other non-digits.
formattedPhone = '+' + trimmedPhone.substring(1).replace(/\D/g, '');
} else {
// Assume local format, remove all non-digits, prepend default country code.
const digits = trimmedPhone.replace(/\D/g, '');
// Use default country code from .env or fallback (e.g., +1 for North America)
const defaultCode = process.env.DEFAULT_COUNTRY_CODE || '+1';
formattedPhone = defaultCode + digits;
console.log(`Applied default country code to ${patientPhone} -> ${formattedPhone}`);
}
// Basic validation for E.164-like format (starts with +, at least 7 digits)
// Enhance with libphonenumber-js for production robustness (See Section 7)
if (!/^\+\d{7,}$/.test(formattedPhone)) {
req.flash('error_msg', `Invalid phone number format provided: ${patientPhone}. Please use E.164 format (e.g., +15551234567) or a valid local number.`);
return res.render('appointment_form', { formData: req.body });
}
// --- Save Appointment to Database ---
let newAppointment;
try {
newAppointment = await prisma.appointment.create({
data: {
patientName,
doctorName,
patientPhone: formattedPhone, // Store formatted E.164 number
appointmentTime: appointmentDtUtc.toJSDate(), // Store as native Date (in UTC)
// Optional: status: 'pending_schedule' // If using status field
}
});
console.log('Appointment saved to DB:', newAppointment.id);
} catch (dbError) {
console.error('Database Error Saving Appointment:', dbError);
req.flash('error_msg', 'Failed to save appointment due to a database error. Please try again.');
return res.render('appointment_form', { formData: req.body });
}
// --- Schedule the Reminder via Sinch ---
const scheduleResult = await scheduleReminder(newAppointment);
if (scheduleResult.success) {
req.flash('success_msg', `Appointment scheduled successfully! Reminder SMS queued (Batch ID: ${scheduleResult.batchId}).`);
res.redirect('/success'); // Redirect to a success page
} else {
// Scheduling failed after saving to DB.
// The DB record exists but no reminder is scheduled.
// Implications: Requires manual follow-up, a cleanup job, or a retry mechanism (see Section 5).
// Adding a 'status' field to the Appointment model helps manage this.
console.error(`Failed to schedule reminder for appointment ID: ${newAppointment.id}. Error: ${scheduleResult.error}`);
// Optional: Update DB status to 'schedule_failed' here
// await prisma.appointment.update({ where: { id: newAppointment.id }, data: { status: 'schedule_failed' } });
req.flash('error_msg', `Appointment saved (ID: ${newAppointment.id}), but failed to schedule the SMS reminder via Sinch. Error: ${scheduleResult.error}. Please contact support or try rescheduling manually.`);
// Redirect back to the form, retaining user input
res.render('appointment_form', { formData: req.body });
}
});
// GET /success : Display a success confirmation page
router.get('/success', (req, res) => {
// Check if a success message exists from a previous redirect
// If accessed directly without a flash message, redirect home.
const successMsg = req.flash('success_msg'); // Retrieve and clear the message
if (!successMsg || successMsg.length === 0) {
return res.redirect('/');
}
// Pass the message to the view
res.render('success', { success_msg: successMsg });
});
// GET /health : Basic health check endpoint
router.get('/health', (req, res) => {
// Could add checks for DB connection, Sinch connectivity etc. later
res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() });
});
module.exports = router;- Sinch Client Init: Includes a check for all required
.envvariables before initializing the client. Clarifies region importance vs. constructor parameter. scheduleReminder: Calculates reminder time in UTC, formats message, calls Sinch API, updates DB on success. Includes better error message extraction from Sinch errors.- GET
/: Renders the form. - POST
/schedule:- Validates input.
- Parses date/time using Luxon (with a warning about local time assumption). Converts to UTC.
- Validates reminder time is in the future.
- Includes more robust phone number formatting logic targeting E.164.
- Saves appointment to DB (UTC time).
- Calls
scheduleReminder. - Handles
scheduleRemindersuccess (redirect with flash message). - Handles
scheduleReminderfailure: Logs error, sets informative flash message explaining the DB record exists but scheduling failed, and re-renders the form.
- GET
/success: Renders success page, ensuring it only shows message if redirected viaflash. - GET
/health: Basic health check.
4. Creating the User Interface (Views)
We need simple EJS views for the form, success page, and error page.
4.1. Basic Styling (public/css/style.css):
Add some minimal CSS for better presentation.
/* public/css/style.css */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
padding: 20px;
max-width: 600px;
margin: 40px auto;
background-color: #f8f9fa;
color: #212529;
}
h1, h2 {
color: #343a40;
margin-bottom: 1rem;
}
.container {
background: #ffffff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
}
.form-group {
margin-bottom: 1.25rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
input[type=""text""],
input[type=""tel""],
input[type=""date""],
input[type=""time""],
button {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #ced4da;
border-radius: 4px;
box-sizing: border-box; /* Include padding and border */
font-size: 1rem;
line-height: 1.5;
}
input:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
button {
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s ease-in-out;
}
button:hover {
background-color: #0056b3;
}
.alert {
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
border: 1px solid transparent;
border-radius: 4px;
font-size: 0.95rem;
}
.alert-success {
color: #0f5132;
background-color: #d1e7dd;
border-color: #badbcc;
}
.alert-danger {
color: #842029;
background-color: #f8d7da;
border-color: #f5c2c7;
}
small {
display: block;
margin-top: 0.25rem;
font-size: 0.875em;
color: #6c757d;
}
pre {
background-color: #e9ecef;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
}4.2. Appointment Form (views/appointment_form.ejs):
This view displays the form for inputting appointment details and shows any error flash messages. Success messages are handled on the /success page.
<!-- views/appointment_form.ejs -->
<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Schedule Appointment Reminder</title>
<link rel=""stylesheet"" href=""/css/style.css"">
</head>
<body>
<div class=""container"">
<h1>Schedule Appointment Reminder</h1>
<% if (locals.error_msg && error_msg.length > 0) { %>
<div class=""alert alert-danger""><%= error_msg %></div>
<% } %>
<form action=""/schedule"" method=""POST"">
<div class=""form-group"">
<label for=""patientName"">Patient Name:</label>
<input type=""text"" id=""patientName"" name=""patientName"" value=""<%= locals.formData && formData.patientName ? formData.patientName : '' %>"" required>
</div>
<div class=""form-group"">
<label for=""doctorName"">Doctor/Service Provider Name:</label>
<input type=""text"" id=""doctorName"" name=""doctorName"" value=""<%= locals.formData && formData.doctorName ? formData.doctorName : '' %>"" required>
</div>
<div class=""form-group"">
<label for=""patientPhone"">Patient Phone Number:</label>
<input type=""tel"" id=""patientPhone"" name=""patientPhone"" placeholder=""e.g., +15551234567 or local number"" value=""<%= locals.formData && formData.patientPhone ? formData.patientPhone : '' %>"" required>
<small>Use E.164 format (+CountryCodeNumber) or local number if default code is set.</small>
</div>
<div class=""form-group"">
<label for=""appointmentDate"">Appointment Date:</label>
<input type=""date"" id=""appointmentDate"" name=""appointmentDate"" value=""<%= locals.formData && formData.appointmentDate ? formData.appointmentDate : '' %>"" required>
</div>
<div class=""form-group"">
<label for=""appointmentTime"">Appointment Time:</label>
<input type=""time"" id=""appointmentTime"" name=""appointmentTime"" value=""<%= locals.formData && formData.appointmentTime ? formData.appointmentTime : '' %>"" required>
<small>Time is assumed to be in the server's local timezone.</small>
</div>
<button type=""submit"">Schedule Reminder</button>
</form>
</div>
</body>
</html>4.3. Success Page (views/success.ejs):
Displays the success flash message after a successful submission.
<!-- views/success.ejs -->
<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Appointment Scheduled</title>
<link rel=""stylesheet"" href=""/css/style.css"">
</head>
<body>
<div class=""container"">
<h1>Success!</h1>
<% if (locals.success_msg && success_msg.length > 0) { %>
<div class=""alert alert-success""><%= success_msg %></div>
<% } else { %>
<p>Appointment scheduled successfully.</p> <!-- Fallback message -->
<% } %>
<p><a href=""/"">Schedule another appointment</a></p>
</div>
</body>
</html>4.4. Error Page (views/error.ejs):
A generic error page used by the global error handler.
<!-- views/error.ejs -->
<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>Error</title>
<link rel=""stylesheet"" href=""/css/style.css"">
</head>
<body>
<div class=""container"">
<h1>An Error Occurred</h1>
<div class=""alert alert-danger"">
<%= message %>
</div>
<% if (locals.error && error.stack) { %>
<h2>Stack Trace (Development Mode)</h2>
<pre><code><%= error.stack %></code></pre>
<% } %>
<p><a href=""/"">Return to Home</a></p>
</div>
</body>
</html>5. Running and Testing the Application
5.1. Ensure Database is Running:
Make sure your PostgreSQL server (or chosen database) is running and accessible using the DATABASE_URL in your .env file.
5.2. Apply Migrations (if not already done):
npm run prisma:migrate:dev5.3. Start the Development Server:
npm run devThis will start the server using nodemon, which automatically restarts when you save file changes. You should see output like:
[nodemon] starting `node app.js`
Server started on port 3000. Access at http://localhost:3000
5.4. Test the Application:
- Open your web browser and navigate to
http://localhost:3000. - You should see the appointment scheduling form.
- Fill out the form:
- Use valid names.
- For Patient Phone Number, use a number you have verified with Sinch (especially important for trial accounts). Use E.164 format (e.g.,
+15551234567) or a local format if you've setDEFAULT_COUNTRY_CODE. - Select a date and time at least 2 hours and 5 minutes in the future (due to the 2-hour reminder offset and the 5-minute buffer in the validation logic).
- Click "Schedule Reminder".
- Expected Outcomes:
- Success: You should be redirected to the
/successpage with a confirmation message including the Sinch Batch ID. Check your terminal logs for "Appointment saved to DB" and "Scheduling SMS..." messages, followed by the Sinch API response. You should receive the SMS reminder on the specified phone number approximately 2 hours before the scheduled appointment time. - Validation Error: If you miss a field or enter an invalid date/time/phone, the form should re-render with an error message at the top, preserving your previous input. Check terminal logs for validation error messages.
- Scheduling Error: If the appointment saves to the database but fails to schedule via Sinch (e.g., invalid API keys, Sinch service issue), the form should re-render with an error message indicating the appointment ID and the Sinch error. Check terminal logs for "Database Error Saving Appointment" or "Error scheduling SMS via Sinch" messages.
- Server Error: If a more critical error occurs, you should see the generic error page (
views/error.ejs). Check terminal logs for the full error stack trace.
- Success: You should be redirected to the
5.5. Check Database:
You can use a database tool (like psql, pgAdmin, DBeaver, or Prisma Studio) to inspect the Appointment table in your database (appointment_reminders by default) and verify that records are being created and the reminderSentAt field is updated upon successful scheduling.
To use Prisma Studio (a GUI for your database):
npx prisma studio6. Deployment Considerations
Deploying this application requires several steps beyond local development.
6.1. Choose a Hosting Provider:
Options include:
- Platform-as-a-Service (PaaS): Heroku, Render, Fly.io, Google Cloud App Engine, AWS Elastic Beanstalk. Often simpler to manage.
- Virtual Private Server (VPS): DigitalOcean, Linode, AWS EC2, Google Cloud Compute Engine. More control, more setup required.
- Containers: Dockerizing the app and deploying to Kubernetes, Docker Swarm, AWS ECS/Fargate, Google Cloud Run.
6.2. Production Database:
- Use a managed database service (e.g., AWS RDS, Google Cloud SQL, Heroku Postgres, ElephantSQL, DigitalOcean Managed Databases) instead of running your own database server on a VPS unless you have specific needs and expertise.
- Ensure your
DATABASE_URLenvironment variable points to the production database.
6.3. Environment Variables:
- Never commit your
.envfile. - Use your hosting provider's mechanism for setting environment variables securely (e.g., Heroku Config Vars, Render Environment Groups, AWS Secrets Manager, Google Secret Manager, Docker secrets).
- Set
NODE_ENV=production. This enables optimizations in Express, disables detailed error messages to the client, and enables secure cookies if using HTTPS. - Generate a strong, unique
SESSION_SECRETfor production.
6.4. Build Step:
- While this simple app doesn't have a complex build step, ensure all production dependencies (not devDependencies) are installed:
npm install --omit=devornpm ci --omit=dev. - If using TypeScript or a bundler, you would add a build script to
package.json(e.g.,tscorwebpack) and run that before starting the app.
6.5. Database Migrations in Production:
- Apply migrations using the non-interactive command:
npm run prisma:migrate:deploy. - Integrate this into your deployment pipeline before starting the new version of the application to ensure the database schema matches the code.
6.6. Process Manager:
- Run your Node.js application using a process manager like PM2 or
systemd(on Linux VPS). This handles:- Restarting the app if it crashes.
- Running the app in the background.
- Clustering (running multiple instances to utilize multiple CPU cores).
- Log management.
- Example with PM2:
bash
npm install pm2 -g pm2 start app.js --name ""appointment-scheduler"" --env production pm2 startup # To ensure PM2 restarts on server reboot pm2 save # Save current process list
6.7. HTTPS:
- Essential for production. Encrypts data in transit and is required for secure cookies.
- Options:
- Use a reverse proxy like Nginx or Caddy in front of your Node.js app to handle SSL termination. Let's Encrypt provides free certificates.
- Many PaaS providers handle HTTPS automatically.
6.8. Logging and Monitoring:
- Configure robust logging. Log to standard output/error streams, and let your hosting environment or process manager handle log aggregation (e.g., Papertrail, LogDNA, Datadog, CloudWatch Logs).
- Implement application performance monitoring (APM) tools (e.g., Datadog, New Relic, Dynatrace) to track performance, errors, and resource usage.
- Set up health checks (like the
/healthendpoint) for load balancers or monitoring systems.
6.9. Security Hardening:
- Keep dependencies updated (
npm audit fix). - Review
helmetconfiguration for appropriate security headers. - Implement more robust rate limiting, potentially using Redis for shared state across multiple instances.
- Consider input validation libraries (e.g.,
joi,express-validator) for more complex validation rules. - Protect against Cross-Site Scripting (XSS) - EJS escapes by default, but be cautious if injecting HTML.
- Protect against Cross-Site Request Forgery (CSRF) using tokens (e.g.,
csurfmiddleware). - Regular security audits.
7. Potential Enhancements and Further Considerations
- Timezone Handling:
- The current implementation assumes the server's local time for input. This is fragile.
- Best Practice: Store all
appointmentTimevalues in the database as UTC (already done). - On the frontend, use JavaScript to capture the user's local timezone or provide a timezone selector.
- Send the selected timezone along with the form data.
- On the backend, use Luxon to parse the date/time with the provided timezone before converting to UTC for storage:
javascript
// Example assuming 'userTimezone' (e.g., 'America/New_York') is sent from frontend const userTimezone = req.body.userTimezone || 'local'; // Fallback appointmentDt = DateTime.fromISO(`${appointmentDate}T${appointmentTime}`, { zone: userTimezone }); const appointmentDtUtc = appointmentDt.toUTC(); // ... store appointmentDtUtc.toJSDate() ... - When displaying times (e.g., in the reminder message), convert the stored UTC time back to an appropriate local timezone (either the user's original timezone or a standard one for the business).
- Phone Number Validation:
- Use a dedicated library like
libphonenumber-jsfor robust parsing, validation, and formatting of international phone numbers.
bashnpm install libphonenumber-jsjavascriptconst { parsePhoneNumberFromString } = require('libphonenumber-js'); // ... inside POST /schedule ... try { const phoneNumber = parsePhoneNumberFromString(patientPhone); // Can specify default country if (!phoneNumber || !phoneNumber.isValid()) { throw new Error('Invalid phone number'); } formattedPhone = phoneNumber.format('E.164'); // Guaranteed E.164 format } catch (phoneError) { req.flash('error_msg', `Invalid phone number: ${patientPhone}. Please provide a valid number.`); return res.render('appointment_form', { formData: req.body }); } - Use a dedicated library like
- Error Handling & Retries:
- Implement a retry mechanism for failed Sinch API calls (e.g., using exponential backoff).
- Add a
statusfield to theAppointmentmodel (pending_schedule,scheduled,schedule_failed,delivered,delivery_failed). - Use Sinch Delivery Reports (webhooks or API polling) to update the status based on actual delivery success/failure.
- Create background jobs (e.g., using
node-cronor a dedicated job queue like BullMQ) to:- Retry scheduling failed reminders.
- Clean up old appointments.
- Periodically check for appointments where scheduling might have been missed.
- User Interface Improvements:
- Use a date/time picker library on the frontend for a better user experience.
- Add client-side validation for immediate feedback.
- Implement a dashboard to view scheduled, sent, and failed reminders.
- Authentication/Authorization:
- Add user login for administrators scheduling appointments (e.g., using Passport.js).
- Configuration:
- Make the reminder offset (currently hardcoded at 2 hours) configurable via environment variables or a settings UI.
- Testing:
- Write unit tests (e.g., using Jest or Mocha) for helper functions and validation logic.
- Write integration tests (e.g., using Supertest) to test API endpoints and interactions with the database/Sinch (potentially using mocks).
- Sinch Features:
- Explore using Sinch Delivery Reports for confirmation.
- Consider two-way SMS for appointment confirmations/cancellations.
Frequently Asked Questions
How to create appointment reminders with Node.js?
This guide provides a step-by-step process using Node.js, Express, and the Sinch SMS API. You'll build a web application where administrators can schedule appointments and automated SMS reminders are sent to clients at a pre-defined time before the appointment, reducing no-shows and enhancing communication.
What is the Sinch SMS API used for?
The Sinch SMS API is used to schedule and send SMS reminders to clients. The tutorial leverages its built-in scheduling (`send_at`) feature to automate the reminder process. It integrates seamlessly with the Node.js application via the Sinch Node SDK (`@sinch/sdk-core`).
Why use Prisma in appointment reminder system?
Prisma is a modern database toolkit that simplifies database interactions and migrations. In this project, it's used with PostgreSQL, but it's adaptable to other databases as needed. It makes database access and management much easier within the Node.js environment.
When to send appointment reminders via SMS?
The application is designed to send reminders 2 hours before the appointment time. The tutorial uses Luxon for reliable date and time handling to ensure accurate scheduling, and the reminders themselves are scheduled via Sinch's `send_at` parameter.
Can I use a different database with Prisma?
Yes, Prisma supports various databases like PostgreSQL, MySQL, SQLite, SQL Server, and MongoDB. While the tutorial uses PostgreSQL, you can adapt the Prisma setup in your `schema.prisma` file to connect to a different database if required.
How to set up Sinch for appointment reminders?
You'll need a Sinch account with SMS API access. This includes a Project ID, API Access Key (ID and Secret), and a Sinch phone number. Also, verify a recipient number for testing. You'll configure these credentials in your .env file.
What is the role of Luxon in the application?
Luxon is a powerful date and time handling library. It's crucial for accurate scheduling logic, especially calculating and managing the reminder time relative to the appointment time, considering time zones, and formatting for the Sinch API.
How to configure environment variables in Node.js?
Environment variables are stored in a `.env` file in the project root. This file contains sensitive information like API keys and database URLs, which should never be committed to version control. The `dotenv` package loads these variables into the application's environment.
What are the prerequisites for this tutorial?
You need Node.js, npm or yarn, a Sinch account with SMS API access, a verified recipient phone number on your Sinch account, basic Node.js and Express knowledge, access to a PostgreSQL database, and understanding of command-line operations.
How to handle timezones in appointment scheduling?
While the tutorial uses server's local time, ideally you should store appointment times in UTC. Capture the user's timezone on the frontend and send it with the form data. On the backend, use Luxon to parse the date/time with the user's timezone before converting to UTC for database storage.
How to validate phone numbers for Sinch SMS?
While the provided code includes basic formatting, use a dedicated library like `libphonenumber-js` for robust validation. Parse and format numbers to E.164 format (+CountryCodeNumber) to ensure Sinch compatibility.
What is the purpose of the .gitignore file?
The .gitignore file specifies files and directories that should be excluded from version control (Git). This includes sensitive data like the .env file (environment variables), dependency folders (node_modules), and operating-system specific files.
How to deploy the appointment reminder application?
Deployment involves choosing a hosting platform (PaaS, VPS, or Containers), setting up a production database, securely configuring environment variables, and using a process manager like PM2. The tutorial provides guidance on these steps and further considerations for production readiness.
How to improve error handling for Sinch integration?
Implement retry mechanisms for failed Sinch API calls using exponential backoff. Add a status field to your database to track reminder states (pending, scheduled, failed). Leverage Sinch Delivery Reports to monitor message delivery.
What are some potential enhancements for the app?
Consider adding user authentication, a more polished UI with date/time pickers, a dashboard to view reminders, configurable reminder times, more robust error handling, and thorough testing (unit and integration tests).