This guide provides a step-by-step walkthrough for building a production-ready SMS-based One-Time Password (OTP) verification system using Node.js, Express, and the Sinch Verification API. This adds a crucial layer of security to user authentication flows, commonly used for login verification, password resets, or transaction confirmations.
We will build a simple Express application that allows users to register with a phone number, request an OTP via SMS, and verify that OTP to confirm their identity.
Goals:
- Set up a Node.js Express project for OTP verification.
- Integrate the Sinch Verification API for sending and verifying SMS OTPs using direct API calls.
- Store user data (phone number, verification status) potentially using a database (PostgreSQL with Sequelize shown as an example).
- Implement API endpoints for initiating and verifying OTPs.
- Incorporate essential security practices like rate limiting and secure secret management.
- Provide guidance on error handling, logging, deployment, and testing.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Express: Minimalist web framework for Node.js.
- Sinch Verification API: Service for sending and verifying OTPs via SMS, voice, etc. (We'll focus on SMS).
- axios: Promise-based HTTP client for making API calls to Sinch.
- dotenv: Module to load environment variables from a
.env
file. - (Optional) PostgreSQL & Sequelize: Database and ORM for user data persistence.
- (Optional) express-rate-limit: Middleware for basic brute-force protection.
Prerequisites:
- Node.js and npm (or yarn) installed.
- A Sinch account (https://dashboard.sinch.com/signup).
- Basic understanding of JavaScript, Node.js, Express, and REST APIs.
- (Optional) PostgreSQL installed and running if you choose the database persistence approach.
- (Optional) A code editor like VS Code.
- (Optional) API testing tool like Postman or
curl
.
Flow:
- User enters phone number and requests OTP on Frontend.
- Frontend sends phone number to Backend API (
/request-otp
). - Backend uses an HTTP client (axios) to call the Sinch Verification API, requesting an OTP for the user's phone number.
- Sinch API sends the OTP code via SMS to the User's Phone.
- User receives SMS, enters OTP into Frontend.
- Frontend sends phone number and OTP code to Backend API (
/verify-otp
). Backend uses the HTTP client to call the Sinch Verification API to verify the code. Optional: Backend updates user verification status in Database. Backend sends success/failure response to Frontend.
Final Outcome:
By the end of this guide, you will have a functional Node.js Express application capable of sending SMS OTPs via Sinch and verifying them using direct API calls, forming the basis of a secure 2FA system.
1. Setting up the project
Let's start by creating our project directory and initializing it with npm.
Step 1: Create Project Directory
Open your terminal and create a new directory for the project, then navigate into it:
mkdir node-sinch-otp
cd node-sinch-otp
Step 2: Initialize npm
Initialize the project using npm. The -y
flag accepts default settings.
npm init -y
This creates a package.json
file.
Step 3: Install Dependencies
We need Express for our server, dotenv
to manage environment variables, and axios
to interact with the Sinch REST API.
# Core dependencies
npm install express dotenv axios
# Optional: Database (Sequelize for Postgres)
npm install pg sequelize sequelize-cli
# Optional: Security
npm install express-rate-limit
# Optional: Development dependency
npm install --save-dev nodemon jest supertest winston
express
: Web framework.dotenv
: Loads environment variables from.env
.axios
: HTTP client to make requests to the Sinch API.pg
: PostgreSQL client for Node.js (used by Sequelize).sequelize
: Promise-based Node.js ORM for Postgres, MySQL, etc.sequelize-cli
: Command-line interface for Sequelize (migrations, seeding).express-rate-limit
: Basic rate limiting middleware.winston
: Logger library (used in later steps).nodemon
: Utility that automatically restarts the server on file changes during development.jest
,supertest
: For unit and integration testing.
Step 4: Configure nodemon
and Test Scripts (Optional)
Open package.json
and add/update scripts:
{
"name": "node-sinch-otp",
"version": "1.0.0",
"description": "Node.js Express app for Sinch SMS OTP",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"start:dev": "nodemon src/server.js",
"test": "jest",
"db:migrate": "npx sequelize-cli db:migrate",
"db:seed:all": "npx sequelize-cli db:seed:all"
},
"keywords": [
"node",
"express",
"sinch",
"otp",
"2fa",
"sms"
],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.x.x",
"dotenv": "^16.x.x",
"express": "^4.x.x",
"express-rate-limit": "^7.x.x",
"pg": "^8.x.x",
"sequelize": "^6.x.x",
"sequelize-cli": "^6.x.x",
"winston": "^3.x.x"
},
"devDependencies": {
"jest": "^29.x.x",
"nodemon": "^3.x.x",
"supertest": "^6.x.x"
}
}
Step 5: Create Project Structure
Organize the project files for better maintainability:
node-sinch-otp/
├── src/
│ ├── controllers/
│ │ └── auth.controller.js
│ ├── services/
│ │ ├── sinch.service.js
│ │ └── user.service.js # (Optional) DB interactions
│ ├── routes/
│ │ ├── auth.routes.js
│ │ └── index.js # Main router
│ ├── config/
│ │ ├── index.js # Centralized config export
│ │ ├── sinch.config.js # Sinch specific config
│ │ ├── db.config.js # (Optional) DB config
│ │ ├── database.js # (Optional) Sequelize-CLI JS config
│ │ └── logger.js # Logger configuration
│ ├── models/ # (Optional) Sequelize models
│ │ └── user.model.js
│ ├── migrations/ # (Optional) DB migrations
│ ├── seeders/ # (Optional) DB seeders
│ ├── utils/ # Utility functions
│ │ └── response.js
│ └── server.js # Main Express server setup
├── .env # Environment variables (DO NOT COMMIT)
├── .env.example # Example environment variables
├── .gitignore # Files/folders to ignore in git
├── .sequelizerc # (Optional) Sequelize-CLI config file
├── package.json
└── package-lock.json
Create these directories and empty files.
Step 6: Create .gitignore
Create a .gitignore
file in the root directory:
# .gitignore
# Dependencies
node_modules/
# Environment variables
.env*
!.env.example
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
error.log
combined.log
# Build output
dist
build
# Operating System Files
.DS_Store
Thumbs.db
# Optional Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.sublime-workspace
# Test results
coverage/
Step 7: Set up Environment Variables (.env
)
Create a .env
file in the root directory.
# .env
PORT=3000
# Sinch Credentials (Obtain from Sinch Dashboard - Verification API)
# IMPORTANT: Replace these placeholders with your ACTUAL credentials!
# These might be called 'Key ID' & 'Key Secret' or 'Application Key' & 'Application Secret'.
# Verify the exact names and requirements in your Sinch dashboard for the Verification API product.
SINCH_KEY_ID=YOUR_SINCH_KEY_ID
SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET
# SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID # Might be needed depending on API/auth method used
SINCH_API_BASE_URL=https://verification.api.sinch.com # Check docs for current base URL
# (Optional) Database Credentials
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_NAME=your_db_name
# (Optional) Logging Level
LOG_LEVEL=info
NODE_ENV=development
CRITICAL: Replace YOUR_SINCH_KEY_ID
and YOUR_SINCH_KEY_SECRET
with your actual credentials obtained from the Sinch dashboard. The application will not work without them. Also, update database credentials if you are using the database option.
Never commit your actual .env
file to version control. Create a .env.example
file with placeholder values to guide other developers.
# .env.example
PORT=3000
SINCH_KEY_ID=REPLACE_WITH_YOUR_SINCH_KEY_ID
SINCH_KEY_SECRET=REPLACE_WITH_YOUR_SINCH_KEY_SECRET
SINCH_API_BASE_URL=https://verification.api.sinch.com
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=sinch_otp_db
LOG_LEVEL=info
NODE_ENV=development
Step 8: Basic Server Setup (src/server.js
)
// src/server.js
require('dotenv').config(); // Load .env variables first
const express = require('express');
const mainRouter = require('./routes'); // Assuming main router is in src/routes/index.js
const { errorHandler } = require('./utils/response'); // Basic error handler
const logger = require('./config/logger'); // Import logger
const { connectDB } = require('./config/db.config'); // Optional DB connection
const app = express();
const port = process.env.PORT || 3000;
// Middlewares
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies
// API Routes
app.use('/api', mainRouter);
// Health Check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
// Global Error Handler Middleware (Should be last)
app.use(errorHandler);
// Start the server
const startServer = async () => {
try {
// Only attempt DB connection if DB host is configured
if (process.env.DB_HOST) {
await connectDB();
}
app.listen(port, () => {
logger.info(`Server running on http://localhost:${port}`);
});
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
}
};
startServer();
module.exports = app; // Export for potential testing
Step 9: Basic Response Utility (src/utils/response.js
)
// src/utils/response.js
const logger = require('../config/logger'); // Import logger
/**
* Sends a standardized success response.
* @param {object} res - Express response object.
* @param {number} statusCode - HTTP status code.
* @param {object|array|string} data - Response payload.
* @param {string} [message='Success'] - Optional message.
*/
const successResponse = (res, statusCode, data, message = 'Success') => {
res.status(statusCode).json({
status: 'success',
message,
data,
});
};
/**
* Sends a standardized error response.
* @param {object} res - Express response object.
* @param {number} statusCode - HTTP status code.
* @param {string} message - Error message.
* @param {object} [errorDetails=null] - Optional additional error details (for logging)
*/
const errorResponse = (res, statusCode, message, errorDetails = null) => {
res.status(statusCode).json({
status: 'error',
message,
});
// Log the detailed error internally if provided
if (errorDetails) {
logger.error(`Responding with ${statusCode}: ${message}`, errorDetails);
}
};
/**
* Basic global error handler middleware.
*/
const errorHandler = (err, req, res, next) => {
logger.error("ERROR:", err.stack || err); // Log the error stack
// Default to 500 if no status code is set on the error
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
// Avoid sending sensitive error details to the client in production
const responseMessage = (process.env.NODE_ENV === 'production' && statusCode === 500)
? 'Internal Server Error'
: message;
res.status(statusCode).json({
status: 'error',
message: responseMessage,
});
};
module.exports = {
successResponse,
errorResponse,
errorHandler,
};
Step 10: Set up Logger (src/config/logger.js
)
Create the logger configuration file. Ensure you installed winston
in Step 3.
// src/config/logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }), // Log stack traces
winston.format.splat(),
winston.format.json() // Log in JSON format
),
defaultMeta: { service: 'node-sinch-otp' },
transports: [
// Write all logs with level `error` and below to `error.log`
new winston.transports.File({ filename: 'error.log', level: 'error' }),
// Write all logs with level `info` and below to `combined.log`
new winston.transports.File({ filename: 'combined.log' }),
],
});
// If we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(), // Add colors
winston.format.simple() // Simple format: level: message
)
}));
}
module.exports = logger;
You should now have a runnable basic Express server structure.
2. Implementing Core Functionality (Service Layer)
Now, let's implement the logic for interacting with Sinch using axios
and managing user data (optionally).
Step 1: Configure Sinch Service (src/config/sinch.config.js
and src/services/sinch.service.js
)
First, create a configuration file to load Sinch credentials securely.
// src/config/sinch.config.js
require('dotenv').config();
// Validate essential Sinch environment variables
const requiredSinchVars = ['SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_API_BASE_URL'];
requiredSinchVars.forEach(key => {
if (!process.env[key]) {
console.error(`FATAL ERROR: Missing required Sinch environment variable: ${key}. Check your .env file.`);
process.exit(1); // Exit if essential config is missing
}
});
const sinchConfig = {
keyId: process.env.SINCH_KEY_ID, // Often used as username in Basic Auth
keySecret: process.env.SINCH_KEY_SECRET, // Often used as password in Basic Auth
apiBaseUrl: process.env.SINCH_API_BASE_URL,
// Add other Sinch config if needed
};
module.exports = sinchConfig;
Next, create the service file to encapsulate Sinch API interactions using axios
.
// src/services/sinch.service.js
const axios = require('axios');
const { keyId, keySecret, apiBaseUrl } = require('../config/sinch.config');
const logger = require('../config/logger');
// Create an axios instance for Sinch API calls
const sinchApiClient = axios.create({
baseURL: `${apiBaseUrl}/verification/v1`, // Assuming v1 verification endpoint base
auth: {
username: keyId, // Using Key ID as username for Basic Auth
password: keySecret // Using Key Secret as password for Basic Auth
},
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
/**
* Initiates an SMS OTP verification request via Sinch API.
* @param {string} phoneNumber - User's phone number in E.164 format (e.g., +1xxxxxxxxxx).
* @returns {Promise<object>} - Promise resolving with the verification result details (e.g., { id: verificationId })
* @throws {Error} - Throws error with details if the Sinch API call fails.
*/
const requestOtp = async (phoneNumber) => {
logger.info(`Requesting OTP for ${phoneNumber} via Sinch API`);
const payload = {
identity: { type: 'number', endpoint: phoneNumber },
method: 'sms'
// Add other options like 'reference', 'custom', 'acceptedLanguages' as needed per Sinch docs
// reference: 'your_internal_reference_id',
// custom: 'Your custom message identifier or text'
};
try {
const response = await sinchApiClient.post('/verifications', payload);
logger.info(`Sinch verification started successfully for ${phoneNumber}`, response.data);
// Return relevant info, typically the verification ID
return { id: response.data.id }; // Adjust based on actual Sinch API response structure
} catch (error) {
logger.error('Sinch API Error - Request OTP:', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
config: error.config // Log request details for debugging
});
// Provide a more specific error message
const errorMessage = `Failed to initiate OTP verification with Sinch. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`;
const serviceError = new Error(errorMessage);
serviceError.statusCode = error.response?.status || 500; // Pass status code up if available
serviceError.details = error.response?.data; // Attach details
throw serviceError;
}
};
/**
* Verifies an SMS OTP code via Sinch API using the Verification ID method.
* @param {string} verificationId - The ID received from the requestOtp call.
* @param {string} code - The OTP code entered by the user.
* @returns {Promise<boolean>} - Promise resolving with true if verification is successful, false otherwise.
* @throws {Error} - Throws error if the Sinch API call fails unexpectedly (e.g., network error, server error).
*/
const verifyOtpById = async (verificationId, code) => {
logger.info(`Verifying OTP for verification ID ${verificationId} with code ${code}`);
const payload = {
method: 'sms',
sms: { code: code }
};
try {
// Sinch uses PUT to report verification result against the ID
const response = await sinchApiClient.put(`/verifications/id/${verificationId}`, payload);
logger.info(`Sinch verification report result for ID ${verificationId}:`, response.data);
// Check the actual success status field from the API response
// Common success status is 'SUCCESSFUL', verify this in Sinch docs
return response.data.status === 'SUCCESSFUL';
} catch (error) {
logger.error(`Sinch API Error - Verify OTP by ID ${verificationId}:`, {
message: error.message,
status: error.response?.status,
data: error.response?.data,
});
// Specific handling for common failure cases (like invalid code)
// Check if Sinch returns a specific status code or error message for invalid OTP
// Example: Assuming 4xx status indicates client error (like wrong code) vs 5xx server error
if (error.response && error.response.status >= 400 && error.response.status < 500) {
// Common reasons: Invalid code_ verification expired_ verification already used_ verification not found
logger.warn(`Verification failed for ID ${verificationId}. Status: ${error.response.status}. Reason: ${error.response?.data?.error?.message || 'Client error'}`);
return false; // Treat client-side errors (like wrong code) as verification failure
}
// For other errors (network_ 5xx)_ re-throw a service error
const errorMessage = `Failed to verify OTP code with Sinch for ID ${verificationId}. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`;
const serviceError = new Error(errorMessage);
serviceError.statusCode = error.response?.status || 500;
serviceError.details = error.response?.data;
throw serviceError;
}
};
/**
* Verifies an SMS OTP code via Sinch API using the Phone Number method.
* NOTE: This might require a different endpoint or payload structure depending on the Sinch API version.
* Using the verification ID method (`verifyOtpById`) is generally preferred if the ID is available.
* @param {string} phoneNumber - User's phone number in E.164 format.
* @param {string} code - The OTP code entered by the user.
* @returns {Promise<boolean>} - Promise resolving with true if verification is successful, false otherwise.
* @throws {Error} - Throws error if the Sinch API call fails unexpectedly.
*/
const verifyOtpByNumber = async (phoneNumber, code) => {
logger.info(`Verifying OTP for number ${phoneNumber} with code ${code}`);
const payload = {
method: 'sms',
sms: { code: code }
};
try {
// Sinch often uses PUT to report verification result against the number
const response = await sinchApiClient.put(`/verifications/number/${phoneNumber}`, payload);
logger.info(`Sinch verification report result for number ${phoneNumber}:`, response.data);
// Check the actual success status field from the API response
return response.data.status === 'SUCCESSFUL';
} catch (error) {
logger.error(`Sinch API Error - Verify OTP by Number ${phoneNumber}:`, {
message: error.message,
status: error.response?.status,
data: error.response?.data,
});
// Specific handling for common failure cases (like invalid code)
if (error.response && error.response.status >= 400 && error.response.status < 500) {
logger.warn(`Verification failed for number ${phoneNumber}. Status: ${error.response.status}. Reason: ${error.response?.data?.error?.message || 'Client error'}`);
return false; // Treat client-side errors (like wrong code) as verification failure
}
// For other errors (network_ 5xx)_ re-throw a service error
const errorMessage = `Failed to verify OTP code with Sinch for number ${phoneNumber}. Status: ${error.response?.status || 'N/A'}. Reason: ${error.response?.data?.error?.message || error.message}`;
const serviceError = new Error(errorMessage);
serviceError.statusCode = error.response?.status || 500;
serviceError.details = error.response?.data;
throw serviceError;
}
};
module.exports = {
requestOtp_
// Choose one verification method based on your flow and Sinch API docs:
verifyOtp: verifyOtpByNumber_ // Or verifyOtpById if you store/pass the verification ID
// verifyOtp: verifyOtpById
};
Step 2: (Optional) User Service and Database Setup
If you need to store user information_ set up the database and a user service.
-
(a) Configure Sequelize CLI: Initialize Sequelize if you haven't:
npx sequelize-cli init
This creates
config/config.json
_models/index.js
_ etc. We wantsequelize-cli
to use our.env
variables. Create a.sequelizerc
file in the project root:// .sequelizerc const path = require('path'); module.exports = { 'config': path.resolve('src'_ 'config'_ 'database.js')_ // Path to JS config file 'models-path': path.resolve('src'_ 'models')_ 'seeders-path': path.resolve('src'_ 'seeders')_ 'migrations-path': path.resolve('src'_ 'migrations') };
Now_ create the JS configuration file referenced above (
src/config/database.js
):// src/config/database.js require('dotenv').config(); // Load .env variables // Define config for different environments // Reads from process.env_ ensuring sensitive creds aren't hardcoded module.exports = { development: { username: process.env.DB_USER_ password: process.env.DB_PASSWORD_ database: process.env.DB_NAME_ host: process.env.DB_HOST_ port: process.env.DB_PORT || 5432_ dialect: 'postgres'_ // Or your chosen dialect dialectOptions: { // Example: SSL for production Postgres // ssl: { require: true_ rejectUnauthorized: false } // Adjust as needed } }_ test: { // Configuration for test environment (e.g._ separate test DB) username: process.env.DB_USER_TEST || process.env.DB_USER_ password: process.env.DB_PASSWORD_TEST || process.env.DB_PASSWORD_ database: process.env.DB_NAME_TEST || `${process.env.DB_NAME}_test`_ host: process.env.DB_HOST_TEST || process.env.DB_HOST_ port: process.env.DB_PORT_TEST || 5432_ dialect: 'postgres'_ logging: false_ // Disable logging for tests }_ production: { // Configuration for production environment username: process.env.DB_USER_PROD || process.env.DB_USER_ password: process.env.DB_PASSWORD_PROD || process.env.DB_PASSWORD_ database: process.env.DB_NAME_PROD || process.env.DB_NAME_ host: process.env.DB_HOST_PROD || process.env.DB_HOST_ port: process.env.DB_PORT_PROD || 5432_ dialect: 'postgres'_ logging: false_ // Usually disable verbose logging in prod dialectOptions: { // ssl: { require: true_ rejectUnauthorized: false } // Example for cloud DBs }_ pool: { // Production pool settings max: 10_ min: 1_ acquire: 30000_ idle: 10000 } } };
-
(b) Configure Database Connection (
src/config/db.config.js
): This file sets up the Sequelize instance for the application runtime.// src/config/db.config.js require('dotenv').config(); const { Sequelize } = require('sequelize'); const dbCliConfig = require('./database'); // Load the CLI config const logger = require('./logger'); // Determine the environment (default to development) const env = process.env.NODE_ENV || 'development'; const config = dbCliConfig[env]; // Get the config for the current environment let sequelizeInstance = null; let connectDBFunction = async () => logger.info('DB connection skipped (config incomplete).'); if (config && config.database && config.username && config.host) { sequelizeInstance = new Sequelize(config.database, config.username, config.password, { host: config.host, port: config.port, dialect: config.dialect, logging: (msg) => logger.debug(msg), // Log SQL via logger debug level pool: config.pool || { max: 5, min: 0, acquire: 30000, idle: 10000 }, dialectOptions: config.dialectOptions || {}, }); connectDBFunction = async () => { try { await sequelizeInstance.authenticate(); logger.info('Database connection established successfully.'); // DO NOT use sync() in production. Use migrations. // await sequelizeInstance.sync({ alter: process.env.NODE_ENV === 'development' }); } catch (error) { logger.error('Unable to connect to the database:', error); throw error; // Re-throw error to be caught by server start } }; } else { logger.warn('Database configuration is incomplete. Skipping DB connection setup.'); } module.exports = { sequelize: sequelizeInstance, connectDB: connectDBFunction };
-
(c) Update
server.js
to connect DB: This was already handled in thesrc/server.js
code provided in Section 1, Step 8. It checksprocess.env.DB_HOST
before callingconnectDB
. -
(d) Create User Model (
src/models/user.model.js
):// src/models/user.model.js const { DataTypes } = require('sequelize'); const { sequelize } = require('../config/db.config'); // Use the configured instance const logger = require('../config/logger'); // Check if sequelize instance exists (it might be null if DB config is missing) if (!sequelize) { logger.warn('Sequelize instance is null. Skipping User model definition.'); module.exports = null; // Export null if DB is not configured } else { const User = sequelize.define('User', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, }, phoneNumber: { type: DataTypes.STRING, allowNull: false, unique: true, validate: { // Basic E.164 format check. Consider using google-libphonenumber for robust validation. isE164(value) { if(!/^\+[1-9]\d{1,14}$/.test(value)) { throw new Error('Phone number must be in E.164 format (e.g., +1xxxxxxxxxx)'); } } } }, isVerified: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, }, // Add other fields as needed: name, email, verificationId (optional) etc. // verificationId: { // Optionally store the latest Sinch verification ID // type: DataTypes.STRING, // allowNull: true, // }, }, { timestamps: true, // Automatically adds createdAt and updatedAt tableName: 'Users', // Explicitly define table name }); module.exports = User; }
-
(e) Create Migration File: Use Sequelize CLI. It will now use
src/config/database.js
because of.sequelizerc
.npx sequelize-cli model:generate --name User --attributes phoneNumber:string,isVerified:boolean
Then, edit the generated migration file in
src/migrations/
to match the model precisely (UUID primary key, constraints, timestamps, index). The generated file will have a timestamp in its name (e.g.,YYYYMMDDHHMMSS-create-user.js
).// src/migrations/YYYYMMDDHHMMSS-create-user.js 'use strict'; /** @type {import('sequelize-cli').Migration} */ module.exports = { async up(queryInterface, Sequelize) { await queryInterface.createTable('Users', { id: { allowNull: false, primaryKey: true, type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, }, phoneNumber: { type: Sequelize.STRING, allowNull: false, unique: true, }, isVerified: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, }, // verificationId: { // Add if you included in model // type: Sequelize.STRING, // allowNull: true, // }, createdAt: { allowNull: false, type: Sequelize.DATE, }, updatedAt: { allowNull: false, type: Sequelize.DATE, }, }); // Add an index on phoneNumber for faster lookups await queryInterface.addIndex('Users', ['phoneNumber']); }, async down(queryInterface, Sequelize) { await queryInterface.dropTable('Users'); }, };
-
(f) Run Migrations: The script added to
package.json
earlier (db:migrate
) will work. Ensure your database exists and credentials in.env
are correct for theNODE_ENV
environment (defaults to development).npm run db:migrate
To run migrations for production:
NODE_ENV=production npm run db:migrate
-
(g) Create User Service (
src/services/user.service.js
):// src/services/user.service.js const User = require('../models/user.model'); // Might be null if DB not configured const logger = require('../config/logger'); /** * Finds or creates a user by phone number. * @param {string} phoneNumber - User's phone number in E.164 format. * @returns {Promise<User|null>} - The found or newly created user instance, or null if DB is not configured. * @throws {Error} - If a database operation fails. */ const findOrCreateUser = async (phoneNumber) => { if (!User) { logger.warn('User model not available, skipping findOrCreateUser.'); return null; // Skip if DB model is not loaded } try { const [user, created] = await User.findOrCreate({ where: { phoneNumber: phoneNumber }, defaults: { phoneNumber: phoneNumber, isVerified: false, }, }); if (created) { logger.info(`New user created for ${phoneNumber}`); } else { logger.info(`Existing user found for ${phoneNumber}`); } return user; } catch (error) { logger.error(`Error finding or creating user for ${phoneNumber}:`, error); // Re-throw a more specific error or handle as needed throw new Error(`Database error while processing user ${phoneNumber}.`); } }; /** * Updates a user's verification status. * @param {string} phoneNumber - The phone number of the user to update. * @param {boolean} isVerified - The new verification status. * @returns {Promise<boolean>} - True if the update was successful (at least one row affected), false otherwise or if DB not configured. * @throws {Error} - If a database operation fails. */ const updateUserVerificationStatus = async (phoneNumber, isVerified) => { if (!User) { logger.warn('User model not available, skipping updateUserVerificationStatus.'); return false; } try { const [affectedRows] = await User.update( { isVerified: isVerified }, { where: { phoneNumber: phoneNumber } } ); if (affectedRows > 0) { logger.info(`Updated verification status for ${phoneNumber} to ${isVerified}`); return true; } else { logger.warn(`No user found with phone number ${phoneNumber} to update verification status.`); return false; } } catch (error) { logger.error(`Error updating verification status for ${phoneNumber}:`, error); throw new Error(`Database error while updating verification status for ${phoneNumber}.`); } }; module.exports = { findOrCreateUser, updateUserVerificationStatus, // Add other user-related functions here (e.g., findUserByPhoneNumber) };