Learn how to build a robust application using Next.js and Node.js to schedule appointments and automatically send SMS reminders via Twilio's Message Scheduling feature. This guide provides a complete walkthrough, from project setup to deployment and verification.
This approach leverages Twilio's built-in scheduling, eliminating the need for custom cron jobs or background workers to manage reminder sending times. You create the appointment, tell Twilio when to send the reminder, and Twilio handles the rest.
Project Overview and Goals
What We're Building:
A web application where users can:
- Schedule future appointments, specifying details like name, phone number, appointment date/time, and time zone.
- Automatically trigger an SMS reminder via Twilio, scheduled to be sent a predefined time (e.g., 1 hour) before the appointment.
Problem Solved:
Automates the process of sending timely appointment reminders, reducing no-shows and improving customer communication without complex scheduling infrastructure on your backend.
Technologies Used:
- Next.js: React framework for frontend UI and API routes (backend logic). Chosen for its integrated frontend/backend development experience, performance optimizations, and ease of deployment.
- Node.js: The runtime environment for Next.js.
- Twilio Programmable Messaging: Used via the official Node.js helper library to send SMS messages.
- Twilio Message Scheduling: A feature of Twilio Messaging used to schedule SMS/MMS messages for future delivery (requires a Messaging Service).
- Prisma: Modern ORM for database access (using PostgreSQL in this guide). Chosen for its type safety, migration management, and developer experience.
- PostgreSQL: A robust open-source relational database.
- Zod: TypeScript-first schema declaration and validation library. Used for validating API request payloads.
System Architecture:
The following diagram illustrates the flow:
graph LR
A[User's Browser] -- HTTP Request --> B(Next.js App / Vercel);
B -- Enters Appointment Details --> C{Frontend UI (React)};
C -- POST /api/appointments --> D[Next.js API Route];
D -- Validate Input --> D;
D -- Save Appointment (UTC) --> E{Prisma Client};
E -- Writes to --> F[(PostgreSQL DB)];
D -- Calculate Reminder Time --> D;
D -- Schedule SMS --> G[Twilio Client];
G -- Send Schedule Request --> H(Twilio API / Message Scheduling);
%% Styling
style A fill:#f9f,stroke:#333,stroke-width:2px;
style B fill:#ccf,stroke:#333,stroke-width:2px;
style C fill:#cdf,stroke:#333,stroke-width:2px;
style D fill:#cdf,stroke:#333,stroke-width:2px;
style E fill:#f9d,stroke:#333,stroke-width:2px;
style F fill:#ddf,stroke:#333,stroke-width:2px;
style G fill:#f9d,stroke:#333,stroke-width:2px;
style H fill:#f90,stroke:#333,stroke-width:2px;
(Note: The above Mermaid diagram shows the user interacting with the Next.js frontend, which sends data to a Next.js API route. The API route validates input, saves data to PostgreSQL via Prisma, calculates the reminder time, and uses the Twilio client to schedule an SMS via Twilio's Message Scheduling API.)
Outcome:
A functional Next.js application deployed (e.g., on Vercel) capable of accepting appointment details and reliably scheduling SMS reminders via Twilio.
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- A Twilio account (Sign up for free).
- An active Twilio phone number capable of sending SMS (Buy a number).
- A Twilio Messaging Service configured with your Twilio phone number (detailed below).
- Access to a PostgreSQL database (local or cloud-hosted).
- A Vercel account (or similar hosting platform) for deployment.
- Basic understanding of React, Next.js, TypeScript, and REST APIs.
1. Setting up the Project
Let's initialize our Next.js project using TypeScript and install necessary dependencies.
-
Create Next.js App: Open your terminal and run:
npx create-next-app@latest --typescript appointment-reminders-nextjs cd appointment-reminders-nextjs
-
Install Dependencies: We need Prisma for database interactions, the Twilio helper library, and Zod for validation.
npm install prisma @prisma/client twilio zod date-fns date-fns-tz npm install --save-dev @types/node typescript ts-node @types/react @types/react-dom
prisma
: The Prisma CLI tool.@prisma/client
: The auto-generated, type-safe database client.twilio
: Official Twilio Node.js helper library.zod
: For data validation.date-fns
/date-fns-tz
: Robust libraries for date/time manipulation and time zone handling.
-
Initialize Prisma: Set up Prisma with PostgreSQL as the provider.
npx prisma init --datasource-provider postgresql
This creates a
prisma
directory with aschema.prisma
file and a.env
file for your database connection string. -
Configure Environment Variables: Prisma added
DATABASE_URL
to.env
. We also need variables for Twilio. Rename.env
to.env.local
(which Next.js uses and is ignored by Git by default)..env.local
# Prisma Database Connection String (Update with your actual DB connection details) # Example for local PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE # WARNING: Replace YOUR_DB_USER and YOUR_DB_PASSWORD with secure credentials. Do not use defaults in production. DATABASE_URL=""postgresql://YOUR_DB_USER:YOUR_DB_PASSWORD@localhost:5432/reminders"" # Twilio Credentials # Found in your Twilio Console Dashboard: https://www.twilio.com/console TWILIO_ACCOUNT_SID=""ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"" TWILIO_AUTH_TOKEN=""your_auth_token_xxxxxxxxxxxxxx"" # Twilio Messaging Service SID # Found in Console > Messaging > Services. Starts with 'MG'. # MANDATORY for Message Scheduling. TWILIO_MESSAGING_SERVICE_SID=""MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"" # Optional: Your Twilio phone number (mainly for reference/debugging) # The 'From' number is determined by the Messaging Service pool. TWILIO_PHONE_NUMBER=""+15551234567""
DATABASE_URL
: Replace the example with your actual PostgreSQL connection string, ensuring you use strong, unique credentials (YOUR_DB_USER
,YOUR_DB_PASSWORD
) and that the database (reminders
in the example) exists. Never commit default or insecure credentials.TWILIO_ACCOUNT_SID
/TWILIO_AUTH_TOKEN
: Obtain these from your Twilio Console Dashboard under ""Account Info"". Treat the Auth Token like a password – keep it secret.TWILIO_MESSAGING_SERVICE_SID
: This is critical for using Message Scheduling. You must create a Messaging Service and add your Twilio phone number to its sender pool. See Section 4 for detailed steps. Find the SID (starting withMG
) on the Messaging Services page.
-
Project Structure: Your basic structure will look like this:
appointment-reminders-nextjs/ ├── prisma/ │ └── schema.prisma # Database schema definition ├── pages/ │ ├── api/ │ │ └── appointments.ts # API route for creating appointments │ ├── _app.tsx │ └── index.tsx # Frontend page with the form ├── public/ ├── styles/ ├── .env.local # Environment variables (ignored by Git) ├── next.config.js ├── package.json ├── tsconfig.json └── ... other config files
2. Implementing Core Functionality (Frontend Form)
We'll create a simple form on the homepage (pages/index.tsx
) to capture appointment details.
pages/index.tsx
import { useState, FormEvent } from 'react';
import { format } from 'date-fns';
import styles from '../styles/Home.module.css'; // Optional: for basic styling
// Basic list of time zones for the dropdown
const timeZones = Intl.DateTimeFormat().resolvedOptions().timeZone ? // Use browser default if available
[Intl.DateTimeFormat().resolvedOptions().timeZone, 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Berlin']
: ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Berlin']; // Fallback list
export default function Home() {
const [name, setName] = useState('');
const [phoneNumber, setPhoneNumber] = useState('');
const [appointmentDate, setAppointmentDate] = useState(''); // Store as string YYYY-MM-DD
const [appointmentTime, setAppointmentTime] = useState(''); // Store as string HH:MM (24hr)
const [timeZone, setTimeZone] = useState(timeZones[0]); // Default to first timezone
const [statusMessage, setStatusMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setIsLoading(true);
setStatusMessage('');
// Combine date and time into a single string for the API
const localAppointmentTimeString = `${appointmentDate}T${appointmentTime}:00`;
try {
const response = await fetch('/api/appointments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
phoneNumber,
// Send combined datetime string and timezone separately
appointmentTime: localAppointmentTimeString,
timeZone,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to schedule appointment');
}
setStatusMessage(`Appointment for ${name} scheduled! Reminder Message SID: ${result.messageSid}`);
// Clear form
setName('');
setPhoneNumber('');
setAppointmentDate('');
setAppointmentTime('');
setTimeZone(timeZones[0]);
} catch (error: any) {
console.error('Submission error:', error);
setStatusMessage(`Error: ${error.message}`);
} finally {
setIsLoading(false);
}
};
// Get today's date in YYYY-MM-DD format for min attribute
const today = format(new Date(), 'yyyy-MM-dd');
return (
<div className={styles.container}> {/* Optional styling */}
<main className={styles.main}> {/* Optional styling */}
<h1 className={styles.title}>Schedule Appointment Reminder</h1> {/* Optional styling */}
<form onSubmit={handleSubmit} className={styles.form}> {/* Optional styling */}
<div className={styles.formGroup}>
<label htmlFor=""name"">Name:</label>
<input
type=""text""
id=""name""
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className={styles.formGroup}>
<label htmlFor=""phoneNumber"">Phone Number (E.164 format):</label>
<input
type=""tel""
id=""phoneNumber""
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder=""+15551234567""
pattern=""\+[1-9]\d{1,14}"" // Basic E.164 pattern
required
/>
<small>Include country code, e.g., +1 for US/Canada.</small>
</div>
<div className={styles.formGroup}>
<label htmlFor=""appointmentDate"">Appointment Date:</label>
<input
type=""date""
id=""appointmentDate""
value={appointmentDate}
onChange={(e) => setAppointmentDate(e.target.value)}
min={today} // Prevent scheduling in the past
required
/>
</div>
<div className={styles.formGroup}>
<label htmlFor=""appointmentTime"">Appointment Time (24hr):</label>
<input
type=""time""
id=""appointmentTime""
value={appointmentTime}
onChange={(e) => setAppointmentTime(e.target.value)}
required
/>
</div>
<div className={styles.formGroup}>
<label htmlFor=""timeZone"">Time Zone:</label>
<select
id=""timeZone""
value={timeZone}
onChange={(e) => setTimeZone(e.target.value)}
required
>
{timeZones.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div>
<button type=""submit"" disabled={isLoading} className={styles.button}>
{isLoading ? 'Scheduling...' : 'Schedule Reminder'}
</button>
</form>
{statusMessage && <p className={styles.statusMessage}>{statusMessage}</p>}
</main>
</div>
);
}
- This component provides inputs for name, phone number (using
type=""tel""
with a basic E.164 pattern), appointment date (type=""date""
), time (type=""time""
), and a dropdown for the time zone. - It combines date and time inputs before sending them to the API.
- It handles the form submission asynchronously, calls our backend API endpoint (
/api/appointments
), and displays status or error messages. - Basic client-side validation (
required
,pattern
,min
date) is included.
You can add basic CSS in styles/Home.module.css
for better presentation.
3. Building the API Layer
Now, let's create the Next.js API route that handles appointment creation and schedules the Twilio SMS reminder.
pages/api/appointments.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
import Twilio from 'twilio';
import { z } from 'zod';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
import { add, sub, isBefore, isValid, parseISO } from 'date-fns';
const prisma = new PrismaClient();
// Input validation schema using Zod
const appointmentSchema = z.object({
name: z.string().min(1, { message: ""Name cannot be empty"" }),
// Basic E.164 validation - improve as needed for production
phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, { message: ""Invalid phone number format (must be E.164)"" }),
appointmentTime: z.string().refine((time) => isValid(parseISO(time)), { // Check if it's a valid ISO-like string (YYYY-MM-DDTHH:mm:ss)
message: ""Invalid appointment date/time format"",
}),
timeZone: z.string().refine((tz) => { // Basic check if timezone seems valid
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch (ex) {
return false;
}
}, { message: ""Invalid time zone"" }),
});
// Twilio client initialization (ensure environment variables are set!)
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const messagingServiceSid = process.env.TWILIO_MESSAGING_SERVICE_SID;
// Validate environment variables
if (!accountSid || !authToken || !messagingServiceSid) {
console.error(""Twilio environment variables missing!"");
// Avoid creating the client if variables are missing to prevent runtime errors elsewhere
}
// Only create client if variables are present
const twilioClient = accountSid && authToken ? Twilio(accountSid, authToken) : null;
// --- Main API Handler ---
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
if (!twilioClient || !messagingServiceSid) {
return res.status(500).json({ error: ""Twilio configuration missing on server."" });
}
// 1. Validate Input
const validationResult = appointmentSchema.safeParse(req.body);
if (!validationResult.success) {
return res.status(400).json({ error: ""Invalid input"", details: validationResult.error.flatten() });
}
const { name, phoneNumber, appointmentTime: localAppointmentTimeString, timeZone } = validationResult.data;
try {
// 2. Time Zone Conversion & Scheduling Logic
const localAppointmentTime = parseISO(localAppointmentTimeString); // Parse the combined string
const appointmentTimeUTC = zonedTimeToUtc(localAppointmentTime, timeZone);
// Calculate reminder time (e.g., 1 hour before appointment)
const reminderTimeUTC = sub(appointmentTimeUTC, { hours: 1 });
const nowUTC = new Date(); // Current time in UTC
// Twilio scheduling constraints: > 15 minutes, < 7 days from now
const minScheduleTime = add(nowUTC_ { minutes: 16 }); // 1 minute buffer
const maxScheduleTime = add(nowUTC_ { days: 7 });
if (isBefore(reminderTimeUTC_ minScheduleTime)) {
return res.status(400).json({ error: `Calculated reminder time (${reminderTimeUTC.toISOString()}) is too soon. Must be at least 15 minutes from now.` });
}
if (isBefore(maxScheduleTime_ reminderTimeUTC)) {
return res.status(400).json({ error: `Calculated reminder time (${reminderTimeUTC.toISOString()}) is too far in the future. Must be within 7 days from now.` });
}
// 3. Save Appointment to Database (using UTC time)
const appointment = await prisma.appointment.create({
data: {
name_
phoneNumber_
appointmentTime: appointmentTimeUTC_ // Store UTC time
timeZone_ // Store original timezone for context if needed
status: 'SCHEDULED'_ // Initial status
}_
});
// 4. Schedule SMS with Twilio
const reminderBody = `Hi ${name}. Just a reminder that you have an appointment scheduled at ${localAppointmentTimeString} (${timeZone}).`; // Use local time in message
const message = await twilioClient.messages.create({
to: phoneNumber_
messagingServiceSid: messagingServiceSid_ // Use Messaging Service SID!
body: reminderBody_
scheduleType: 'fixed'_
sendAt: reminderTimeUTC.toISOString()_ // Send UTC time in ISO 8601 format
});
// 5. Update appointment record with Twilio Message SID (optional but good practice)
await prisma.appointment.update({
where: { id: appointment.id }_
data: { twilioMessageSid: message.sid }_
});
// 6. Return Success Response
console.log(`Scheduled message SID: ${message.sid} for appointment ID: ${appointment.id}`);
res.status(201).json({
message: ""Appointment scheduled successfully!""_
appointmentId: appointment.id_
messageSid: message.sid_
scheduledSendTimeUTC: reminderTimeUTC.toISOString()_
});
} catch (error: any) {
console.error('API Error:'_ error);
// Handle potential Twilio API errors specifically
if (error.response && error.response.data) {
console.error('Twilio API Error Details:'_ error.response.data);
return res.status(error.status || 500).json({ error: `Twilio Error: ${error.message}`_ details: error.response.data });
}
// Handle potential Prisma errors (e.g._ unique constraint)
if (error.code && error.code.startsWith('P')) { // Prisma error codes start with P
console.error('Prisma Error Details:'_ error);
return res.status(409).json({ error: 'Database error'_ details: error.message }); // 409 Conflict might be appropriate
}
// General server error
return res.status(500).json({ error: 'Internal Server Error'_ details: error.message });
} finally {
await prisma.$disconnect(); // Disconnect Prisma client
}
}
Explanation:
- Imports: Necessary modules from Next.js_ Prisma_ Twilio_ Zod_ and date-fns.
- Prisma Client: Instantiated outside the handler for reuse.
- Zod Schema: Defines the expected shape and validation rules for the incoming request body.
- Twilio Client: Initialized using environment variables. Includes checks to ensure variables are present.
- Handler Function:
- Checks for POST method.
- Validates Twilio configuration.
- Input Validation: Uses
appointmentSchema.safeParse
to validatereq.body
. Returns a 400 error if validation fails. - Time Zone Handling:
- Parses the incoming
localAppointmentTimeString
usingparseISO
. - Converts the local time to UTC using
zonedTimeToUtc
fromdate-fns-tz
_ crucial for consistent storage and scheduling. - Calculates the
reminderTimeUTC
(1 hour beforeappointmentTimeUTC
). - Crucially_ checks if
reminderTimeUTC
is within Twilio's allowed window (more than 15 minutes from now_ less than 7 days from now). Returns a 400 error if not.
- Parses the incoming
- Database Save: Creates a new appointment record in the database using
prisma.appointment.create
_ storing theappointmentTime
in UTC. - Twilio Scheduling:
- Constructs the reminder message body.
- Calls
twilioClient.messages.create
with:to
: The validated phone number.messagingServiceSid
: Mandatory for scheduling. Twilio uses numbers from this service's pool to send the message.body
: The reminder text.scheduleType: 'fixed'
: Specifies a fixed time for sending.sendAt
: The calculatedreminderTimeUTC
converted to ISO 8601 format (required by Twilio).
- Update Record: Updates the newly created appointment with the
message.sid
returned by Twilio. This allows tracking or canceling the scheduled message later if needed. - Response: Returns a 201 status code with the new appointment ID and Twilio message SID upon success.
- Error Handling: Includes a
try...catch
block to handle validation_ database_ Twilio API_ and other server errors_ returning appropriate status codes and messages. Logs errors to the console. - Prisma Disconnect: Ensures the Prisma client connection is closed.
Testing the API Endpoint:
You can use curl
or Postman to test this endpoint directly:
curl -X POST http://localhost:3000/api/appointments \
-H ""Content-Type: application/json"" \
-d '{
""name"": ""Jane Doe""_
""phoneNumber"": ""+15559876543""_
""appointmentTime"": ""2025-05-15T14:30:00""_
""timeZone"": ""America/New_York""
}'
Expected Success Response (JSON):
{
""message"": ""Appointment scheduled successfully!""_
""appointmentId"": ""cl...""_ // Prisma-generated ID
""messageSid"": ""SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx""_ // Twilio Message SID
""scheduledSendTimeUTC"": ""2025-05-15T17:30:00.000Z"" // Example: 14:30 EDT is 18:30 UTC_ reminder 1hr before is 17:30 UTC
}
Expected Error Response (e.g._ Validation Error):
{
""error"": ""Invalid input""_
""details"": {
""fieldErrors"": {
""phoneNumber"": [
""Invalid phone number format (must be E.164)""
]
}_
""formErrors"": []
}
}
4. Integrating with Twilio (Messaging Service Setup)
Using Twilio's Message Scheduling requires a Messaging Service.
Steps to Create and Configure a Messaging Service:
- Navigate to Messaging Services: Go to the Twilio Console -> Messaging -> Services.
- Create Service: Click ""Create Messaging Service"".
- Friendly Name: Enter a name (e.g., ""Appointment Reminders Service"") and click ""Create"".
- Use Case: Select ""Notify my users"" (or another relevant option). Click ""Continue"".
- Add Senders: This is the crucial step.
- Click ""Add Senders"".
- Sender Type: Select ""Phone Number"". Click ""Continue"".
- Select Numbers: Choose the Twilio phone number(s) you purchased and want to use for sending reminders. Check the box next to the number(s).
- Click ""Add Phone Numbers"".
- Integration (Optional): Review settings for incoming messages if needed (not required for sending scheduled reminders). Click ""Step 3: Set up integration"", then ""Step 4: Add compliance info"".
- Compliance (Optional for initial testing): You may need to register for A2P 10DLC if sending to US numbers in production. For testing, you can often skip this initially, but be aware of filtering. Click ""Complete Messaging Service Setup"".
- Find Service SID: Go back to the Messaging Services page. Find the service you just created. The SID (starting with
MG
) is listed there. - Update
.env.local
: Copy thisMG...
SID and paste it as the value forTWILIO_MESSAGING_SERVICE_SID
in your.env.local
file.
Why Messaging Service?
- Required for Scheduling: Twilio's API mandates using a Messaging Service SID when
scheduleType
is set. - Scalability: Easily add/remove numbers, configure geo-matching, sticky sender, etc.
- Compliance: Central point for managing compliance features like A2P 10DLC registration.
API Keys (.env.local
):
Ensure your TWILIO_ACCOUNT_SID
and TWILIO_AUTH_TOKEN
are correctly copied from the Twilio Console Dashboard into your .env.local
file.
5. Error Handling, Logging, and Retries
Error Handling:
- API Route (
appointments.ts
):- Uses Zod for robust input validation, returning 400 errors with details.
- Wraps core logic in
try...catch
. - Catches specific Twilio errors (checking
error.response
,error.status
,error.message
). - Catches specific Prisma errors (checking
error.code
). - Provides a generic 500 error for unexpected issues.
- Returns informative JSON error messages.
Logging:
- Current: Uses
console.log
for successes andconsole.error
for failures within the API route. This is suitable for development and basic Vercel logging. - Production: Consider integrating a dedicated logging service (e.g., Logtail, Datadog, Sentry via Vercel Integrations). These offer structured logging, search, alerting, and retention.
- Twilio Logs: Use the Twilio Console Logs (especially the ""Scheduled"" tab) to monitor scheduled message status, attempts, and errors directly within Twilio.
Retry Mechanisms:
- Twilio Handles Scheduling Retries: Once a message is successfully scheduled via the API (you get a
message.sid
), Twilio manages the queue and any necessary retries for sending the message at the scheduled time based on carrier availability and deliverability factors. You don't need to implement backend retries for the sending part itself. - API Call Retries: If the initial API call to
twilioClient.messages.create
fails (e.g., network issue, temporary Twilio outage), you could implement a retry strategy on your server (e.g., usingasync-retry
package with exponential backoff). However, for this specific use case, it might be simpler to return an error to the user and let them try submitting the form again. Adding server-side retries for the scheduling call adds complexity (e.g., ensuring idempotency).
Testing Error Scenarios:
- Submit the form with invalid data (bad phone number, missing fields, past date).
- Temporarily invalidate
TWILIO_AUTH_TOKEN
in.env.local
to test Twilio auth errors. - Try scheduling a reminder less than 15 minutes or more than 7 days in the future.
- Simulate database errors (e.g., shut down your local DB).
6. Database Schema and Data Layer
Prisma Schema (prisma/schema.prisma
):
Define the Appointment
model.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql""
url = env(""DATABASE_URL"")
}
model Appointment {
id String @id @default(cuid()) // Unique ID for the appointment
name String
phoneNumber String // E.164 format
appointmentTime DateTime // Stored in UTC
timeZone String // Original time zone submitted by user (e.g., ""America/New_York"")
status String? @default(""SCHEDULED"") // e.g., SCHEDULED, REMINDER_SENT, CANCELED
twilioMessageSid String? @unique // The SID of the scheduled Twilio message, unique constraint helps prevent duplicate schedules for the same message.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([appointmentTime]) // Index for querying by time
}
Explanation:
id
: Unique identifier (CUID).name
,phoneNumber
: Customer details.appointmentTime
: The actual time of the appointment, stored as aDateTime
in UTC.timeZone
: The user's original time zone, useful for display or context.status
: Tracks the appointment/reminder state.twilioMessageSid
: Stores the SID returned by Twilio when scheduling the message. Making it@unique
can help prevent accidentally scheduling multiple reminders if your API logic had flaws (though the primary check should be in the application logic).createdAt
,updatedAt
: Standard timestamps.@@index([appointmentTime])
: Adds a database index to efficiently query appointments based on their time.
Migrations:
-
Create Migration: After defining or modifying your schema, create a migration file:
npx prisma migrate dev --name init_appointments
- This command generates SQL migration files in
prisma/migrations/
and applies the changes to your development database.--name
provides a descriptive label for the migration.
- This command generates SQL migration files in
-
Apply Migrations (Production): In a production environment, you typically run:
npx prisma migrate deploy
This applies all pending migrations found in the
prisma/migrations
folder.
Data Layer:
- The data layer logic is handled within the API route (
pages/api/appointments.ts
) using the Prisma Client (prisma.appointment.create
,prisma.appointment.update
). - Prisma Client provides type-safe database access based on your schema.
Sample Data (Optional):
You could create a separate script (prisma/seed.ts
) to populate sample data if needed for testing, using prisma.$connect()
and prisma.appointment.createMany(...)
, then run it with npx prisma db seed
.
7. Security Features
- Input Validation: Zod (
appointmentSchema
) in the API route provides robust validation against the expected data types, formats (basic E.164), and presence of required fields. This prevents malformed data from reaching your database or Twilio. - Environment Variables: API keys and database URLs are stored in
.env.local
, which is not committed to Git, preventing accidental exposure. Ensure these are set securely in your deployment environment. - API Route Protection: The API route only accepts POST requests. Other methods are rejected.
- Rate Limiting (Recommended): Protect your API endpoint from abuse.
- Vercel: Vercel provides built-in rate limiting features on Hobby (limited) and Pro plans. You can configure limits per IP or user token.
- Middleware: Implement rate limiting using Next.js Middleware and libraries like
upstash/ratelimit
orrate-limiter-flexible
.
- Cross-Site Scripting (XSS): React inherently escapes data rendered in JSX, providing basic protection. Avoid using
dangerouslySetInnerHTML
. Ensure any user-provided content displayed back is properly sanitized if not handled by React's escaping. - SQL Injection: Prisma uses parameterized queries by default, preventing SQL injection vulnerabilities.
- Phone Number Validation: The current E.164 regex is basic. For production, consider using a more comprehensive library like
google-libphonenumber
(via its Node.js port) for stricter validation, although this adds complexity. Twilio's Lookup API can also validate numbers but incurs cost.
Testing Security:
- Attempt to POST malformed JSON to the API endpoint.
- Try sending invalid phone numbers or time zones.
- Use security scanning tools (like OWASP ZAP locally, or integrated scanners in CI/CD) to check for common vulnerabilities.
- Review Vercel's security settings and logs for suspicious activity.
8. Handling Special Cases
- Time Zones (Covered in API):
- Collect time zone input from the user.
- Convert the user's local appointment time + time zone to UTC using
date-fns-tz
. - Store the appointment time in UTC in the database.