code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

text
+-------------+       +------------------------+       +-----------------+       +-----------------+
| 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 (us or eu) 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.

bash
mkdir sinch-appointment-scheduler
cd sinch-appointment-scheduler

1.2. Initialize Node.js Project:

bash
npm init -y

This creates a package.json file with default settings.

1.3. Create Directory Structure:

Create the necessary folders and files:

bash
# 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 File
  • public/: 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:

text
# .gitignore

# Dependencies
node_modules/

# Environment variables
.env

# Prisma
prisma/migrations/*/*.sql
prisma/dev.db
prisma/dev.db-journal

# OS generated files
.DS_Store
Thumbs.db

1.5. Install Dependencies:

Install the necessary npm packages:

bash
npm install express @sinch/sdk-core dotenv ejs luxon @prisma/client connect-flash express-session helmet express-rate-limit
npm install --save-dev prisma nodemon
  • express: The web framework.
  • @sinch/sdk-core: The official Sinch Node.js SDK for interacting with their APIs.
  • dotenv: Loads environment variables from the .env file.
  • 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 by connect-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):

bash
npx prisma init --datasource-provider postgresql

This command does two things:

  1. Creates the prisma directory (if it doesn't exist) and the schema.prisma file.
  2. Creates the .env file (if it doesn't exist) and adds a DATABASE_URL variable 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.

dotenv
# .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:

    1. Log in to the Sinch Customer Dashboard.
    2. Navigate to the "Access Keys" section in the left-hand menu.
    3. Note your Project ID.
    4. If you don't have an Access Key pair, click "Create Key". Copy the Key ID and Secret immediately and store them securely (like in this .env file). The Secret is only shown once.
    5. Find your Sinch phone number under your SMS Service Plan details.
    6. Determine your SMS region (us or eu) based on your account setup. This must match the SINCH_SMS_REGION variable.
  • 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_reminders in the example) exists.

1.8. Configure package.json Scripts:

Add the following scripts to your package.json for easier development and database management:

json
// 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 using nodemon for 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
// 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 Appointment model with fields for patient/doctor names, patient phone number, the appointment time (crucially stored in UTC), and timestamps.
  • reminderSentAt tracks 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.

bash
npm run prisma:migrate:dev

Follow 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.

bash
npm run prisma:generate

This 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.

javascript
// 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 helmet for security headers, express-rate-limit for basic abuse prevention, body parsers, session management, and flash messages.
  • Secure Cookies: The secure cookie flag is enabled in production (requires HTTPS). httpOnly is also set.
  • View Engine: EJS is configured.
  • Routing: Routes from routes.js are mounted.
  • Error Handling: Includes 404 handler and a generic error handler that logs appropriately based on environment and renders an error.ejs view.
  • Server Start: The app.listen call is conditional, allowing app to 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.

javascript
// 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 .env variables 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 scheduleReminder success (redirect with flash message).
    • Handles scheduleReminder failure: 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 via flash.
  • 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.

css
/* 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.

html
<!-- 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.

html
<!-- 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.

html
<!-- 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):

bash
npm run prisma:migrate:dev

5.3. Start the Development Server:

bash
npm run dev

This 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:

  1. Open your web browser and navigate to http://localhost:3000.
  2. You should see the appointment scheduling form.
  3. 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 set DEFAULT_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).
  4. Click "Schedule Reminder".
  5. Expected Outcomes:
    • Success: You should be redirected to the /success page 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.

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):

bash
npx prisma studio

6. 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_URL environment variable points to the production database.

6.3. Environment Variables:

  • Never commit your .env file.
  • 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_SECRET for 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=dev or npm ci --omit=dev.
  • If using TypeScript or a bundler, you would add a build script to package.json (e.g., tsc or webpack) 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 /health endpoint) for load balancers or monitoring systems.

6.9. Security Hardening:

  • Keep dependencies updated (npm audit fix).
  • Review helmet configuration 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., csurf middleware).
  • 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 appointmentTime values 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-js for robust parsing, validation, and formatting of international phone numbers.
    bash
    npm install libphonenumber-js
    javascript
    const { 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 });
    }
  • Error Handling & Retries:
    • Implement a retry mechanism for failed Sinch API calls (e.g., using exponential backoff).
    • Add a status field to the Appointment model (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-cron or 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).